@mintplayer/ng-spark 22.19.0 → 22.20.0
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/fesm2022/mintplayer-ng-spark-column-filter.mjs +138 -0
- package/fesm2022/mintplayer-ng-spark-column-filter.mjs.map +1 -0
- package/fesm2022/mintplayer-ng-spark-grid.mjs +122 -4
- package/fesm2022/mintplayer-ng-spark-grid.mjs.map +1 -1
- package/fesm2022/mintplayer-ng-spark-models.mjs.map +1 -1
- package/fesm2022/mintplayer-ng-spark-po-form.mjs +1 -1
- package/fesm2022/mintplayer-ng-spark-po-form.mjs.map +1 -1
- package/fesm2022/mintplayer-ng-spark-renderers.mjs.map +1 -1
- package/fesm2022/mintplayer-ng-spark-services.mjs +23 -0
- package/fesm2022/mintplayer-ng-spark-services.mjs.map +1 -1
- package/package.json +6 -2
- package/types/mintplayer-ng-spark-column-filter.d.ts +77 -0
- package/types/mintplayer-ng-spark-grid.d.ts +65 -1
- package/types/mintplayer-ng-spark-models.d.ts +64 -2
- package/types/mintplayer-ng-spark-renderers.d.ts +13 -0
- package/types/mintplayer-ng-spark-services.d.ts +35 -16
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
import * as i0 from '@angular/core';
|
|
2
|
+
import { input, computed, signal, ChangeDetectionStrategy, Component } from '@angular/core';
|
|
3
|
+
import { BsCheckboxComponent } from '@mintplayer/ng-bootstrap/checkbox';
|
|
4
|
+
import { SparkIconComponent } from '@mintplayer/ng-spark/icon';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* The contents of one column's filter panel (#431).
|
|
8
|
+
*
|
|
9
|
+
* Spark supplies its own panel on **every** filterable column rather than using the datatable's
|
|
10
|
+
* built-in one. Two reasons, and the first forces the second:
|
|
11
|
+
*
|
|
12
|
+
* - A `canListDistincts: false` column has no built-in mode that fits. `FilterMode` is
|
|
13
|
+
* `'values' | 'comparison'` and there is no `contains` operator, so a free-text filter cannot be
|
|
14
|
+
* expressed — and resolving `null` from the distinct source is not an escape either, because that
|
|
15
|
+
* means "fall back to the local pass", which is disabled for a `[fetch]`-bound grid. Such a column
|
|
16
|
+
* would render the built-in panel showing its "no values" string: a dead panel.
|
|
17
|
+
* - Since some columns must nest regardless, mixing would put two visually different panels in one
|
|
18
|
+
* grid.
|
|
19
|
+
*
|
|
20
|
+
* What the datatable still owns, and this component must not reimplement: the async distinct source
|
|
21
|
+
* and its debounce, request cancellation, the matching/remaining bucketing, the change event, the
|
|
22
|
+
* document-root overlay, the focus trap and Escape. This is markup over {@link FilterContext}.
|
|
23
|
+
*
|
|
24
|
+
* ⚠️ **Styling is ours.** The panel is portalled out of the grid's subtree, so Bootstrap's
|
|
25
|
+
* `.form-control` and `.btn` do not reach it — they are only styled inside `bs-*` components. Every
|
|
26
|
+
* visual here is explicit.
|
|
27
|
+
*/
|
|
28
|
+
class SparkColumnFilterPanelComponent {
|
|
29
|
+
column = input.required(/* @ts-ignore */
|
|
30
|
+
...(ngDevMode ? [{ debugName: "column" }] : /* istanbul ignore next */ []));
|
|
31
|
+
/** The datatable's bucketed list, or null while nothing has loaded. */
|
|
32
|
+
values = input(null, /* @ts-ignore */
|
|
33
|
+
...(ngDevMode ? [{ debugName: "values" }] : /* istanbul ignore next */ []));
|
|
34
|
+
ctx = input.required(/* @ts-ignore */
|
|
35
|
+
...(ngDevMode ? [{ debugName: "ctx" }] : /* istanbul ignore next */ []));
|
|
36
|
+
/**
|
|
37
|
+
* Optional relabelling for a column with a custom cell renderer.
|
|
38
|
+
*
|
|
39
|
+
* A renderer is a component that paints arbitrary DOM, and a distinct value has no row, so it
|
|
40
|
+
* cannot be instantiated to produce text. The label therefore defaults to the server-computed cell
|
|
41
|
+
* text — a status rendered as a coloured pill lists `Active`, a rating rendered as stars lists `3`.
|
|
42
|
+
* This hook is the escape for the case where that is too raw: a pure function, no DOM, no row.
|
|
43
|
+
*/
|
|
44
|
+
filterLabel = input(null, /* @ts-ignore */
|
|
45
|
+
...(ngDevMode ? [{ debugName: "filterLabel" }] : /* istanbul ignore next */ []));
|
|
46
|
+
/** Whether this column offers a value list at all, or only free text. */
|
|
47
|
+
listsDistincts = computed(() => this.column().canListDistincts !== false, /* @ts-ignore */
|
|
48
|
+
...(ngDevMode ? [{ debugName: "listsDistincts" }] : /* istanbul ignore next */ []));
|
|
49
|
+
/** Selected values, by value. Identity is the value, never the label — two rows may share text. */
|
|
50
|
+
selected = signal([], /* @ts-ignore */
|
|
51
|
+
...(ngDevMode ? [{ debugName: "selected" }] : /* istanbul ignore next */ []));
|
|
52
|
+
inverse = signal(false, /* @ts-ignore */
|
|
53
|
+
...(ngDevMode ? [{ debugName: "inverse" }] : /* istanbul ignore next */ []));
|
|
54
|
+
freeText = signal('', /* @ts-ignore */
|
|
55
|
+
...(ngDevMode ? [{ debugName: "freeText" }] : /* istanbul ignore next */ []));
|
|
56
|
+
hasSelection = computed(() => this.selected().length > 0, /* @ts-ignore */
|
|
57
|
+
...(ngDevMode ? [{ debugName: "hasSelection" }] : /* istanbul ignore next */ []));
|
|
58
|
+
matching = computed(() => this.values()?.matching ?? [], /* @ts-ignore */
|
|
59
|
+
...(ngDevMode ? [{ debugName: "matching" }] : /* istanbul ignore next */ []));
|
|
60
|
+
remaining = computed(() => this.values()?.remaining ?? [], /* @ts-ignore */
|
|
61
|
+
...(ngDevMode ? [{ debugName: "remaining" }] : /* istanbul ignore next */ []));
|
|
62
|
+
hasMore = computed(() => this.values()?.hasMore === true, /* @ts-ignore */
|
|
63
|
+
...(ngDevMode ? [{ debugName: "hasMore" }] : /* istanbul ignore next */ []));
|
|
64
|
+
/**
|
|
65
|
+
* Whether to show the empty state.
|
|
66
|
+
*
|
|
67
|
+
* Covers three states on purpose: nothing loaded yet, nothing matched, and the distinct request
|
|
68
|
+
* failed. The datatable swallows a rejected promise into a null list with no error, so the third
|
|
69
|
+
* is not distinguishable here - and the server already refuses to distinguish "no values" from
|
|
70
|
+
* "you may not list them".
|
|
71
|
+
*/
|
|
72
|
+
isEmpty = computed(() => !this.matching().length && !this.remaining().length, /* @ts-ignore */
|
|
73
|
+
...(ngDevMode ? [{ debugName: "isEmpty" }] : /* istanbul ignore next */ []));
|
|
74
|
+
labelFor(value) {
|
|
75
|
+
const relabel = this.filterLabel();
|
|
76
|
+
return relabel ? relabel(value.value) : value.label;
|
|
77
|
+
}
|
|
78
|
+
isChecked(value) {
|
|
79
|
+
return this.selected().some(v => sameValue(v.value, value.value));
|
|
80
|
+
}
|
|
81
|
+
toggle(value, checked) {
|
|
82
|
+
const next = checked
|
|
83
|
+
? [...this.selected(), value]
|
|
84
|
+
: this.selected().filter(v => !sameValue(v.value, value.value));
|
|
85
|
+
this.selected.set(next);
|
|
86
|
+
this.ctx().apply(next, this.inverse());
|
|
87
|
+
}
|
|
88
|
+
toggleInverse() {
|
|
89
|
+
// Not a flag on the wire: the grid moves the selected values between `includes` and `excludes`.
|
|
90
|
+
// Applying immediately keeps the panel's state and the grid's in step without an Apply button.
|
|
91
|
+
this.inverse.update(v => !v);
|
|
92
|
+
this.ctx().apply(this.selected(), this.inverse());
|
|
93
|
+
}
|
|
94
|
+
onSearch(term) {
|
|
95
|
+
// Debounced and cancelled by the datatable; re-queried only when it cannot answer locally.
|
|
96
|
+
this.ctx().search(term);
|
|
97
|
+
}
|
|
98
|
+
/**
|
|
99
|
+
* Applies the free-text filter of a column whose values may not be enumerated.
|
|
100
|
+
*
|
|
101
|
+
* It goes through the same `apply` as a checkbox list, carrying one synthetic entry, so the grid
|
|
102
|
+
* has exactly one event shape to translate and no second wire form to maintain.
|
|
103
|
+
*/
|
|
104
|
+
applyFreeText() {
|
|
105
|
+
const text = this.freeText().trim();
|
|
106
|
+
const next = text ? [{ value: text, label: text }] : [];
|
|
107
|
+
this.selected.set(next);
|
|
108
|
+
this.ctx().apply(next, this.inverse());
|
|
109
|
+
}
|
|
110
|
+
clear() {
|
|
111
|
+
this.selected.set([]);
|
|
112
|
+
this.inverse.set(false);
|
|
113
|
+
this.freeText.set('');
|
|
114
|
+
this.ctx().clear();
|
|
115
|
+
}
|
|
116
|
+
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "22.1.3", ngImport: i0, type: SparkColumnFilterPanelComponent, deps: [], target: i0.ɵɵFactoryTarget.Component });
|
|
117
|
+
static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "22.1.3", type: SparkColumnFilterPanelComponent, isStandalone: true, selector: "spark-column-filter-panel", inputs: { column: { classPropertyName: "column", publicName: "column", isSignal: true, isRequired: true, transformFunction: null }, values: { classPropertyName: "values", publicName: "values", isSignal: true, isRequired: false, transformFunction: null }, ctx: { classPropertyName: "ctx", publicName: "ctx", isSignal: true, isRequired: true, transformFunction: null }, filterLabel: { classPropertyName: "filterLabel", publicName: "filterLabel", isSignal: true, isRequired: false, transformFunction: null } }, ngImport: i0, template: "<!--\n \u26A0\uFE0F DOM ORDER IS THE FOCUS ORDER. The overlay resolves initial focus by querying\n `.filter-search, .filter-operand` and falls back to the first tabbable element, so the search box\n is first AND carries that class deliberately. The built-in panel aims focus past its own Clear\n button for the same reason: opening onto a disabled-or-destructive control is the wrong place to\n land, and Clear is disabled exactly when the panel is freshly opened.\n\n No role=\"dialog\" and no aria-label here: the overlay's own `.filter-panel` wrapper already carries\n both, and a second dialog role nested inside the first is worse than none.\n-->\n<div class=\"spark-filter-panel\">\n @if (listsDistincts()) {\n <div class=\"spark-filter-search\">\n <spark-icon name=\"search\" />\n <input\n class=\"filter-search\"\n type=\"search\"\n [attr.aria-label]=\"'Search values'\"\n (input)=\"onSearch($any($event.target).value)\" />\n </div>\n\n <div class=\"spark-filter-body\">\n <!--\n Not a checkbox: it does not select a value, it changes what the selection means.\n aria-pressed is written for both states so the off state is announced too.\n -->\n <button\n type=\"button\"\n class=\"spark-filter-inverse\"\n [class.is-on]=\"inverse()\"\n [attr.aria-pressed]=\"inverse()\"\n [attr.aria-label]=\"'Exclude the selected values'\"\n (click)=\"toggleInverse()\">\n \u2260\n </button>\n\n <div class=\"spark-filter-values\" role=\"group\" [attr.aria-label]=\"'Values of ' + column().name\">\n <!-- `values()` is already narrowed by the search term; re-filtering here would double-apply it. -->\n @for (value of matching(); track value.value) {\n <bs-checkbox\n class=\"spark-filter-value\"\n [isToggled]=\"isChecked(value)\"\n (isToggledChange)=\"toggle(value, $event === true)\">\n {{ labelFor(value) }}\n </bs-checkbox>\n }\n\n <!--\n Values that were present when the filter was first applied but are not any more. Dimmed\n and still selectable, so a selection can be widened without clearing it first. The\n datatable derives this bucket by diffing its own snapshot; the server always sends it empty.\n -->\n @for (value of remaining(); track value.value) {\n <bs-checkbox\n class=\"spark-filter-value is-remaining\"\n [isToggled]=\"isChecked(value)\"\n (isToggledChange)=\"toggle(value, $event === true)\">\n {{ labelFor(value) }}\n </bs-checkbox>\n }\n\n @if (isEmpty()) {\n <!--\n One message for three states: nothing loaded yet, nothing matches, and the distinct\n request failed. The last is deliberately indistinguishable \u2014 a rejected promise surfaces\n as a null list with no error, and the server already refuses to say whether an empty\n answer means \"no values\" or \"you may not list them\".\n -->\n <p class=\"spark-filter-empty\">No values available</p>\n }\n </div>\n </div>\n\n @if (hasMore()) {\n <!-- Words, not an icon: it is the only signal that typing will fetch different values. -->\n <p class=\"spark-filter-more\">More values exist \u2014 refine the search to see them.</p>\n }\n } @else {\n <!--\n A column whose values may not be enumerated. Free text instead of a list, applied through the\n same `apply` so the grid has exactly one event shape to translate.\n -->\n <div class=\"spark-filter-search\">\n <spark-icon name=\"search\" />\n <input\n class=\"filter-search\"\n type=\"search\"\n [value]=\"freeText()\"\n [attr.aria-label]=\"'Filter value'\"\n (input)=\"freeText.set($any($event.target).value)\"\n (keydown.enter)=\"applyFreeText()\" />\n </div>\n <button type=\"button\" class=\"spark-filter-apply\" (click)=\"applyFreeText()\">Apply</button>\n }\n\n <!-- Last, so it is never the landing spot. Kept in the tab order when disabled. -->\n <button type=\"button\" class=\"spark-filter-clear\" [disabled]=\"!hasSelection()\" (click)=\"clear()\">\n <spark-icon name=\"funnel\" />\n <span>Clear the filter on {{ column().name }}</span>\n </button>\n</div>\n", styles: [".spark-filter-panel{display:flex;flex-direction:column;gap:.5rem;min-width:14rem;max-width:22rem;max-height:min(20rem,60vh);padding:.5rem;font-size:.875rem}.spark-filter-clear{display:flex;align-items:center;gap:.375rem;padding:.25rem .375rem;border:0;border-radius:.25rem;background:transparent;color:var(--bs-body-color);text-align:left;cursor:pointer}.spark-filter-clear:hover:not(:disabled){background:var(--bs-secondary-bg)}.spark-filter-clear:disabled{color:var(--bs-secondary-color);cursor:default}.spark-filter-search{display:flex;align-items:center;gap:.375rem;padding:.25rem .5rem;border:1px solid var(--bs-border-color);border-radius:.25rem}.spark-filter-search input{flex:1 1 auto;min-width:0;border:0;outline:0;background:transparent;color:inherit;font:inherit;line-height:1.5}.spark-filter-body{display:flex;flex:1 1 auto;min-height:0;gap:.375rem}.spark-filter-inverse{flex:0 0 auto;align-self:flex-start;width:1.75rem;height:1.75rem;border:1px solid var(--bs-border-color);border-radius:.25rem;background:transparent;color:var(--bs-body-color);cursor:pointer}.spark-filter-inverse.is-on{background:var(--bs-primary);border-color:var(--bs-primary);color:var(--bs-light)}.spark-filter-values{flex:1 1 auto;min-height:0;overflow-y:auto;display:flex;flex-direction:column;min-width:0}.spark-filter-value{padding:.125rem 0}.spark-filter-value.is-remaining{color:var(--bs-secondary-color)}.spark-filter-empty,.spark-filter-more{margin:0;color:var(--bs-secondary-color)}.spark-filter-apply{align-self:flex-start;padding:.25rem .75rem;border:1px solid var(--bs-primary);border-radius:.25rem;background:var(--bs-primary);color:var(--bs-light);cursor:pointer}\n"], dependencies: [{ kind: "component", type: BsCheckboxComponent, selector: "bs-checkbox", inputs: ["type", "isToggled", "indeterminate", "name", "value", "group"], outputs: ["isToggledChange", "indeterminateChange"] }, { kind: "component", type: SparkIconComponent, selector: "spark-icon", inputs: ["name"] }], changeDetection: i0.ChangeDetectionStrategy.OnPush });
|
|
118
|
+
}
|
|
119
|
+
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.1.3", ngImport: i0, type: SparkColumnFilterPanelComponent, decorators: [{
|
|
120
|
+
type: Component,
|
|
121
|
+
args: [{ selector: 'spark-column-filter-panel', standalone: true, imports: [BsCheckboxComponent, SparkIconComponent], changeDetection: ChangeDetectionStrategy.OnPush, template: "<!--\n \u26A0\uFE0F DOM ORDER IS THE FOCUS ORDER. The overlay resolves initial focus by querying\n `.filter-search, .filter-operand` and falls back to the first tabbable element, so the search box\n is first AND carries that class deliberately. The built-in panel aims focus past its own Clear\n button for the same reason: opening onto a disabled-or-destructive control is the wrong place to\n land, and Clear is disabled exactly when the panel is freshly opened.\n\n No role=\"dialog\" and no aria-label here: the overlay's own `.filter-panel` wrapper already carries\n both, and a second dialog role nested inside the first is worse than none.\n-->\n<div class=\"spark-filter-panel\">\n @if (listsDistincts()) {\n <div class=\"spark-filter-search\">\n <spark-icon name=\"search\" />\n <input\n class=\"filter-search\"\n type=\"search\"\n [attr.aria-label]=\"'Search values'\"\n (input)=\"onSearch($any($event.target).value)\" />\n </div>\n\n <div class=\"spark-filter-body\">\n <!--\n Not a checkbox: it does not select a value, it changes what the selection means.\n aria-pressed is written for both states so the off state is announced too.\n -->\n <button\n type=\"button\"\n class=\"spark-filter-inverse\"\n [class.is-on]=\"inverse()\"\n [attr.aria-pressed]=\"inverse()\"\n [attr.aria-label]=\"'Exclude the selected values'\"\n (click)=\"toggleInverse()\">\n \u2260\n </button>\n\n <div class=\"spark-filter-values\" role=\"group\" [attr.aria-label]=\"'Values of ' + column().name\">\n <!-- `values()` is already narrowed by the search term; re-filtering here would double-apply it. -->\n @for (value of matching(); track value.value) {\n <bs-checkbox\n class=\"spark-filter-value\"\n [isToggled]=\"isChecked(value)\"\n (isToggledChange)=\"toggle(value, $event === true)\">\n {{ labelFor(value) }}\n </bs-checkbox>\n }\n\n <!--\n Values that were present when the filter was first applied but are not any more. Dimmed\n and still selectable, so a selection can be widened without clearing it first. The\n datatable derives this bucket by diffing its own snapshot; the server always sends it empty.\n -->\n @for (value of remaining(); track value.value) {\n <bs-checkbox\n class=\"spark-filter-value is-remaining\"\n [isToggled]=\"isChecked(value)\"\n (isToggledChange)=\"toggle(value, $event === true)\">\n {{ labelFor(value) }}\n </bs-checkbox>\n }\n\n @if (isEmpty()) {\n <!--\n One message for three states: nothing loaded yet, nothing matches, and the distinct\n request failed. The last is deliberately indistinguishable \u2014 a rejected promise surfaces\n as a null list with no error, and the server already refuses to say whether an empty\n answer means \"no values\" or \"you may not list them\".\n -->\n <p class=\"spark-filter-empty\">No values available</p>\n }\n </div>\n </div>\n\n @if (hasMore()) {\n <!-- Words, not an icon: it is the only signal that typing will fetch different values. -->\n <p class=\"spark-filter-more\">More values exist \u2014 refine the search to see them.</p>\n }\n } @else {\n <!--\n A column whose values may not be enumerated. Free text instead of a list, applied through the\n same `apply` so the grid has exactly one event shape to translate.\n -->\n <div class=\"spark-filter-search\">\n <spark-icon name=\"search\" />\n <input\n class=\"filter-search\"\n type=\"search\"\n [value]=\"freeText()\"\n [attr.aria-label]=\"'Filter value'\"\n (input)=\"freeText.set($any($event.target).value)\"\n (keydown.enter)=\"applyFreeText()\" />\n </div>\n <button type=\"button\" class=\"spark-filter-apply\" (click)=\"applyFreeText()\">Apply</button>\n }\n\n <!-- Last, so it is never the landing spot. Kept in the tab order when disabled. -->\n <button type=\"button\" class=\"spark-filter-clear\" [disabled]=\"!hasSelection()\" (click)=\"clear()\">\n <spark-icon name=\"funnel\" />\n <span>Clear the filter on {{ column().name }}</span>\n </button>\n</div>\n", styles: [".spark-filter-panel{display:flex;flex-direction:column;gap:.5rem;min-width:14rem;max-width:22rem;max-height:min(20rem,60vh);padding:.5rem;font-size:.875rem}.spark-filter-clear{display:flex;align-items:center;gap:.375rem;padding:.25rem .375rem;border:0;border-radius:.25rem;background:transparent;color:var(--bs-body-color);text-align:left;cursor:pointer}.spark-filter-clear:hover:not(:disabled){background:var(--bs-secondary-bg)}.spark-filter-clear:disabled{color:var(--bs-secondary-color);cursor:default}.spark-filter-search{display:flex;align-items:center;gap:.375rem;padding:.25rem .5rem;border:1px solid var(--bs-border-color);border-radius:.25rem}.spark-filter-search input{flex:1 1 auto;min-width:0;border:0;outline:0;background:transparent;color:inherit;font:inherit;line-height:1.5}.spark-filter-body{display:flex;flex:1 1 auto;min-height:0;gap:.375rem}.spark-filter-inverse{flex:0 0 auto;align-self:flex-start;width:1.75rem;height:1.75rem;border:1px solid var(--bs-border-color);border-radius:.25rem;background:transparent;color:var(--bs-body-color);cursor:pointer}.spark-filter-inverse.is-on{background:var(--bs-primary);border-color:var(--bs-primary);color:var(--bs-light)}.spark-filter-values{flex:1 1 auto;min-height:0;overflow-y:auto;display:flex;flex-direction:column;min-width:0}.spark-filter-value{padding:.125rem 0}.spark-filter-value.is-remaining{color:var(--bs-secondary-color)}.spark-filter-empty,.spark-filter-more{margin:0;color:var(--bs-secondary-color)}.spark-filter-apply{align-self:flex-start;padding:.25rem .75rem;border:1px solid var(--bs-primary);border-radius:.25rem;background:var(--bs-primary);color:var(--bs-light);cursor:pointer}\n"] }]
|
|
122
|
+
}], propDecorators: { column: [{ type: i0.Input, args: [{ isSignal: true, alias: "column", required: true }] }], values: [{ type: i0.Input, args: [{ isSignal: true, alias: "values", required: false }] }], ctx: [{ type: i0.Input, args: [{ isSignal: true, alias: "ctx", required: true }] }], filterLabel: [{ type: i0.Input, args: [{ isSignal: true, alias: "filterLabel", required: false }] }] } });
|
|
123
|
+
/**
|
|
124
|
+
* SameValueZero, matching how the datatable keys its list.
|
|
125
|
+
*
|
|
126
|
+
* `NaN` must equal itself or a numeric column's "not a number" entry can be ticked but never
|
|
127
|
+
* unticked, and `===` gets that wrong.
|
|
128
|
+
*/
|
|
129
|
+
function sameValue(a, b) {
|
|
130
|
+
return a === b || (Number.isNaN(a) && Number.isNaN(b));
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Generated bundle index. Do not edit.
|
|
135
|
+
*/
|
|
136
|
+
|
|
137
|
+
export { SparkColumnFilterPanelComponent };
|
|
138
|
+
//# sourceMappingURL=mintplayer-ng-spark-column-filter.mjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"mintplayer-ng-spark-column-filter.mjs","sources":["../../column-filter/src/spark-column-filter-panel.component.ts","../../column-filter/src/spark-column-filter-panel.component.html","../../column-filter/mintplayer-ng-spark-column-filter.ts"],"sourcesContent":["import { ChangeDetectionStrategy, Component, computed, input, signal } from '@angular/core';\nimport { DistinctValue, DistinctValues, FilterContext } from '@mintplayer/ng-bootstrap/datatable';\nimport { QueryColumn } from '@mintplayer/ng-spark/models';\nimport { BsCheckboxComponent } from '@mintplayer/ng-bootstrap/checkbox';\nimport { SparkIconComponent } from '@mintplayer/ng-spark/icon';\n\n/**\n * The contents of one column's filter panel (#431).\n *\n * Spark supplies its own panel on **every** filterable column rather than using the datatable's\n * built-in one. Two reasons, and the first forces the second:\n *\n * - A `canListDistincts: false` column has no built-in mode that fits. `FilterMode` is\n * `'values' | 'comparison'` and there is no `contains` operator, so a free-text filter cannot be\n * expressed — and resolving `null` from the distinct source is not an escape either, because that\n * means \"fall back to the local pass\", which is disabled for a `[fetch]`-bound grid. Such a column\n * would render the built-in panel showing its \"no values\" string: a dead panel.\n * - Since some columns must nest regardless, mixing would put two visually different panels in one\n * grid.\n *\n * What the datatable still owns, and this component must not reimplement: the async distinct source\n * and its debounce, request cancellation, the matching/remaining bucketing, the change event, the\n * document-root overlay, the focus trap and Escape. This is markup over {@link FilterContext}.\n *\n * ⚠️ **Styling is ours.** The panel is portalled out of the grid's subtree, so Bootstrap's\n * `.form-control` and `.btn` do not reach it — they are only styled inside `bs-*` components. Every\n * visual here is explicit.\n */\n@Component({\n selector: 'spark-column-filter-panel',\n standalone: true,\n imports: [BsCheckboxComponent, SparkIconComponent],\n templateUrl: './spark-column-filter-panel.component.html',\n styleUrl: './spark-column-filter-panel.component.scss',\n changeDetection: ChangeDetectionStrategy.OnPush,\n})\nexport class SparkColumnFilterPanelComponent {\n readonly column = input.required<QueryColumn>();\n\n /** The datatable's bucketed list, or null while nothing has loaded. */\n readonly values = input<DistinctValues | null>(null);\n\n readonly ctx = input.required<FilterContext>();\n\n /**\n * Optional relabelling for a column with a custom cell renderer.\n *\n * A renderer is a component that paints arbitrary DOM, and a distinct value has no row, so it\n * cannot be instantiated to produce text. The label therefore defaults to the server-computed cell\n * text — a status rendered as a coloured pill lists `Active`, a rating rendered as stars lists `3`.\n * This hook is the escape for the case where that is too raw: a pure function, no DOM, no row.\n */\n readonly filterLabel = input<((value: unknown) => string) | null>(null);\n\n /** Whether this column offers a value list at all, or only free text. */\n protected readonly listsDistincts = computed(() => this.column().canListDistincts !== false);\n\n /** Selected values, by value. Identity is the value, never the label — two rows may share text. */\n private readonly selected = signal<DistinctValue[]>([]);\n\n protected readonly inverse = signal(false);\n\n protected readonly freeText = signal('');\n\n protected readonly hasSelection = computed(() => this.selected().length > 0);\n\n protected readonly matching = computed(() => this.values()?.matching ?? []);\n protected readonly remaining = computed(() => this.values()?.remaining ?? []);\n protected readonly hasMore = computed(() => this.values()?.hasMore === true);\n\n /**\n * Whether to show the empty state.\n *\n * Covers three states on purpose: nothing loaded yet, nothing matched, and the distinct request\n * failed. The datatable swallows a rejected promise into a null list with no error, so the third\n * is not distinguishable here - and the server already refuses to distinguish \"no values\" from\n * \"you may not list them\".\n */\n protected readonly isEmpty = computed(() => !this.matching().length && !this.remaining().length);\n\n protected labelFor(value: DistinctValue): string {\n const relabel = this.filterLabel();\n return relabel ? relabel(value.value) : value.label;\n }\n\n protected isChecked(value: DistinctValue): boolean {\n return this.selected().some(v => sameValue(v.value, value.value));\n }\n\n protected toggle(value: DistinctValue, checked: boolean): void {\n const next = checked\n ? [...this.selected(), value]\n : this.selected().filter(v => !sameValue(v.value, value.value));\n\n this.selected.set(next);\n this.ctx().apply(next, this.inverse());\n }\n\n protected toggleInverse(): void {\n // Not a flag on the wire: the grid moves the selected values between `includes` and `excludes`.\n // Applying immediately keeps the panel's state and the grid's in step without an Apply button.\n this.inverse.update(v => !v);\n this.ctx().apply(this.selected(), this.inverse());\n }\n\n protected onSearch(term: string): void {\n // Debounced and cancelled by the datatable; re-queried only when it cannot answer locally.\n this.ctx().search(term);\n }\n\n /**\n * Applies the free-text filter of a column whose values may not be enumerated.\n *\n * It goes through the same `apply` as a checkbox list, carrying one synthetic entry, so the grid\n * has exactly one event shape to translate and no second wire form to maintain.\n */\n protected applyFreeText(): void {\n const text = this.freeText().trim();\n const next: DistinctValue[] = text ? [{ value: text, label: text }] : [];\n\n this.selected.set(next);\n this.ctx().apply(next, this.inverse());\n }\n\n protected clear(): void {\n this.selected.set([]);\n this.inverse.set(false);\n this.freeText.set('');\n this.ctx().clear();\n }\n}\n\n/**\n * SameValueZero, matching how the datatable keys its list.\n *\n * `NaN` must equal itself or a numeric column's \"not a number\" entry can be ticked but never\n * unticked, and `===` gets that wrong.\n */\nfunction sameValue(a: unknown, b: unknown): boolean {\n return a === b || (Number.isNaN(a as number) && Number.isNaN(b as number));\n}\n","<!--\n ⚠️ DOM ORDER IS THE FOCUS ORDER. The overlay resolves initial focus by querying\n `.filter-search, .filter-operand` and falls back to the first tabbable element, so the search box\n is first AND carries that class deliberately. The built-in panel aims focus past its own Clear\n button for the same reason: opening onto a disabled-or-destructive control is the wrong place to\n land, and Clear is disabled exactly when the panel is freshly opened.\n\n No role=\"dialog\" and no aria-label here: the overlay's own `.filter-panel` wrapper already carries\n both, and a second dialog role nested inside the first is worse than none.\n-->\n<div class=\"spark-filter-panel\">\n @if (listsDistincts()) {\n <div class=\"spark-filter-search\">\n <spark-icon name=\"search\" />\n <input\n class=\"filter-search\"\n type=\"search\"\n [attr.aria-label]=\"'Search values'\"\n (input)=\"onSearch($any($event.target).value)\" />\n </div>\n\n <div class=\"spark-filter-body\">\n <!--\n Not a checkbox: it does not select a value, it changes what the selection means.\n aria-pressed is written for both states so the off state is announced too.\n -->\n <button\n type=\"button\"\n class=\"spark-filter-inverse\"\n [class.is-on]=\"inverse()\"\n [attr.aria-pressed]=\"inverse()\"\n [attr.aria-label]=\"'Exclude the selected values'\"\n (click)=\"toggleInverse()\">\n ≠\n </button>\n\n <div class=\"spark-filter-values\" role=\"group\" [attr.aria-label]=\"'Values of ' + column().name\">\n <!-- `values()` is already narrowed by the search term; re-filtering here would double-apply it. -->\n @for (value of matching(); track value.value) {\n <bs-checkbox\n class=\"spark-filter-value\"\n [isToggled]=\"isChecked(value)\"\n (isToggledChange)=\"toggle(value, $event === true)\">\n {{ labelFor(value) }}\n </bs-checkbox>\n }\n\n <!--\n Values that were present when the filter was first applied but are not any more. Dimmed\n and still selectable, so a selection can be widened without clearing it first. The\n datatable derives this bucket by diffing its own snapshot; the server always sends it empty.\n -->\n @for (value of remaining(); track value.value) {\n <bs-checkbox\n class=\"spark-filter-value is-remaining\"\n [isToggled]=\"isChecked(value)\"\n (isToggledChange)=\"toggle(value, $event === true)\">\n {{ labelFor(value) }}\n </bs-checkbox>\n }\n\n @if (isEmpty()) {\n <!--\n One message for three states: nothing loaded yet, nothing matches, and the distinct\n request failed. The last is deliberately indistinguishable — a rejected promise surfaces\n as a null list with no error, and the server already refuses to say whether an empty\n answer means \"no values\" or \"you may not list them\".\n -->\n <p class=\"spark-filter-empty\">No values available</p>\n }\n </div>\n </div>\n\n @if (hasMore()) {\n <!-- Words, not an icon: it is the only signal that typing will fetch different values. -->\n <p class=\"spark-filter-more\">More values exist — refine the search to see them.</p>\n }\n } @else {\n <!--\n A column whose values may not be enumerated. Free text instead of a list, applied through the\n same `apply` so the grid has exactly one event shape to translate.\n -->\n <div class=\"spark-filter-search\">\n <spark-icon name=\"search\" />\n <input\n class=\"filter-search\"\n type=\"search\"\n [value]=\"freeText()\"\n [attr.aria-label]=\"'Filter value'\"\n (input)=\"freeText.set($any($event.target).value)\"\n (keydown.enter)=\"applyFreeText()\" />\n </div>\n <button type=\"button\" class=\"spark-filter-apply\" (click)=\"applyFreeText()\">Apply</button>\n }\n\n <!-- Last, so it is never the landing spot. Kept in the tab order when disabled. -->\n <button type=\"button\" class=\"spark-filter-clear\" [disabled]=\"!hasSelection()\" (click)=\"clear()\">\n <spark-icon name=\"funnel\" />\n <span>Clear the filter on {{ column().name }}</span>\n </button>\n</div>\n","/**\n * Generated bundle index. Do not edit.\n */\n\nexport * from './index';\n"],"names":[],"mappings":";;;;;AAMA;;;;;;;;;;;;;;;;;;;;;AAqBG;MASU,+BAA+B,CAAA;IACjC,MAAM,GAAG,KAAK,CAAC,QAAQ;+EAAe;;IAGtC,MAAM,GAAG,KAAK,CAAwB,IAAI;+EAAC;IAE3C,GAAG,GAAG,KAAK,CAAC,QAAQ;4EAAiB;AAE9C;;;;;;;AAOG;IACM,WAAW,GAAG,KAAK,CAAsC,IAAI;oFAAC;;AAGpD,IAAA,cAAc,GAAG,QAAQ,CAAC,MAAM,IAAI,CAAC,MAAM,EAAE,CAAC,gBAAgB,KAAK,KAAK;uFAAC;;IAG3E,QAAQ,GAAG,MAAM,CAAkB,EAAE;iFAAC;IAEpC,OAAO,GAAG,MAAM,CAAC,KAAK;gFAAC;IAEvB,QAAQ,GAAG,MAAM,CAAC,EAAE;iFAAC;AAErB,IAAA,YAAY,GAAG,QAAQ,CAAC,MAAM,IAAI,CAAC,QAAQ,EAAE,CAAC,MAAM,GAAG,CAAC;qFAAC;AAEzD,IAAA,QAAQ,GAAG,QAAQ,CAAC,MAAM,IAAI,CAAC,MAAM,EAAE,EAAE,QAAQ,IAAI,EAAE;iFAAC;AACxD,IAAA,SAAS,GAAG,QAAQ,CAAC,MAAM,IAAI,CAAC,MAAM,EAAE,EAAE,SAAS,IAAI,EAAE;kFAAC;AAC1D,IAAA,OAAO,GAAG,QAAQ,CAAC,MAAM,IAAI,CAAC,MAAM,EAAE,EAAE,OAAO,KAAK,IAAI;gFAAC;AAE5E;;;;;;;AAOG;IACgB,OAAO,GAAG,QAAQ,CAAC,MAAM,CAAC,IAAI,CAAC,QAAQ,EAAE,CAAC,MAAM,IAAI,CAAC,IAAI,CAAC,SAAS,EAAE,CAAC,MAAM;gFAAC;AAEtF,IAAA,QAAQ,CAAC,KAAoB,EAAA;AACrC,QAAA,MAAM,OAAO,GAAG,IAAI,CAAC,WAAW,EAAE;AAClC,QAAA,OAAO,OAAO,GAAG,OAAO,CAAC,KAAK,CAAC,KAAK,CAAC,GAAG,KAAK,CAAC,KAAK;IACrD;AAEU,IAAA,SAAS,CAAC,KAAoB,EAAA;QACtC,OAAO,IAAI,CAAC,QAAQ,EAAE,CAAC,IAAI,CAAC,CAAC,IAAI,SAAS,CAAC,CAAC,CAAC,KAAK,EAAE,KAAK,CAAC,KAAK,CAAC,CAAC;IACnE;IAEU,MAAM,CAAC,KAAoB,EAAE,OAAgB,EAAA;QACrD,MAAM,IAAI,GAAG;cACT,CAAC,GAAG,IAAI,CAAC,QAAQ,EAAE,EAAE,KAAK;cAC1B,IAAI,CAAC,QAAQ,EAAE,CAAC,MAAM,CAAC,CAAC,IAAI,CAAC,SAAS,CAAC,CAAC,CAAC,KAAK,EAAE,KAAK,CAAC,KAAK,CAAC,CAAC;AAEjE,QAAA,IAAI,CAAC,QAAQ,CAAC,GAAG,CAAC,IAAI,CAAC;AACvB,QAAA,IAAI,CAAC,GAAG,EAAE,CAAC,KAAK,CAAC,IAAI,EAAE,IAAI,CAAC,OAAO,EAAE,CAAC;IACxC;IAEU,aAAa,GAAA;;;AAGrB,QAAA,IAAI,CAAC,OAAO,CAAC,MAAM,CAAC,CAAC,IAAI,CAAC,CAAC,CAAC;AAC5B,QAAA,IAAI,CAAC,GAAG,EAAE,CAAC,KAAK,CAAC,IAAI,CAAC,QAAQ,EAAE,EAAE,IAAI,CAAC,OAAO,EAAE,CAAC;IACnD;AAEU,IAAA,QAAQ,CAAC,IAAY,EAAA;;QAE7B,IAAI,CAAC,GAAG,EAAE,CAAC,MAAM,CAAC,IAAI,CAAC;IACzB;AAEA;;;;;AAKG;IACO,aAAa,GAAA;QACrB,MAAM,IAAI,GAAG,IAAI,CAAC,QAAQ,EAAE,CAAC,IAAI,EAAE;QACnC,MAAM,IAAI,GAAoB,IAAI,GAAG,CAAC,EAAE,KAAK,EAAE,IAAI,EAAE,KAAK,EAAE,IAAI,EAAE,CAAC,GAAG,EAAE;AAExE,QAAA,IAAI,CAAC,QAAQ,CAAC,GAAG,CAAC,IAAI,CAAC;AACvB,QAAA,IAAI,CAAC,GAAG,EAAE,CAAC,KAAK,CAAC,IAAI,EAAE,IAAI,CAAC,OAAO,EAAE,CAAC;IACxC;IAEU,KAAK,GAAA;AACb,QAAA,IAAI,CAAC,QAAQ,CAAC,GAAG,CAAC,EAAE,CAAC;AACrB,QAAA,IAAI,CAAC,OAAO,CAAC,GAAG,CAAC,KAAK,CAAC;AACvB,QAAA,IAAI,CAAC,QAAQ,CAAC,GAAG,CAAC,EAAE,CAAC;AACrB,QAAA,IAAI,CAAC,GAAG,EAAE,CAAC,KAAK,EAAE;IACpB;uGA7FW,+BAA+B,EAAA,IAAA,EAAA,EAAA,EAAA,MAAA,EAAA,EAAA,CAAA,eAAA,CAAA,SAAA,EAAA,CAAA;AAA/B,IAAA,OAAA,IAAA,GAAA,EAAA,CAAA,oBAAA,CAAA,EAAA,UAAA,EAAA,QAAA,EAAA,OAAA,EAAA,QAAA,EAAA,IAAA,EAAA,+BAA+B,EAAA,YAAA,EAAA,IAAA,EAAA,QAAA,EAAA,2BAAA,EAAA,MAAA,EAAA,EAAA,MAAA,EAAA,EAAA,iBAAA,EAAA,QAAA,EAAA,UAAA,EAAA,QAAA,EAAA,QAAA,EAAA,IAAA,EAAA,UAAA,EAAA,IAAA,EAAA,iBAAA,EAAA,IAAA,EAAA,EAAA,MAAA,EAAA,EAAA,iBAAA,EAAA,QAAA,EAAA,UAAA,EAAA,QAAA,EAAA,QAAA,EAAA,IAAA,EAAA,UAAA,EAAA,KAAA,EAAA,iBAAA,EAAA,IAAA,EAAA,EAAA,GAAA,EAAA,EAAA,iBAAA,EAAA,KAAA,EAAA,UAAA,EAAA,KAAA,EAAA,QAAA,EAAA,IAAA,EAAA,UAAA,EAAA,IAAA,EAAA,iBAAA,EAAA,IAAA,EAAA,EAAA,WAAA,EAAA,EAAA,iBAAA,EAAA,aAAA,EAAA,UAAA,EAAA,aAAA,EAAA,QAAA,EAAA,IAAA,EAAA,UAAA,EAAA,KAAA,EAAA,iBAAA,EAAA,IAAA,EAAA,EAAA,EAAA,QAAA,EAAA,EAAA,EAAA,QAAA,ECpC5C,41IAqGA,EAAA,MAAA,EAAA,CAAA,ooDAAA,CAAA,EAAA,YAAA,EAAA,CAAA,EAAA,IAAA,EAAA,WAAA,EAAA,IAAA,EDtEY,mBAAmB,uLAAE,kBAAkB,EAAA,QAAA,EAAA,YAAA,EAAA,MAAA,EAAA,CAAA,MAAA,CAAA,EAAA,CAAA,EAAA,eAAA,EAAA,EAAA,CAAA,uBAAA,CAAA,MAAA,EAAA,CAAA;;2FAKtC,+BAA+B,EAAA,UAAA,EAAA,CAAA;kBAR3C,SAAS;+BACE,2BAA2B,EAAA,UAAA,EACzB,IAAI,EAAA,OAAA,EACP,CAAC,mBAAmB,EAAE,kBAAkB,CAAC,EAAA,eAAA,EAGjC,uBAAuB,CAAC,MAAM,EAAA,QAAA,EAAA,41IAAA,EAAA,MAAA,EAAA,CAAA,ooDAAA,CAAA,EAAA;;AAkGjD;;;;;AAKG;AACH,SAAS,SAAS,CAAC,CAAU,EAAE,CAAU,EAAA;AACvC,IAAA,OAAO,CAAC,KAAK,CAAC,KAAK,MAAM,CAAC,KAAK,CAAC,CAAW,CAAC,IAAI,MAAM,CAAC,KAAK,CAAC,CAAW,CAAC,CAAC;AAC5E;;AE5IA;;AAEG;;;;"}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { DatatableSettings, BsDatatableComponent, BsDatatableColumnDirective, BsRowTemplateDirective } from '@mintplayer/ng-bootstrap/datatable';
|
|
1
|
+
import { DatatableSettings, BsDatatableComponent, BsDatatableColumnDirective, BsDatatableFilterPanelDirective, BsRowTemplateDirective } from '@mintplayer/ng-bootstrap/datatable';
|
|
2
2
|
import { hasShowedOnFlag, ShowedOn, valueFor, selectionModeFor, parseSelectionRule, filterQueryActions } from '@mintplayer/ng-spark/models';
|
|
3
3
|
import * as i0 from '@angular/core';
|
|
4
4
|
import { inject, Injectable, TemplateRef, input, Directive, computed, ChangeDetectionStrategy, Component, model, output, signal, effect, untracked, contentChildren, viewChild } from '@angular/core';
|
|
@@ -14,6 +14,7 @@ import { BsSpinnerComponent } from '@mintplayer/ng-bootstrap/spinner';
|
|
|
14
14
|
import { SparkQueryRefreshService } from '@mintplayer/ng-spark/client-operations';
|
|
15
15
|
import { ResolveTranslationPipe, QueryCellValuePipe, QueryReferenceChipsPipe, TranslateKeyPipe } from '@mintplayer/ng-spark/pipes';
|
|
16
16
|
import { SparkAttributeDescriptionComponent } from '@mintplayer/ng-spark/attribute-description';
|
|
17
|
+
import { SparkColumnFilterPanelComponent } from '@mintplayer/ng-spark/column-filter';
|
|
17
18
|
import { BsCardComponent, BsCardHeaderComponent } from '@mintplayer/ng-bootstrap/card';
|
|
18
19
|
import { BsPriorityNavComponent, BsPriorityNavItemDirective } from '@mintplayer/ng-bootstrap/priority-nav';
|
|
19
20
|
|
|
@@ -77,6 +78,12 @@ class SparkGridRenderers {
|
|
|
77
78
|
return null;
|
|
78
79
|
return this.registry.find(r => r.name === column.renderer)?.columnComponent ?? null;
|
|
79
80
|
}
|
|
81
|
+
/** The registered filter-label function for a column, or null to use the server-computed text. */
|
|
82
|
+
filterLabelFor(column) {
|
|
83
|
+
if (!column.renderer)
|
|
84
|
+
return null;
|
|
85
|
+
return this.registry.find(r => r.name === column.renderer)?.filterLabel ?? null;
|
|
86
|
+
}
|
|
80
87
|
/**
|
|
81
88
|
* Inputs for a query-grid cell renderer, filtered to what the component actually declares —
|
|
82
89
|
* `NgComponentOutlet` throws on an input the target does not have, which is what lets every
|
|
@@ -658,6 +665,107 @@ class SparkQueryGridComponent {
|
|
|
658
665
|
}));
|
|
659
666
|
this.reload();
|
|
660
667
|
}
|
|
668
|
+
/**
|
|
669
|
+
* Replaces the fetched columns only when they actually differ.
|
|
670
|
+
*
|
|
671
|
+
* Columns ship **once per result**, so every page fetch returns an identical set. Assigning it
|
|
672
|
+
* unconditionally wrote a new array of new objects into the signal each time, re-rendering every
|
|
673
|
+
* header and re-evaluating every `*bsDatatableColumn` input for no change in content.
|
|
674
|
+
*
|
|
675
|
+
* Compared by serialized value rather than by reference: the objects are freshly deserialized from
|
|
676
|
+
* JSON on every response, so reference equality is always false and would defeat the check. A
|
|
677
|
+
* dozen small objects per fetch is nothing beside the request that produced them.
|
|
678
|
+
*
|
|
679
|
+
* ⚠️ This was written believing it fixed a panel-teardown bug (#431 F7). Measured in a browser: it
|
|
680
|
+
* does not. The columns were already identical on every fetch, so the guard hits every time and the
|
|
681
|
+
* teardown had another cause entirely. Kept because avoiding a pointless re-render of every header
|
|
682
|
+
* on every page is worth having on its own — but it is an optimization, not a fix, and the F7
|
|
683
|
+
* question remains open.
|
|
684
|
+
*/
|
|
685
|
+
setFetchedColumns(columns) {
|
|
686
|
+
const current = this.fetchedColumns();
|
|
687
|
+
if (current.length === columns.length && JSON.stringify(current) === JSON.stringify(columns))
|
|
688
|
+
return;
|
|
689
|
+
this.fetchedColumns.set(columns);
|
|
690
|
+
}
|
|
691
|
+
/**
|
|
692
|
+
* The per-column filters currently applied (#431).
|
|
693
|
+
*
|
|
694
|
+
* Read inside `makeFetch`'s closure, like `search`, so a change refetches by producing a new fetch
|
|
695
|
+
* identity rather than by being captured.
|
|
696
|
+
*/
|
|
697
|
+
filters = signal([], /* @ts-ignore */
|
|
698
|
+
...(ngDevMode ? [{ debugName: "filters" }] : /* istanbul ignore next */ []));
|
|
699
|
+
/**
|
|
700
|
+
* The value source behind every column's panel.
|
|
701
|
+
*
|
|
702
|
+
* ⚠️ **Referentially stable, and that is the whole point.** It reads `filters()` when the datatable
|
|
703
|
+
* *calls* it, not when it is built — so it always sees the current filters while never changing
|
|
704
|
+
* identity.
|
|
705
|
+
*
|
|
706
|
+
* This was a `computed` first, on the reasoning that it had to close over the current filters
|
|
707
|
+
* because `DistinctsRequest` carries only `{ column, search, signal }`. That was wrong, and the way
|
|
708
|
+
* it was wrong is worth keeping: a new identity reassigns `[distincts]` on the element, and the
|
|
709
|
+
* component aborts the in-flight request and bumps a generation counter — so a response already on
|
|
710
|
+
* its way is discarded as stale and the list renders empty over a perfectly good answer.
|
|
711
|
+
*
|
|
712
|
+
* Reading the signal at call time removes the race entirely and is simpler besides.
|
|
713
|
+
*
|
|
714
|
+
* The asked-for column's own filter is excluded: a panel must offer the values you could still
|
|
715
|
+
* pick, not only the ones you already picked.
|
|
716
|
+
*/
|
|
717
|
+
distinctsFn = async (request) => {
|
|
718
|
+
const queryId = this.query()?.id;
|
|
719
|
+
if (!queryId)
|
|
720
|
+
return null;
|
|
721
|
+
// Read at call time — never captured.
|
|
722
|
+
const others = this.filters().filter(f => f.name !== request.column);
|
|
723
|
+
// The server's shape IS the datatable's shape, so there is nothing to map.
|
|
724
|
+
return this.sparkService.getDistinctValues(queryId, request.column, {
|
|
725
|
+
search: request.search,
|
|
726
|
+
columns: others,
|
|
727
|
+
parentId: this.parentId(),
|
|
728
|
+
parentType: this.parentType(),
|
|
729
|
+
});
|
|
730
|
+
};
|
|
731
|
+
/**
|
|
732
|
+
* Translates one filter change into the wire shape and refetches.
|
|
733
|
+
*
|
|
734
|
+
* The event always arrives as the `'values'` shape because the panel only ever calls
|
|
735
|
+
* `ctx.apply(...)`; comparison mode is never declared. `inverse` is a flag on the event and two
|
|
736
|
+
* arrays on the wire — the translation is this method's whole job.
|
|
737
|
+
*/
|
|
738
|
+
onFilterChange(detail) {
|
|
739
|
+
if (detail.mode !== 'values')
|
|
740
|
+
return;
|
|
741
|
+
const values = detail.selected.map(v => v.value);
|
|
742
|
+
const next = this.filters().filter(f => f.name !== detail.column);
|
|
743
|
+
if (values.length > 0) {
|
|
744
|
+
next.push(detail.inverse
|
|
745
|
+
? { name: detail.column, excludes: values }
|
|
746
|
+
: { name: detail.column, includes: values });
|
|
747
|
+
}
|
|
748
|
+
this.filters.set(next);
|
|
749
|
+
this.onFilterChanged();
|
|
750
|
+
}
|
|
751
|
+
/**
|
|
752
|
+
* Back to page 1 and a fresh fetch identity, exactly as a new search term does.
|
|
753
|
+
*
|
|
754
|
+
* Both halves matter: the old page number means nothing against a different result set, and the
|
|
755
|
+
* datatable dedupes reloads by `(page, perPage, sort)` — none of which a filter changes — so
|
|
756
|
+
* without a new fetch identity the request is silently never made.
|
|
757
|
+
*/
|
|
758
|
+
onFilterChanged() {
|
|
759
|
+
if (this.hasExternalData())
|
|
760
|
+
return;
|
|
761
|
+
const s = this.settings();
|
|
762
|
+
this.settings.set(new DatatableSettings({
|
|
763
|
+
perPage: { values: s.perPage.values, selected: s.perPage.selected },
|
|
764
|
+
page: { values: [1], selected: 1 },
|
|
765
|
+
sortColumns: s.sortColumns,
|
|
766
|
+
}));
|
|
767
|
+
this.reload();
|
|
768
|
+
}
|
|
661
769
|
async onCustomAction(action) {
|
|
662
770
|
if (action.confirmationMessageKey) {
|
|
663
771
|
const message = this.lang.t(action.confirmationMessageKey) || 'Are you sure?';
|
|
@@ -722,6 +830,9 @@ class SparkQueryGridComponent {
|
|
|
722
830
|
// Ids from the previous query are meaningless against the next one, and would be POSTed as
|
|
723
831
|
// though they belonged to it.
|
|
724
832
|
this.selection.set([]);
|
|
833
|
+
// A filter belongs to the query it was applied to. Surviving a query switch would POST it
|
|
834
|
+
// against the next query, where the column may not exist or may mean something else entirely.
|
|
835
|
+
this.filters.set([]);
|
|
725
836
|
try {
|
|
726
837
|
const [resolvedQuery, entityTypes] = await Promise.all([
|
|
727
838
|
this.sparkService.getQuery(queryId),
|
|
@@ -785,6 +896,9 @@ class SparkQueryGridComponent {
|
|
|
785
896
|
skip: (req.page - 1) * req.perPage,
|
|
786
897
|
take: req.perPage,
|
|
787
898
|
search: this.search() || undefined,
|
|
899
|
+
// Read inside the closure, exactly as `search` is: what forces a refetch is the fetch
|
|
900
|
+
// identity `reload()` creates, not the value captured when this closure was built.
|
|
901
|
+
columns: this.filters(),
|
|
788
902
|
parentId, parentType,
|
|
789
903
|
}).then(r => {
|
|
790
904
|
this.errorMessage.set(null);
|
|
@@ -793,7 +907,7 @@ class SparkQueryGridComponent {
|
|
|
793
907
|
// surface (ShowedOn.Query) and is the same place the sort-column allow-list is checked.
|
|
794
908
|
// ?? [] because a malformed or older response must render an empty grid, not throw inside a
|
|
795
909
|
// computed — where the stack points at the column filter and not at the response that lacked them.
|
|
796
|
-
this.
|
|
910
|
+
this.setFetchedColumns(r.columns ?? []);
|
|
797
911
|
// Per-result, so it is re-read on every page rather than latched from the first.
|
|
798
912
|
this.disabledActions.set(r.disabledActions ?? []);
|
|
799
913
|
return {
|
|
@@ -829,15 +943,19 @@ class SparkQueryGridComponent {
|
|
|
829
943
|
getColumnRendererComponent(column) {
|
|
830
944
|
return this.gridRenderers.columnComponentFor(column);
|
|
831
945
|
}
|
|
946
|
+
/** The renderer's optional filter-label override for this column, or null for the server text. */
|
|
947
|
+
filterLabelFor(column) {
|
|
948
|
+
return this.gridRenderers.filterLabelFor(column);
|
|
949
|
+
}
|
|
832
950
|
getColumnRendererInputs(component, item, column) {
|
|
833
951
|
return this.gridRenderers.columnInputsFor(component, item, column);
|
|
834
952
|
}
|
|
835
953
|
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "22.1.3", ngImport: i0, type: SparkQueryGridComponent, deps: [], target: i0.ɵɵFactoryTarget.Component });
|
|
836
|
-
static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "22.1.3", type: SparkQueryGridComponent, isStandalone: true, selector: "spark-query-grid", inputs: { queryId: { classPropertyName: "queryId", publicName: "queryId", isSignal: true, isRequired: true, transformFunction: null }, parentId: { classPropertyName: "parentId", publicName: "parentId", isSignal: true, isRequired: false, transformFunction: null }, parentType: { classPropertyName: "parentType", publicName: "parentType", isSignal: true, isRequired: false, transformFunction: null }, data: { classPropertyName: "data", publicName: "data", isSignal: true, isRequired: false, transformFunction: null }, search: { classPropertyName: "search", publicName: "search", isSignal: true, isRequired: false, transformFunction: null }, columns: { classPropertyName: "columns", publicName: "columns", isSignal: true, isRequired: false, transformFunction: null }, reloadToken: { classPropertyName: "reloadToken", publicName: "reloadToken", isSignal: true, isRequired: false, transformFunction: null }, settings: { classPropertyName: "settings", publicName: "settings", isSignal: true, isRequired: false, transformFunction: null }, selection: { classPropertyName: "selection", publicName: "selection", isSignal: true, isRequired: false, transformFunction: null }, rowRoute: { classPropertyName: "rowRoute", publicName: "rowRoute", isSignal: true, isRequired: false, transformFunction: null } }, outputs: { settings: "settingsChange", selection: "selectionChange", error: "error", rowClicked: "rowClicked", customActionExecuted: "customActionExecuted" }, ngImport: i0, template: "<!--\n Three explicit states, in this order, and the order is load-bearing.\n\n This template used to open with `@if (query(); as q)` wrapping EVERYTHING, and `query()` is only\n set after loadData's awaits resolve. Three separate bugs fell out of that one line: the spinner\n was unreachable on a first load (it rendered inside the gate, while `loading` starts true and\n `query` starts null), a first-load failure rendered ZERO DOM \u2014 no card, no message, nothing to\n see or report \u2014 and a failed RELOAD left the previous query's rows on screen.\n\n So: `loading` is checked first and outside any dependency on `query`, and the final `@else`\n always renders something. Do not fold these back into one gate, and do not move the spinner\n inside the `query()` branch.\n-->\n@if (loading()) {\n <div class=\"text-center p-3\">\n <bs-spinner />\n </div>\n} @else if (query()) {\n <!-- A page-fetch failure while the grid is already resolved. Without this the catch returns an\n empty page and a failed query is indistinguishable from one that legitimately has no rows. -->\n @if (errorMessage(); as err) {\n <bs-alert [type]=\"colors.danger\" class=\"mb-3\">\n {{ err }}\n </bs-alert>\n }\n\n <!--\n One datatable, two row sources. `[data]` and `[fetch]` are mutually exclusive in\n ng-bootstrap \u2014 binding both makes `[fetch]` win silently \u2014 so exactly one is ever non-null\n here: `fetchFn` is left null whenever `data` is bound. See the class comment for why rows can\n arrive from outside at all (streaming must not put a websocket in every detail page's bundle).\n -->\n <bs-datatable\n [selectionMode]=\"selectionMode()\"\n [(selection)]=\"selection\"\n [virtualScroll]=\"isVirtualScrolling()\"\n [itemSize]=\"40\"\n [isResponsive]=\"isVirtualScrolling()\"\n [data]=\"data()\"\n [fetch]=\"fetchFn()\"\n [(settings)]=\"settings\">\n @for (col of visibleColumns(); track col.name) {\n <div *bsDatatableColumn=\"col.name; sortable: true\">\n {{ (col.label | resolveTranslation) || col.name }}\n <spark-attribute-description [description]=\"col.description\" />\n </div>\n }\n\n <ng-container *bsRowTemplate=\"let item\">\n @let row = $any(item);\n @for (col of visibleColumns(); track col.name; let first = $first) {\n <td>\n @if (row) {\n <!-- The first column links to the row's detail page. Two things about this are\n routinely misread:\n\n 1. `cellContent` is projected INSIDE the anchor, so a custom `renderer` does NOT\n suppress this link \u2014 a renderer that emits its own <a> produces nested anchors\n (invalid HTML) rather than replacing this one.\n 2. `canRead()` is the whole gate, and it is the rights model: `Query` without\n `Read` lists the rows and withholds the link. Withhold `Read` for a query\n whose rows no detail page can load \u2014 a Custom.* query that fabricates rows in\n memory, say.\n 3. `rowRoute` replaces the DESTINATION only, never the gate: returning a route\n from it cannot make a row clickable that `canRead()` withheld, and returning\n null from it suppresses the link for that one row. -->\n @if (first && canRead() && rowRouteFor(row); as route) {\n <a [routerLink]=\"route\"\n (click)=\"rowClicked.emit(row)\">\n <ng-container *ngTemplateOutlet=\"cellContent; context: { $implicit: row, col: col }\"></ng-container>\n </a>\n } @else {\n <ng-container *ngTemplateOutlet=\"cellContent; context: { $implicit: row, col: col }\"></ng-container>\n }\n } @else {\n \n }\n </td>\n }\n </ng-container>\n </bs-datatable>\n} @else {\n <!-- The query could not be resolved. Rendering something keeps the host's layout intact and,\n crucially, makes the failure visible: this branch used to render nothing at all. -->\n <bs-alert [type]=\"colors.danger\" class=\"mb-0\">\n {{ errorMessage() || ('spark.query.unavailable' | t) }}\n </bs-alert>\n}\n\n<ng-template #cellContent let-item let-col=\"col\">\n <!--\n Presentation lives in <spark-grid-cell>, shared with the AsDetail table on the PO detail page.\n Value resolution stays here: a query row is a PersistentObject with an attribute list, and the\n AsDetail table's rows are plain dictionaries, so only the rendering is common.\n\n `link` is deliberately not passed. The first-column link is the grid's own rule and wraps this\n template from outside; setting both would nest anchors.\n -->\n <spark-grid-cell\n [column]=\"col\"\n [display]=\"(col | queryCellValue:item:lookupReferenceOptions())\"\n [rendererValue]=\"rendererValueFor(item, col)\"\n [item]=\"item\"\n [chips]=\"(col | queryReferenceChips:item)\" />\n</ng-template>\n", dependencies: [{ kind: "ngmodule", type: CommonModule }, { kind: "directive", type: i1.NgTemplateOutlet, selector: "[ngTemplateOutlet]", inputs: ["ngTemplateOutletContext", "ngTemplateOutlet", "ngTemplateOutletInjector"] }, { kind: "ngmodule", type: RouterModule }, { kind: "directive", type: i2.RouterLink, selector: "[routerLink]", inputs: ["target", "queryParams", "fragment", "queryParamsHandling", "state", "info", "relativeTo", "preserveFragment", "skipLocationChange", "replaceUrl", "browserUrl", "routerLink"] }, { kind: "component", type: BsAlertComponent, selector: "bs-alert", inputs: ["type", "announce", "isVisible"], outputs: ["isVisibleChange", "afterOpenedOrClosed"] }, { kind: "component", type: BsDatatableComponent, selector: "bs-datatable", inputs: ["columns", "data", "fetch", "settings", "selectionMode", "selectable", "selection", "rowKey", "resizableColumns", "pagination", "virtualScroll", "itemSize", "virtualBuffer", "isResponsive", "compareWith", "tree", "idKey", "childCountKey", "treeIndent", "expandedIds", "selectionStrategy"], outputs: ["settingsChange", "selectionChange", "rowClick", "rowDblClick", "rowContextMenu", "expandedIdsChange", "rowExpand", "rowCollapse"] }, { kind: "directive", type: BsDatatableColumnDirective, selector: "[bsDatatableColumn]", inputs: ["bsDatatableColumn", "bsDatatableColumnSortable"] }, { kind: "directive", type: BsRowTemplateDirective, selector: "[bsRowTemplate]" }, { kind: "component", type: BsSpinnerComponent, selector: "bs-spinner", inputs: ["type", "color"] }, { kind: "component", type: SparkGridCellComponent, selector: "spark-grid-cell", inputs: ["column", "display", "rendererValue", "item", "chips", "link"] }, { kind: "component", type: SparkAttributeDescriptionComponent, selector: "spark-attribute-description", inputs: ["description", "position"] }, { kind: "pipe", type: ResolveTranslationPipe, name: "resolveTranslation" }, { kind: "pipe", type: QueryCellValuePipe, name: "queryCellValue" }, { kind: "pipe", type: QueryReferenceChipsPipe, name: "queryReferenceChips" }, { kind: "pipe", type: TranslateKeyPipe, name: "t" }], changeDetection: i0.ChangeDetectionStrategy.OnPush });
|
|
954
|
+
static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "22.1.3", type: SparkQueryGridComponent, isStandalone: true, selector: "spark-query-grid", inputs: { queryId: { classPropertyName: "queryId", publicName: "queryId", isSignal: true, isRequired: true, transformFunction: null }, parentId: { classPropertyName: "parentId", publicName: "parentId", isSignal: true, isRequired: false, transformFunction: null }, parentType: { classPropertyName: "parentType", publicName: "parentType", isSignal: true, isRequired: false, transformFunction: null }, data: { classPropertyName: "data", publicName: "data", isSignal: true, isRequired: false, transformFunction: null }, search: { classPropertyName: "search", publicName: "search", isSignal: true, isRequired: false, transformFunction: null }, columns: { classPropertyName: "columns", publicName: "columns", isSignal: true, isRequired: false, transformFunction: null }, reloadToken: { classPropertyName: "reloadToken", publicName: "reloadToken", isSignal: true, isRequired: false, transformFunction: null }, settings: { classPropertyName: "settings", publicName: "settings", isSignal: true, isRequired: false, transformFunction: null }, selection: { classPropertyName: "selection", publicName: "selection", isSignal: true, isRequired: false, transformFunction: null }, rowRoute: { classPropertyName: "rowRoute", publicName: "rowRoute", isSignal: true, isRequired: false, transformFunction: null } }, outputs: { settings: "settingsChange", selection: "selectionChange", error: "error", rowClicked: "rowClicked", customActionExecuted: "customActionExecuted" }, ngImport: i0, template: "<!--\n Three explicit states, in this order, and the order is load-bearing.\n\n This template used to open with `@if (query(); as q)` wrapping EVERYTHING, and `query()` is only\n set after loadData's awaits resolve. Three separate bugs fell out of that one line: the spinner\n was unreachable on a first load (it rendered inside the gate, while `loading` starts true and\n `query` starts null), a first-load failure rendered ZERO DOM \u2014 no card, no message, nothing to\n see or report \u2014 and a failed RELOAD left the previous query's rows on screen.\n\n So: `loading` is checked first and outside any dependency on `query`, and the final `@else`\n always renders something. Do not fold these back into one gate, and do not move the spinner\n inside the `query()` branch.\n-->\n@if (loading()) {\n <div class=\"text-center p-3\">\n <bs-spinner />\n </div>\n} @else if (query()) {\n <!-- A page-fetch failure while the grid is already resolved. Without this the catch returns an\n empty page and a failed query is indistinguishable from one that legitimately has no rows. -->\n @if (errorMessage(); as err) {\n <bs-alert [type]=\"colors.danger\" class=\"mb-3\">\n {{ err }}\n </bs-alert>\n }\n\n <!--\n One datatable, two row sources. `[data]` and `[fetch]` are mutually exclusive in\n ng-bootstrap \u2014 binding both makes `[fetch]` win silently \u2014 so exactly one is ever non-null\n here: `fetchFn` is left null whenever `data` is bound. See the class comment for why rows can\n arrive from outside at all (streaming must not put a websocket in every detail page's bundle).\n -->\n <bs-datatable\n [selectionMode]=\"selectionMode()\"\n [(selection)]=\"selection\"\n [virtualScroll]=\"isVirtualScrolling()\"\n [itemSize]=\"40\"\n [isResponsive]=\"isVirtualScrolling()\"\n [data]=\"data()\"\n [fetch]=\"fetchFn()\"\n [distincts]=\"distinctsFn\"\n (filterChange)=\"onFilterChange($event)\"\n [(settings)]=\"settings\">\n @for (col of visibleColumns(); track col.name) {\n <!--\n `canSort`/`canFilter` arrive already resolved by the server (query override \u2192 attribute \u2192\n true), so this only spells the polarity. `!== false` is needed for BOTH, for opposite\n reasons: absent means capable on our wire, while the datatable's own `filterable` defaults\n to false, unlike `sortable`.\n\n \u26A0\uFE0F `filterActive` and `filterSummary` are deliberately NOT bound. Any *bsDatatableColumn\n input change recomputes the column set, which retires the live filter view \u2014 while the web\n component's mount-once guard leaves the panel's nodes in place and never re-renders them.\n Binding them to state our own panel mutates would freeze the panel the instant a value is\n ticked, until it is closed and reopened. The cost is that a filtered column carries no\n visual marker yet, which is worth far less than a panel that dies on first use. See #431 F7.\n -->\n <div *bsDatatableColumn=\"col.name; sortable: col.canSort !== false; filterable: col.canFilter !== false\">\n {{ (col.label | resolveTranslation) || col.name }}\n <spark-attribute-description [description]=\"col.description\" />\n\n @if (col.canFilter !== false) {\n <ng-container *bsDatatableFilterPanel=\"let values; let ctx = ctx\">\n <spark-column-filter-panel\n [column]=\"col\"\n [values]=\"values()\"\n [ctx]=\"ctx\"\n [filterLabel]=\"filterLabelFor(col)\" />\n </ng-container>\n }\n </div>\n }\n\n <ng-container *bsRowTemplate=\"let item\">\n @let row = $any(item);\n @for (col of visibleColumns(); track col.name; let first = $first) {\n <td>\n @if (row) {\n <!-- The first column links to the row's detail page. Two things about this are\n routinely misread:\n\n 1. `cellContent` is projected INSIDE the anchor, so a custom `renderer` does NOT\n suppress this link \u2014 a renderer that emits its own <a> produces nested anchors\n (invalid HTML) rather than replacing this one.\n 2. `canRead()` is the whole gate, and it is the rights model: `Query` without\n `Read` lists the rows and withholds the link. Withhold `Read` for a query\n whose rows no detail page can load \u2014 a Custom.* query that fabricates rows in\n memory, say.\n 3. `rowRoute` replaces the DESTINATION only, never the gate: returning a route\n from it cannot make a row clickable that `canRead()` withheld, and returning\n null from it suppresses the link for that one row. -->\n @if (first && canRead() && rowRouteFor(row); as route) {\n <a [routerLink]=\"route\"\n (click)=\"rowClicked.emit(row)\">\n <ng-container *ngTemplateOutlet=\"cellContent; context: { $implicit: row, col: col }\"></ng-container>\n </a>\n } @else {\n <ng-container *ngTemplateOutlet=\"cellContent; context: { $implicit: row, col: col }\"></ng-container>\n }\n } @else {\n \n }\n </td>\n }\n </ng-container>\n </bs-datatable>\n} @else {\n <!-- The query could not be resolved. Rendering something keeps the host's layout intact and,\n crucially, makes the failure visible: this branch used to render nothing at all. -->\n <bs-alert [type]=\"colors.danger\" class=\"mb-0\">\n {{ errorMessage() || ('spark.query.unavailable' | t) }}\n </bs-alert>\n}\n\n<ng-template #cellContent let-item let-col=\"col\">\n <!--\n Presentation lives in <spark-grid-cell>, shared with the AsDetail table on the PO detail page.\n Value resolution stays here: a query row is a PersistentObject with an attribute list, and the\n AsDetail table's rows are plain dictionaries, so only the rendering is common.\n\n `link` is deliberately not passed. The first-column link is the grid's own rule and wraps this\n template from outside; setting both would nest anchors.\n -->\n <spark-grid-cell\n [column]=\"col\"\n [display]=\"(col | queryCellValue:item:lookupReferenceOptions())\"\n [rendererValue]=\"rendererValueFor(item, col)\"\n [item]=\"item\"\n [chips]=\"(col | queryReferenceChips:item)\" />\n</ng-template>\n", dependencies: [{ kind: "ngmodule", type: CommonModule }, { kind: "directive", type: i1.NgTemplateOutlet, selector: "[ngTemplateOutlet]", inputs: ["ngTemplateOutletContext", "ngTemplateOutlet", "ngTemplateOutletInjector"] }, { kind: "ngmodule", type: RouterModule }, { kind: "directive", type: i2.RouterLink, selector: "[routerLink]", inputs: ["target", "queryParams", "fragment", "queryParamsHandling", "state", "info", "relativeTo", "preserveFragment", "skipLocationChange", "replaceUrl", "browserUrl", "routerLink"] }, { kind: "component", type: BsAlertComponent, selector: "bs-alert", inputs: ["type", "announce", "isVisible"], outputs: ["isVisibleChange", "afterOpenedOrClosed"] }, { kind: "component", type: BsDatatableComponent, selector: "bs-datatable", inputs: ["columns", "data", "fetch", "distincts", "labels", "settings", "selectionMode", "selectable", "selection", "rowKey", "resizableColumns", "pagination", "virtualScroll", "itemSize", "virtualBuffer", "isResponsive", "compareWith", "tree", "idKey", "childCountKey", "treeIndent", "expandedIds", "selectionStrategy"], outputs: ["settingsChange", "selectionChange", "rowClick", "rowDblClick", "rowContextMenu", "expandedIdsChange", "rowExpand", "rowCollapse", "filterChange"] }, { kind: "directive", type: BsDatatableColumnDirective, selector: "[bsDatatableColumn]", inputs: ["bsDatatableColumn", "bsDatatableColumnSortable", "bsDatatableColumnFilterable", "bsDatatableColumnFilterActive", "bsDatatableColumnFilterSummary", "bsDatatableColumnFilterSelection", "bsDatatableColumnFilterMode", "bsDatatableColumnFilterInputType", "bsDatatableColumnFilterOperators"] }, { kind: "directive", type: BsDatatableFilterPanelDirective, selector: "[bsDatatableFilterPanel]" }, { kind: "directive", type: BsRowTemplateDirective, selector: "[bsRowTemplate]" }, { kind: "component", type: BsSpinnerComponent, selector: "bs-spinner", inputs: ["type", "color"] }, { kind: "component", type: SparkGridCellComponent, selector: "spark-grid-cell", inputs: ["column", "display", "rendererValue", "item", "chips", "link"] }, { kind: "component", type: SparkAttributeDescriptionComponent, selector: "spark-attribute-description", inputs: ["description", "position"] }, { kind: "component", type: SparkColumnFilterPanelComponent, selector: "spark-column-filter-panel", inputs: ["column", "values", "ctx", "filterLabel"] }, { kind: "pipe", type: ResolveTranslationPipe, name: "resolveTranslation" }, { kind: "pipe", type: QueryCellValuePipe, name: "queryCellValue" }, { kind: "pipe", type: QueryReferenceChipsPipe, name: "queryReferenceChips" }, { kind: "pipe", type: TranslateKeyPipe, name: "t" }], changeDetection: i0.ChangeDetectionStrategy.OnPush });
|
|
837
955
|
}
|
|
838
956
|
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.1.3", ngImport: i0, type: SparkQueryGridComponent, decorators: [{
|
|
839
957
|
type: Component,
|
|
840
|
-
args: [{ selector: 'spark-query-grid', imports: [CommonModule, RouterModule, BsAlertComponent, BsDatatableComponent, BsDatatableColumnDirective, BsRowTemplateDirective, BsSpinnerComponent, SparkGridCellComponent, ResolveTranslationPipe, QueryCellValuePipe, QueryReferenceChipsPipe, TranslateKeyPipe, SparkAttributeDescriptionComponent], changeDetection: ChangeDetectionStrategy.OnPush, template: "<!--\n Three explicit states, in this order, and the order is load-bearing.\n\n This template used to open with `@if (query(); as q)` wrapping EVERYTHING, and `query()` is only\n set after loadData's awaits resolve. Three separate bugs fell out of that one line: the spinner\n was unreachable on a first load (it rendered inside the gate, while `loading` starts true and\n `query` starts null), a first-load failure rendered ZERO DOM \u2014 no card, no message, nothing to\n see or report \u2014 and a failed RELOAD left the previous query's rows on screen.\n\n So: `loading` is checked first and outside any dependency on `query`, and the final `@else`\n always renders something. Do not fold these back into one gate, and do not move the spinner\n inside the `query()` branch.\n-->\n@if (loading()) {\n <div class=\"text-center p-3\">\n <bs-spinner />\n </div>\n} @else if (query()) {\n <!-- A page-fetch failure while the grid is already resolved. Without this the catch returns an\n empty page and a failed query is indistinguishable from one that legitimately has no rows. -->\n @if (errorMessage(); as err) {\n <bs-alert [type]=\"colors.danger\" class=\"mb-3\">\n {{ err }}\n </bs-alert>\n }\n\n <!--\n One datatable, two row sources. `[data]` and `[fetch]` are mutually exclusive in\n ng-bootstrap \u2014 binding both makes `[fetch]` win silently \u2014 so exactly one is ever non-null\n here: `fetchFn` is left null whenever `data` is bound. See the class comment for why rows can\n arrive from outside at all (streaming must not put a websocket in every detail page's bundle).\n -->\n <bs-datatable\n [selectionMode]=\"selectionMode()\"\n [(selection)]=\"selection\"\n [virtualScroll]=\"isVirtualScrolling()\"\n [itemSize]=\"40\"\n [isResponsive]=\"isVirtualScrolling()\"\n [data]=\"data()\"\n [fetch]=\"fetchFn()\"\n [(settings)]=\"settings\">\n @for (col of visibleColumns(); track col.name) {\n <div *bsDatatableColumn=\"col.name; sortable:
|
|
958
|
+
args: [{ selector: 'spark-query-grid', imports: [CommonModule, RouterModule, BsAlertComponent, BsDatatableComponent, BsDatatableColumnDirective, BsDatatableFilterPanelDirective, BsRowTemplateDirective, BsSpinnerComponent, SparkGridCellComponent, ResolveTranslationPipe, QueryCellValuePipe, QueryReferenceChipsPipe, TranslateKeyPipe, SparkAttributeDescriptionComponent, SparkColumnFilterPanelComponent], changeDetection: ChangeDetectionStrategy.OnPush, template: "<!--\n Three explicit states, in this order, and the order is load-bearing.\n\n This template used to open with `@if (query(); as q)` wrapping EVERYTHING, and `query()` is only\n set after loadData's awaits resolve. Three separate bugs fell out of that one line: the spinner\n was unreachable on a first load (it rendered inside the gate, while `loading` starts true and\n `query` starts null), a first-load failure rendered ZERO DOM \u2014 no card, no message, nothing to\n see or report \u2014 and a failed RELOAD left the previous query's rows on screen.\n\n So: `loading` is checked first and outside any dependency on `query`, and the final `@else`\n always renders something. Do not fold these back into one gate, and do not move the spinner\n inside the `query()` branch.\n-->\n@if (loading()) {\n <div class=\"text-center p-3\">\n <bs-spinner />\n </div>\n} @else if (query()) {\n <!-- A page-fetch failure while the grid is already resolved. Without this the catch returns an\n empty page and a failed query is indistinguishable from one that legitimately has no rows. -->\n @if (errorMessage(); as err) {\n <bs-alert [type]=\"colors.danger\" class=\"mb-3\">\n {{ err }}\n </bs-alert>\n }\n\n <!--\n One datatable, two row sources. `[data]` and `[fetch]` are mutually exclusive in\n ng-bootstrap \u2014 binding both makes `[fetch]` win silently \u2014 so exactly one is ever non-null\n here: `fetchFn` is left null whenever `data` is bound. See the class comment for why rows can\n arrive from outside at all (streaming must not put a websocket in every detail page's bundle).\n -->\n <bs-datatable\n [selectionMode]=\"selectionMode()\"\n [(selection)]=\"selection\"\n [virtualScroll]=\"isVirtualScrolling()\"\n [itemSize]=\"40\"\n [isResponsive]=\"isVirtualScrolling()\"\n [data]=\"data()\"\n [fetch]=\"fetchFn()\"\n [distincts]=\"distinctsFn\"\n (filterChange)=\"onFilterChange($event)\"\n [(settings)]=\"settings\">\n @for (col of visibleColumns(); track col.name) {\n <!--\n `canSort`/`canFilter` arrive already resolved by the server (query override \u2192 attribute \u2192\n true), so this only spells the polarity. `!== false` is needed for BOTH, for opposite\n reasons: absent means capable on our wire, while the datatable's own `filterable` defaults\n to false, unlike `sortable`.\n\n \u26A0\uFE0F `filterActive` and `filterSummary` are deliberately NOT bound. Any *bsDatatableColumn\n input change recomputes the column set, which retires the live filter view \u2014 while the web\n component's mount-once guard leaves the panel's nodes in place and never re-renders them.\n Binding them to state our own panel mutates would freeze the panel the instant a value is\n ticked, until it is closed and reopened. The cost is that a filtered column carries no\n visual marker yet, which is worth far less than a panel that dies on first use. See #431 F7.\n -->\n <div *bsDatatableColumn=\"col.name; sortable: col.canSort !== false; filterable: col.canFilter !== false\">\n {{ (col.label | resolveTranslation) || col.name }}\n <spark-attribute-description [description]=\"col.description\" />\n\n @if (col.canFilter !== false) {\n <ng-container *bsDatatableFilterPanel=\"let values; let ctx = ctx\">\n <spark-column-filter-panel\n [column]=\"col\"\n [values]=\"values()\"\n [ctx]=\"ctx\"\n [filterLabel]=\"filterLabelFor(col)\" />\n </ng-container>\n }\n </div>\n }\n\n <ng-container *bsRowTemplate=\"let item\">\n @let row = $any(item);\n @for (col of visibleColumns(); track col.name; let first = $first) {\n <td>\n @if (row) {\n <!-- The first column links to the row's detail page. Two things about this are\n routinely misread:\n\n 1. `cellContent` is projected INSIDE the anchor, so a custom `renderer` does NOT\n suppress this link \u2014 a renderer that emits its own <a> produces nested anchors\n (invalid HTML) rather than replacing this one.\n 2. `canRead()` is the whole gate, and it is the rights model: `Query` without\n `Read` lists the rows and withholds the link. Withhold `Read` for a query\n whose rows no detail page can load \u2014 a Custom.* query that fabricates rows in\n memory, say.\n 3. `rowRoute` replaces the DESTINATION only, never the gate: returning a route\n from it cannot make a row clickable that `canRead()` withheld, and returning\n null from it suppresses the link for that one row. -->\n @if (first && canRead() && rowRouteFor(row); as route) {\n <a [routerLink]=\"route\"\n (click)=\"rowClicked.emit(row)\">\n <ng-container *ngTemplateOutlet=\"cellContent; context: { $implicit: row, col: col }\"></ng-container>\n </a>\n } @else {\n <ng-container *ngTemplateOutlet=\"cellContent; context: { $implicit: row, col: col }\"></ng-container>\n }\n } @else {\n \n }\n </td>\n }\n </ng-container>\n </bs-datatable>\n} @else {\n <!-- The query could not be resolved. Rendering something keeps the host's layout intact and,\n crucially, makes the failure visible: this branch used to render nothing at all. -->\n <bs-alert [type]=\"colors.danger\" class=\"mb-0\">\n {{ errorMessage() || ('spark.query.unavailable' | t) }}\n </bs-alert>\n}\n\n<ng-template #cellContent let-item let-col=\"col\">\n <!--\n Presentation lives in <spark-grid-cell>, shared with the AsDetail table on the PO detail page.\n Value resolution stays here: a query row is a PersistentObject with an attribute list, and the\n AsDetail table's rows are plain dictionaries, so only the rendering is common.\n\n `link` is deliberately not passed. The first-column link is the grid's own rule and wraps this\n template from outside; setting both would nest anchors.\n -->\n <spark-grid-cell\n [column]=\"col\"\n [display]=\"(col | queryCellValue:item:lookupReferenceOptions())\"\n [rendererValue]=\"rendererValueFor(item, col)\"\n [item]=\"item\"\n [chips]=\"(col | queryReferenceChips:item)\" />\n</ng-template>\n" }]
|
|
841
959
|
}], ctorParameters: () => [], propDecorators: { queryId: [{ type: i0.Input, args: [{ isSignal: true, alias: "queryId", required: true }] }], parentId: [{ type: i0.Input, args: [{ isSignal: true, alias: "parentId", required: false }] }], parentType: [{ type: i0.Input, args: [{ isSignal: true, alias: "parentType", required: false }] }], data: [{ type: i0.Input, args: [{ isSignal: true, alias: "data", required: false }] }], search: [{ type: i0.Input, args: [{ isSignal: true, alias: "search", required: false }] }], columns: [{ type: i0.Input, args: [{ isSignal: true, alias: "columns", required: false }] }], reloadToken: [{ type: i0.Input, args: [{ isSignal: true, alias: "reloadToken", required: false }] }], settings: [{ type: i0.Input, args: [{ isSignal: true, alias: "settings", required: false }] }, { type: i0.Output, args: ["settingsChange"] }], selection: [{ type: i0.Input, args: [{ isSignal: true, alias: "selection", required: false }] }, { type: i0.Output, args: ["selectionChange"] }], rowRoute: [{ type: i0.Input, args: [{ isSignal: true, alias: "rowRoute", required: false }] }], error: [{ type: i0.Output, args: ["error"] }], rowClicked: [{ type: i0.Output, args: ["rowClicked"] }], customActionExecuted: [{ type: i0.Output, args: ["customActionExecuted"] }] } });
|
|
842
960
|
function extractSourceName(source) {
|
|
843
961
|
const dotIndex = source.indexOf('.');
|