spiderly 19.13.0 → 19.14.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.
@@ -1,5 +1,5 @@
1
1
  import * as i0 from '@angular/core';
2
- import { Input, Component, EventEmitter, Output, Injectable, ViewChild, NgModule, PLATFORM_ID, Inject, HostBinding, Directive, inject, ElementRef, NgZone, input, output, LOCALE_ID, signal, computed, Injector, viewChild, effect, TemplateRef, ContentChildren, ContentChild } from '@angular/core';
2
+ import { Input, Component, EventEmitter, Output, Injectable, ViewChild, NgModule, PLATFORM_ID, Inject, HostBinding, Directive, inject, ElementRef, NgZone, signal, computed, input, output, LOCALE_ID, Injector, viewChild, untracked, effect, TemplateRef, ContentChildren, ContentChild } from '@angular/core';
3
3
  import * as i1 from '@jsverse/transloco';
4
4
  import { TranslocoDirective, TranslocoService } from '@jsverse/transloco';
5
5
  import * as i2 from '@angular/common';
@@ -4024,157 +4024,612 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "19.2.13", ngImpo
4024
4024
  }]
4025
4025
  }], ctorParameters: () => [] });
4026
4026
 
4027
+ var MatchModeCodes;
4028
+ (function (MatchModeCodes) {
4029
+ MatchModeCodes["StartsWith"] = "startsWith";
4030
+ MatchModeCodes["Contains"] = "contains";
4031
+ MatchModeCodes["Equals"] = "equals";
4032
+ MatchModeCodes["LessThan"] = "lessThan";
4033
+ MatchModeCodes["GreaterThan"] = "greaterThan";
4034
+ MatchModeCodes["In"] = "in";
4035
+ })(MatchModeCodes || (MatchModeCodes = {}));
4036
+
4027
4037
  /**
4028
- * Renders the editor for ONE filter of a `spiderly-data-table`'s bar, replacing the control the
4029
- * bar would have drawn for that filter's kind. Bind it to the filter's id; filters with no
4030
- * matching template keep the built-in control, so a table opts in one filter at a time.
4038
+ * What each value type may be asked — the ONE operator table, mirroring what the generated
4039
+ * paginator implements per type (`PaginatedResultGenerator`): `In` on a string column answers
4040
+ * with InvalidMatchMode, PrimeNG's own text list adds three modes the backend 400s, and so on.
4031
4041
  *
4032
- * This is the narrow job the directive has. Placing a filter ANYWHERE else — a drawer, a modal,
4033
- * a header cell — needs no directive at all: `store.get(id)` returns the same handle and depends
4034
- * on nothing in the component tree, which is why the store is the consumer's to own.
4042
+ * Deliberately a LEAF module (imports nothing but the codes) so every consumer can derive from
4043
+ * it without a cycle: the store's runtime validation and offered lists (`filter-store.ts`), the
4044
+ * compile-time `FilterRule.matchMode` union (`entities/filter-rule.ts`), and the data view's
4045
+ * kind-level lists (via `operatorOptionsForKind`). These used to be hand-kept copies; edit the
4046
+ * table here and every surface follows.
4047
+ *
4048
+ * Tuple order is DISPLAY order for every operator picker (Equals, then "less/before", then
4049
+ * "more/after" — the order the header dropdowns always shipped).
4035
4050
  */
4036
- class SpiderlyFilterTemplateDirective {
4037
- constructor(template) {
4038
- this.template = template;
4039
- }
4040
- /**
4041
- * Without this the `let-` variable is `any` at every consumer, however well the context is
4042
- * declared — a directive's injected `TemplateRef<C>` never reaches the template that declares
4043
- * it. Same mechanism as `SpiderlyCellTemplateDirective`.
4044
- */
4045
- static ngTemplateContextGuard(dir, ctx) {
4046
- return true;
4047
- }
4048
- static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "19.2.13", ngImport: i0, type: SpiderlyFilterTemplateDirective, deps: [{ token: i0.TemplateRef }], target: i0.ɵɵFactoryTarget.Directive }); }
4049
- static { this.ɵdir = i0.ɵɵngDeclareDirective({ minVersion: "14.0.0", version: "19.2.13", type: SpiderlyFilterTemplateDirective, isStandalone: true, selector: "ng-template[spiderlyFilterTemplate]", inputs: { filterId: ["spiderlyFilterTemplate", "filterId"] }, ngImport: i0 }); }
4050
- }
4051
- i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "19.2.13", ngImport: i0, type: SpiderlyFilterTemplateDirective, decorators: [{
4052
- type: Directive,
4053
- args: [{
4054
- selector: 'ng-template[spiderlyFilterTemplate]',
4055
- }]
4056
- }], ctorParameters: () => [{ type: i0.TemplateRef }], propDecorators: { filterId: [{
4057
- type: Input,
4058
- args: ['spiderlyFilterTemplate']
4059
- }] } });
4051
+ const ALLOWED_OPERATORS = {
4052
+ text: [
4053
+ MatchModeCodes.StartsWith,
4054
+ MatchModeCodes.Contains,
4055
+ MatchModeCodes.Equals,
4056
+ ],
4057
+ number: [
4058
+ MatchModeCodes.Equals,
4059
+ MatchModeCodes.LessThan,
4060
+ MatchModeCodes.GreaterThan,
4061
+ MatchModeCodes.In,
4062
+ ],
4063
+ boolean: [MatchModeCodes.Equals],
4064
+ date: [
4065
+ MatchModeCodes.Equals,
4066
+ MatchModeCodes.LessThan,
4067
+ MatchModeCodes.GreaterThan,
4068
+ ],
4069
+ };
4060
4070
 
4061
- /** One option's word for one value — the honest raw value when no option matches (failed lookup). */
4062
- function spellOptionValue(options, value) {
4063
- return String(options?.find((option) => option.value === value)?.label ?? value);
4064
- }
4071
+ const OPERATOR_WORDS = {
4072
+ text: {
4073
+ [MatchModeCodes.StartsWith]: {
4074
+ pickerKey: 'StartsWith',
4075
+ phraseKey: 'FilterChipStartsWith',
4076
+ },
4077
+ [MatchModeCodes.Contains]: {
4078
+ pickerKey: 'Contains',
4079
+ phraseKey: 'FilterChipContains',
4080
+ },
4081
+ [MatchModeCodes.Equals]: {
4082
+ pickerKey: 'Equals',
4083
+ phraseKey: 'FilterChipEquals',
4084
+ },
4085
+ // No `In` for text: ALLOWED_OPERATORS.text does not carry it, so the entry was unreachable —
4086
+ // and it made `textFilter({ options })` look supported when the store throws on it.
4087
+ },
4088
+ number: {
4089
+ [MatchModeCodes.Equals]: {
4090
+ pickerKey: 'Equals',
4091
+ phraseKey: 'FilterChipEquals',
4092
+ },
4093
+ [MatchModeCodes.GreaterThan]: {
4094
+ pickerKey: 'MoreThan',
4095
+ phraseKey: 'FilterChipGreaterThan',
4096
+ },
4097
+ [MatchModeCodes.LessThan]: {
4098
+ pickerKey: 'LessThan',
4099
+ phraseKey: 'FilterChipLessThan',
4100
+ },
4101
+ // The picker never renders for a pick-list (one operator), but the CHIP always does.
4102
+ [MatchModeCodes.In]: { pickerKey: 'In', phraseKey: 'FilterChipIn' },
4103
+ },
4104
+ boolean: {
4105
+ [MatchModeCodes.Equals]: {
4106
+ pickerKey: 'Equals',
4107
+ phraseKey: 'FilterChipEquals',
4108
+ },
4109
+ },
4110
+ date: {
4111
+ [MatchModeCodes.Equals]: {
4112
+ pickerKey: 'OnDate',
4113
+ phraseKey: 'FilterChipEquals',
4114
+ },
4115
+ [MatchModeCodes.LessThan]: {
4116
+ pickerKey: 'DatesBefore',
4117
+ phraseKey: 'FilterChipBefore',
4118
+ },
4119
+ [MatchModeCodes.GreaterThan]: {
4120
+ pickerKey: 'DatesAfter',
4121
+ phraseKey: 'FilterChipAfter',
4122
+ },
4123
+ },
4124
+ };
4065
4125
  /**
4066
- * Lowercased and stripped of diacritics for matching. NFD decomposition handles č/ć/š/ž, but NOT
4067
- * đ — it is its own letter (U+0111), not a d carrying a mark, so it decomposes to nothing and has
4068
- * to be mapped by hand.
4126
+ * `In` and options are the same fact seen from two sides: `In` needs a list of values, and the
4127
+ * editor only draws one when the filter declares options. So options mean `In` and nothing else,
4128
+ * and their absence rules `In` out — offering it on a plain number filter would hand the
4129
+ * operator a mode with no control behind it.
4069
4130
  *
4070
- * It maps to "dj", which is what the rest of this workspace already does (pa-cms's
4071
- * `normalizePlaceQuery` and `toAscii`, the backend's `PlaceNameNormalizer`) and what someone
4072
- * without a Serbian layout actually types. Mapping it to "d" — as this first shipped — folds
4073
- * "Đorđe" to "dorde", so typing "djordje" finds nothing: the exact case the comment claimed to
4074
- * fix, and one no spec covered because the test used "drzava", which has no đ in it.
4131
+ * Widened first: the per-kind tuples are literal, so `boolean`'s `[Equals]` makes the `In`
4132
+ * comparison a "no overlap" error rather than an empty result.
4075
4133
  */
4076
- function foldForSearch(value) {
4077
- return value
4078
- .normalize('NFD')
4079
- .replace(/[\u0300-\u036f]/g, '')
4080
- .replace(/\u0111/g, 'dj')
4081
- .replace(/\u0110/g, 'Dj')
4082
- .toLowerCase()
4083
- .trim();
4134
+ function baseOperators(kind, hasOptions) {
4135
+ const accepted = ALLOWED_OPERATORS[kind];
4136
+ return accepted.filter((value) => hasOptions ? value === MatchModeCodes.In : value !== MatchModeCodes.In);
4137
+ }
4138
+ function toOperatorOptions(kind, operators) {
4139
+ return operators.map((value) => ({
4140
+ value,
4141
+ labelKey: OPERATOR_WORDS[kind][value]?.pickerKey ?? value,
4142
+ }));
4084
4143
  }
4085
4144
  /**
4086
- * The visible home of every applied constraint. This is what licenses a hidden column keeping its
4087
- * filter: the header is no longer the only place a filter can be seen, so hiding a column stops
4088
- * meaning "silently drop what it was filtering by".
4089
- *
4090
- * Everything drawn here comes from `applied()`, never from a draft — a chip over a half-typed
4091
- * value would repeat the mistake the header's filter icon shipped, claiming the grid is narrowed
4092
- * while it still shows every row.
4145
+ * A KIND's operator picker options, for callers with no filter behind them — today that is
4146
+ * `spiderly-data-view`, whose own filter config derives its operator lists here instead of
4147
+ * hand-keeping copies (the drift `allowed-operators.ts` exists to end). Per-FILTER narrowing
4148
+ * lives in the store (`createFilterStore` resolves it once per declaration), so this cannot be
4149
+ * handed a fake definition to satisfy — that shape shipped once and interpolated an empty
4150
+ * filter name into its own error message.
4093
4151
  */
4094
- class SpiderlyFilterBarComponent {
4095
- constructor() {
4096
- this.filters = input.required();
4097
- /** The current query's row count, supplied by whoever owns the query. Null hides the counter. */
4098
- this.totalRecords = input(null);
4099
- /**
4100
- * What the grid is ordered by, in priority order and already LABELLED — the bar knows nothing
4101
- * about columns, so whoever owns the sort resolves the names.
4102
- */
4103
- this.sort = input([]);
4104
- /**
4105
- * What the picker offers, labelled by the owner — every sortable column, HIDDEN ones
4106
- * included (their ordering is otherwise unreachable, which is the trap the picker closes).
4107
- * Empty keeps the chip read-only and the picker off.
4108
- */
4109
- this.sortOptions = input([]);
4110
- /** Whether the grid already sits on its default ordering — gates the reset row, nothing else. */
4111
- this.sortIsDefault = input(true);
4112
- /** Asked for, never done here — see the button's comment. */
4113
- this.clearAll = output();
4114
- /** A single-sort ask. Multi-sort stays a header gesture (ctrl-click); the picker replaces. */
4115
- this.sortPick = output();
4116
- /** Back to the owner's default ordering — the owner knows what that resolves to, not the bar. */
4117
- this.sortReset = output();
4118
- this.transloco = inject(TranslocoService);
4119
- /** For the chip's date wording — the same locale mechanism the table's cells format with. */
4120
- this.locale = inject(LOCALE_ID);
4121
- /**
4122
- * Controls a consumer projected for specific filters, collected by whoever hosts this bar. A
4123
- * filter with no template keeps the control the bar draws for its kind, so a table opts in one
4124
- * filter at a time.
4125
- */
4126
- this.controlTemplates = input(undefined);
4127
- this.isAddOpen = signal(false);
4128
- this.isSortOpen = signal(false);
4129
- /** Cleared when the popover closes, so reopening never starts inside someone's old query. */
4130
- this.addSearch = signal('');
4131
- /** The filter whose control is open. One at a time: the bar edits, it is not a form. */
4132
- this.editing = signal(null);
4133
- /**
4134
- * `addable` narrowed by the search box. Matching is unaccented and case-folded because that is
4135
- * how the label gets typed: "drzava" for "Država" on any keyboard without a Serbian layout. The
4136
- * backend's own product search is unaccented for the same reason.
4137
- */
4138
- this.offered = computed(() => {
4139
- const needle = foldForSearch(this.addSearch());
4140
- if (!needle)
4141
- return this.addable();
4142
- return this.addable().filter((option) => foldForSearch(option.label).includes(needle));
4143
- });
4144
- /**
4145
- * What "+ Filter" offers: every declared filter that is not already on the bar. Sourced from the
4146
- * DEFINITIONS, so a filter reaches this list whether or not it has a column, and whether or not
4147
- * that column is visible. That is the whole reason the bar exists.
4148
- *
4149
- * `offered: false` filters stay out — they have a dedicated control of their own on the page
4150
- * (PACMS's order search box), so the generic entry point would give one question two homes.
4151
- * Their chips still render: the applied list above reads no such flag.
4152
- */
4153
- this.addable = computed(() => {
4154
- const onBar = new Set(this.filters().applied().map((chip) => chip.id));
4155
- return Object.entries(this.filters().definitions)
4156
- .filter(([id, definition]) => !onBar.has(id) && definition.offered !== false)
4157
- .map(([id, definition]) => ({ id, ...definition }));
4158
- });
4159
- /**
4160
- * The menu's rows: each option decorated with its ACTIVE direction's arrow (any key, not
4161
- * just the primary), or null. A `computed`, not a per-row method call in the binding — the
4162
- * popover's content is projected, so it change-detects even while closed, and a `find` per
4163
- * option per pass is exactly what `operatorChoices` documents against.
4164
- */
4165
- this.sortRows = computed(() => {
4166
- const active = new Map(this.sort().map((key) => [key.field, key.descending]));
4167
- return this.sortOptions().map((option) => {
4168
- const descending = active.get(option.field);
4169
- return {
4170
- ...option,
4171
- arrow: descending == null ? null : descending ? '↓' : '↑',
4172
- };
4173
- });
4174
- });
4175
- /**
4176
- * The open editor's operator options, already translated — p-select takes labels, not keys.
4177
- *
4152
+ function operatorOptionsForKind(kind, hasOptions = false) {
4153
+ return toOperatorOptions(kind, baseOperators(kind, hasOptions));
4154
+ }
4155
+ /**
4156
+ * The operator applied when nobody picked one, which has to be what a person MEANS by filling in
4157
+ * the control. `Contains` for a fragment typed into a box; `GreaterThan` for a date, never
4158
+ * `Equals` — a date equality against a TIMESTAMP matches only the row written in that exact
4159
+ * second, and answers with an empty grid rather than an error (PACMS ran into this on all three
4160
+ * of its date columns, Filip 2026-08-28).
4161
+ */
4162
+ const DEFAULT_OPERATOR = {
4163
+ text: MatchModeCodes.Contains,
4164
+ number: MatchModeCodes.Equals,
4165
+ boolean: MatchModeCodes.Equals,
4166
+ date: MatchModeCodes.GreaterThan,
4167
+ };
4168
+ function textFilter(config) {
4169
+ return {
4170
+ kind: 'text',
4171
+ label: config.label,
4172
+ operators: config.operators,
4173
+ offered: config.offered,
4174
+ };
4175
+ }
4176
+ /**
4177
+ * Only a NUMBER filter accepts options, and the asymmetry is forced rather than chosen: options
4178
+ * mean `In`, and `In` is the one operator ALLOWED_OPERATORS grants to numbers alone — because the
4179
+ * generated paginator answers it with InvalidMatchMode on a string column. A text pick-list used
4180
+ * to type-check and then throw at the first `set`.
4181
+ */
4182
+ function numberFilter(config) {
4183
+ return {
4184
+ kind: 'number',
4185
+ label: config.label,
4186
+ options: config.options,
4187
+ operators: config.operators,
4188
+ offered: config.offered,
4189
+ };
4190
+ }
4191
+ function booleanFilter(config) {
4192
+ return {
4193
+ kind: 'boolean',
4194
+ label: config.label,
4195
+ operators: config.operators,
4196
+ offered: config.offered,
4197
+ };
4198
+ }
4199
+ function dateFilter(config) {
4200
+ return {
4201
+ kind: 'date',
4202
+ label: config.label,
4203
+ operators: config.operators,
4204
+ offered: config.offered,
4205
+ };
4206
+ }
4207
+ /** The shape `Date.toJSON` writes, which is the only value here JSON cannot round-trip itself. */
4208
+ const ISO_DATE = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/;
4209
+ function reviveValue(value) {
4210
+ if (typeof value === 'string' && ISO_DATE.test(value))
4211
+ return new Date(value);
4212
+ if (Array.isArray(value))
4213
+ return value.map(reviveValue);
4214
+ return value;
4215
+ }
4216
+ /**
4217
+ * A blanked value is NOT a constraint. `contains ''` matches every row, so emitting one would
4218
+ * narrow nothing while the bar claims the grid is filtered.
4219
+ */
4220
+ function isBlank(value) {
4221
+ if (value === null || value === undefined)
4222
+ return true;
4223
+ if (typeof value === 'string')
4224
+ return value.trim() === '';
4225
+ if (Array.isArray(value))
4226
+ return value.length === 0;
4227
+ // A half-typed datepicker yields an Invalid Date, which JSON.stringify writes as `null`.
4228
+ if (value instanceof Date)
4229
+ return Number.isNaN(value.getTime());
4230
+ return false;
4231
+ }
4232
+ /**
4233
+ * Whether two constraint values are the same QUESTION. Identity is not it: every `set` builds a
4234
+ * fresh draft, a multiselect hands back a fresh array per change, and two Dates at one instant
4235
+ * are one constraint. This is what `commit` bails on, so a paste-over-itself or a double Apply
4236
+ * cannot spend a request — the rule PACMS's order list used to hand-roll as `lastCommittedTerm`.
4237
+ */
4238
+ function valueEquals(a, b) {
4239
+ if (a === b)
4240
+ return true;
4241
+ if (a instanceof Date && b instanceof Date)
4242
+ return a.getTime() === b.getTime();
4243
+ if (Array.isArray(a) && Array.isArray(b)) {
4244
+ return a.length === b.length && a.every((item, i) => valueEquals(item, b[i]));
4245
+ }
4246
+ return false;
4247
+ }
4248
+ function constraintEquals(a, b) {
4249
+ return a != null && a.operator === b.operator && valueEquals(a.value, b.value);
4250
+ }
4251
+ /**
4252
+ * The filter engine. It knows nothing about columns: a filter has an id, a value type and a
4253
+ * constraint, and that is the entire vocabulary.
4254
+ */
4255
+ function createFilterStore(definitions) {
4256
+ // Per-filter offered operators, resolved ONCE per declaration — so an invalid narrowing is
4257
+ // said once, not on every editor open — and re-resolved only where the inputs can actually
4258
+ // change: `setOptions`, because options' PRESENCE flips a filter between pick-list (`In`) and
4259
+ // plain operators. A per-store Map rather than the module-level WeakMap this first shipped
4260
+ // as: that memo was keyed on a definition object consumers mutate, so late-filled options
4261
+ // could freeze a filter's operators at the wrong answer.
4262
+ const resolveOffered = (id) => {
4263
+ const definition = definitions[id];
4264
+ const base = baseOperators(definition.kind, definition.options != null);
4265
+ let codes = base;
4266
+ if (definition.operators?.length) {
4267
+ const survivors = definition.operators.filter((operator) => base.includes(operator));
4268
+ // Same polarity as the column path's computeMatchModes: a bad entry is dropped and said
4269
+ // out loud, and a narrowing that leaves nothing falls back to the full list — an editor
4270
+ // with no operators would be unusable, which is worse than an unwanted one. Mostly
4271
+ // unreachable now that FilterConfig types `operators` per kind; kept for dynamically
4272
+ // built arrays.
4273
+ if (survivors.length < definition.operators.length) {
4274
+ console.error(`Filter '${definition.label}' narrows to operators its ${definition.kind} kind does not ` +
4275
+ `offer (${definition.operators.join(', ')}; offered: ${base.join(', ')}). ` +
4276
+ `Unsupported entries are ignored${survivors.length ? '' : '; falling back to the full list'}.`);
4277
+ }
4278
+ codes = survivors.length ? survivors : base;
4279
+ }
4280
+ return { codes, options: toOperatorOptions(definition.kind, codes) };
4281
+ };
4282
+ const offered = new Map();
4283
+ for (const id of Object.keys(definitions)) {
4284
+ offered.set(id, resolveOffered(id));
4285
+ }
4286
+ /**
4287
+ * The operator applied when nobody picked one. Options mean `In`, whatever the kind's default;
4288
+ * a narrowed filter defaults to its FIRST declared operator.
4289
+ */
4290
+ const defaultOperatorFor = (id) => {
4291
+ const definition = definitions[id];
4292
+ if (definition.options != null)
4293
+ return MatchModeCodes.In;
4294
+ return definition.operators?.length
4295
+ ? offered.get(id).codes[0]
4296
+ : DEFAULT_OPERATOR[definition.kind];
4297
+ };
4298
+ // Two stores, and the split is the whole point: `set` writes a DRAFT, `commit` publishes it.
4299
+ // A chip drawn off a draft would repeat the mistake the header's filter icon already shipped —
4300
+ // claiming the grid is narrowed on the first keystroke. Controls that apply on change
4301
+ // (multiselect, boolean, date) call both at once; a text box commits on Enter or blur.
4302
+ const drafts = signal(new Map());
4303
+ const writeDraft = (id, constraint) => {
4304
+ const next = new Map(drafts());
4305
+ if (constraint === null)
4306
+ next.delete(id);
4307
+ else
4308
+ next.set(id, constraint);
4309
+ drafts.set(next);
4310
+ };
4311
+ const committed = signal(new Map());
4312
+ const applied = computed(() => [...committed()].map(([id, constraint]) => ({
4313
+ id: id,
4314
+ label: definitions[id].label,
4315
+ kind: definitions[id].kind,
4316
+ operator: constraint.operator,
4317
+ operatorPhraseKey: OPERATOR_WORDS[definitions[id].kind][constraint.operator]?.phraseKey ??
4318
+ constraint.operator,
4319
+ value: constraint.value,
4320
+ })));
4321
+ const store = {
4322
+ definitions,
4323
+ applied,
4324
+ /**
4325
+ * A handle bound to one filter. This is the PLACEMENT api: it needs nothing from the component
4326
+ * tree, so the same filter can be driven from the bar, from a header cell, or from a drawer
4327
+ * rendered at app root. `value` is the DRAFT — a control shows what was typed, not what was
4328
+ * applied; `applied()` is the other question and the bar asks that one.
4329
+ */
4330
+ get(id) {
4331
+ return {
4332
+ id: id,
4333
+ label: definitions[id].label,
4334
+ kind: definitions[id].kind,
4335
+ value: computed(() => drafts().get(id)?.value),
4336
+ operator: computed(() => drafts().get(id)?.operator),
4337
+ // Getters, not snapshots: options can land AFTER a handle is taken — the bar's editor
4338
+ // holds one for the life of the popover, and PACMS fills its lookups asynchronously —
4339
+ // and a snapshot left an already-open pick-list empty until it was reopened. The
4340
+ // operator list rides along, since setOptions can flip it to `In`.
4341
+ get operators() {
4342
+ return offered.get(id).options;
4343
+ },
4344
+ get defaultOperator() {
4345
+ return defaultOperatorFor(id);
4346
+ },
4347
+ get options() {
4348
+ return definitions[id].options;
4349
+ },
4350
+ set: (constraint) => store.set(id, constraint),
4351
+ commit: () => store.commit(id),
4352
+ reset: () => store.reset(id),
4353
+ };
4354
+ },
4355
+ /**
4356
+ * The ONE seam for choices that arrive after the store is built (PACMS fills its pick-lists
4357
+ * from lookups that race a deploy). Never write `definitions[id].options` by hand: options'
4358
+ * PRESENCE is what makes a filter a pick-list, so the setter also re-resolves the offered
4359
+ * operators — a hand write would leave a late-filled filter offering `Equals` on a control
4360
+ * that emits a list.
4361
+ */
4362
+ setOptions(id, options) {
4363
+ definitions[id].options = options;
4364
+ offered.set(id, resolveOffered(id));
4365
+ },
4366
+ set(id, constraint) {
4367
+ const kind = definitions[id].kind;
4368
+ // Throws rather than logging and ignoring, which is what `computeMatchModes` does for a
4369
+ // column's OFFERED match modes. Different stakes: that one narrows a dropdown, so a bad
4370
+ // entry merely fails to show, while this one assembles a QUERY — dropping it silently would
4371
+ // leave the operator looking at unfiltered rows believing they are filtered.
4372
+ if (!ALLOWED_OPERATORS[kind].includes(constraint.operator)) {
4373
+ throw new Error(`Filter '${String(id)}' is ${kind}, which does not accept the '${constraint.operator}' ` +
4374
+ `operator (allowed: ${ALLOWED_OPERATORS[kind].join(', ')}). The generated paginator ` +
4375
+ `answers an unsupported match mode with InvalidMatchMode.`);
4376
+ }
4377
+ writeDraft(id, constraint);
4378
+ },
4379
+ /**
4380
+ * `set` + `commit` in one breath — the shape every PROGRAMMATIC write takes (a view's
4381
+ * `apply`, the sheet flow). The split API stays for controls, where draft-on-type /
4382
+ * publish-on-Apply is the point; but a bare `set` whose `commit` is forgotten (or written
4383
+ * with the wrong id — the pair repeats the string twice) fails silently: the draft is
4384
+ * written, nothing is published, and a view narrows by nothing under a labelled tab.
4385
+ */
4386
+ setAndCommit(id, constraint) {
4387
+ store.set(id, constraint);
4388
+ store.commit(id);
4389
+ },
4390
+ /**
4391
+ * Publishes the draft. A blanked draft REMOVES the constraint rather than committing an empty
4392
+ * one, so `applied()` and the payload agree: no chip, no key, no `contains ''`.
4393
+ */
4394
+ commit(id) {
4395
+ const draft = drafts().get(id);
4396
+ const current = committed();
4397
+ const shouldRemove = draft === undefined || isBlank(draft.value);
4398
+ // A new Map is a new identity even with identical contents, and everything downstream
4399
+ // reacts to identity — so a no-op commit would spend a request. The bail compares the
4400
+ // CONSTRAINT, not the draft object: every set() builds a fresh draft, and the case that
4401
+ // matters is an identical value re-committed (see valueEquals).
4402
+ if (shouldRemove ? !current.has(id) : constraintEquals(current.get(id), draft))
4403
+ return;
4404
+ const next = new Map(current);
4405
+ if (shouldRemove)
4406
+ next.delete(id);
4407
+ else
4408
+ next.set(id, draft);
4409
+ committed.set(next);
4410
+ },
4411
+ /**
4412
+ * The chip's `x`. Drops the DRAFT as well as the committed constraint: a surviving draft
4413
+ * leaves the control still showing the cleared text, and the next commit would restore a
4414
+ * filter nobody re-typed.
4415
+ */
4416
+ reset(id) {
4417
+ if (drafts().has(id))
4418
+ writeDraft(id, null);
4419
+ if (!committed().has(id))
4420
+ return;
4421
+ const next = new Map(committed());
4422
+ next.delete(id);
4423
+ committed.set(next);
4424
+ },
4425
+ /**
4426
+ * The applied set, plain enough to store. Drafts are deliberately NOT included: a half-typed
4427
+ * value is not a state anyone should come back to, and the bar's whole claim is that what it
4428
+ * shows is what narrows the grid.
4429
+ */
4430
+ snapshot() {
4431
+ const snapshot = {};
4432
+ for (const [id, constraint] of committed()) {
4433
+ snapshot[id] = {
4434
+ operator: constraint.operator,
4435
+ value: constraint.value,
4436
+ };
4437
+ }
4438
+ return snapshot;
4439
+ },
4440
+ /**
4441
+ * Replaces the applied set. The drafts are written too, so reopening a restored chip shows
4442
+ * the value it is filtering by rather than an empty control.
4443
+ *
4444
+ * Ids the store no longer declares are dropped rather than carried: a filter removed from the
4445
+ * declaration in a later release would otherwise ride every request from anyone whose storage
4446
+ * still held it, with no chip able to name it and no way to clear it from the UI.
4447
+ */
4448
+ restore(snapshot) {
4449
+ const next = new Map();
4450
+ for (const [id, constraint] of Object.entries(snapshot ?? {})) {
4451
+ if (!(id in definitions))
4452
+ continue;
4453
+ next.set(id, {
4454
+ operator: constraint.operator,
4455
+ value: reviveValue(constraint.value),
4456
+ });
4457
+ }
4458
+ drafts.set(new Map(next));
4459
+ committed.set(next);
4460
+ },
4461
+ /** The bar's "Clear all". `reset` for every filter, drafts included. */
4462
+ clear() {
4463
+ if (drafts().size > 0)
4464
+ drafts.set(new Map());
4465
+ if (committed().size > 0)
4466
+ committed.set(new Map());
4467
+ },
4468
+ /** The `filters` half of `Filter` — what the generated paginator reads. */
4469
+ toFilterPayload() {
4470
+ const payload = {};
4471
+ for (const [id, constraint] of committed()) {
4472
+ payload[id] = [
4473
+ { matchMode: constraint.operator, value: constraint.value },
4474
+ ];
4475
+ }
4476
+ return payload;
4477
+ },
4478
+ };
4479
+ return store;
4480
+ }
4481
+
4482
+ /**
4483
+ * Renders the editor for ONE filter of a `spiderly-data-table`'s bar, replacing the control the
4484
+ * bar would have drawn for that filter's kind. Bind it to the filter's id; filters with no
4485
+ * matching template keep the built-in control, so a table opts in one filter at a time.
4486
+ *
4487
+ * This is the narrow job the directive has. Placing a filter ANYWHERE else — a drawer, a modal,
4488
+ * a header cell — needs no directive at all: `store.get(id)` returns the same handle and depends
4489
+ * on nothing in the component tree, which is why the store is the consumer's to own.
4490
+ */
4491
+ class SpiderlyFilterTemplateDirective {
4492
+ constructor(template) {
4493
+ this.template = template;
4494
+ }
4495
+ /**
4496
+ * Without this the `let-` variable is `any` at every consumer, however well the context is
4497
+ * declared — a directive's injected `TemplateRef<C>` never reaches the template that declares
4498
+ * it. Same mechanism as `SpiderlyCellTemplateDirective`.
4499
+ */
4500
+ static ngTemplateContextGuard(dir, ctx) {
4501
+ return true;
4502
+ }
4503
+ static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "19.2.13", ngImport: i0, type: SpiderlyFilterTemplateDirective, deps: [{ token: i0.TemplateRef }], target: i0.ɵɵFactoryTarget.Directive }); }
4504
+ static { this.ɵdir = i0.ɵɵngDeclareDirective({ minVersion: "14.0.0", version: "19.2.13", type: SpiderlyFilterTemplateDirective, isStandalone: true, selector: "ng-template[spiderlyFilterTemplate]", inputs: { filterId: ["spiderlyFilterTemplate", "filterId"] }, ngImport: i0 }); }
4505
+ }
4506
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "19.2.13", ngImport: i0, type: SpiderlyFilterTemplateDirective, decorators: [{
4507
+ type: Directive,
4508
+ args: [{
4509
+ selector: 'ng-template[spiderlyFilterTemplate]',
4510
+ }]
4511
+ }], ctorParameters: () => [{ type: i0.TemplateRef }], propDecorators: { filterId: [{
4512
+ type: Input,
4513
+ args: ['spiderlyFilterTemplate']
4514
+ }] } });
4515
+
4516
+ /** One option's word for one value — the honest raw value when no option matches (failed lookup). */
4517
+ function spellOptionValue(options, value) {
4518
+ return String(options?.find((option) => option.value === value)?.label ?? value);
4519
+ }
4520
+ /**
4521
+ * Lowercased and stripped of diacritics for matching. NFD decomposition handles č/ć/š/ž, but NOT
4522
+ * đ — it is its own letter (U+0111), not a d carrying a mark, so it decomposes to nothing and has
4523
+ * to be mapped by hand.
4524
+ *
4525
+ * It maps to "dj", which is what the rest of this workspace already does (pa-cms's
4526
+ * `normalizePlaceQuery` and `toAscii`, the backend's `PlaceNameNormalizer`) and what someone
4527
+ * without a Serbian layout actually types. Mapping it to "d" — as this first shipped — folds
4528
+ * "Đorđe" to "dorde", so typing "djordje" finds nothing: the exact case the comment claimed to
4529
+ * fix, and one no spec covered because the test used "drzava", which has no đ in it.
4530
+ */
4531
+ function foldForSearch(value) {
4532
+ return value
4533
+ .normalize('NFD')
4534
+ .replace(/[\u0300-\u036f]/g, '')
4535
+ .replace(/\u0111/g, 'dj')
4536
+ .replace(/\u0110/g, 'Dj')
4537
+ .toLowerCase()
4538
+ .trim();
4539
+ }
4540
+ /**
4541
+ * The visible home of every applied constraint. This is what licenses a hidden column keeping its
4542
+ * filter: the header is no longer the only place a filter can be seen, so hiding a column stops
4543
+ * meaning "silently drop what it was filtering by".
4544
+ *
4545
+ * Everything drawn here comes from `applied()`, never from a draft — a chip over a half-typed
4546
+ * value would repeat the mistake the header's filter icon shipped, claiming the grid is narrowed
4547
+ * while it still shows every row.
4548
+ */
4549
+ class SpiderlyFilterBarComponent {
4550
+ constructor() {
4551
+ this.filters = input.required();
4552
+ /** The current query's row count, supplied by whoever owns the query. Null hides the counter. */
4553
+ this.totalRecords = input(null);
4554
+ /**
4555
+ * What the grid is ordered by, in priority order and already LABELLED — the bar knows nothing
4556
+ * about columns, so whoever owns the sort resolves the names.
4557
+ */
4558
+ this.sort = input([]);
4559
+ /**
4560
+ * What the picker offers, labelled by the owner — every sortable column, HIDDEN ones
4561
+ * included (their ordering is otherwise unreachable, which is the trap the picker closes).
4562
+ * Empty keeps the chip read-only and the picker off.
4563
+ */
4564
+ this.sortOptions = input([]);
4565
+ /** Whether the grid already sits on its default ordering — gates the reset row, nothing else. */
4566
+ this.sortIsDefault = input(true);
4567
+ /** Asked for, never done here — see the button's comment. */
4568
+ this.clearAll = output();
4569
+ /** A single-sort ask. Multi-sort stays a header gesture (ctrl-click); the picker replaces. */
4570
+ this.sortPick = output();
4571
+ /** Back to the owner's default ordering — the owner knows what that resolves to, not the bar. */
4572
+ this.sortReset = output();
4573
+ this.transloco = inject(TranslocoService);
4574
+ /** For the chip's date wording — the same locale mechanism the table's cells format with. */
4575
+ this.locale = inject(LOCALE_ID);
4576
+ /**
4577
+ * Controls a consumer projected for specific filters, collected by whoever hosts this bar. A
4578
+ * filter with no template keeps the control the bar draws for its kind, so a table opts in one
4579
+ * filter at a time.
4580
+ */
4581
+ this.controlTemplates = input(undefined);
4582
+ this.isAddOpen = signal(false);
4583
+ this.isSortOpen = signal(false);
4584
+ /** Cleared when the popover closes, so reopening never starts inside someone's old query. */
4585
+ this.addSearch = signal('');
4586
+ /** The filter whose control is open. One at a time: the bar edits, it is not a form. */
4587
+ this.editing = signal(null);
4588
+ /**
4589
+ * `addable` narrowed by the search box. Matching is unaccented and case-folded because that is
4590
+ * how the label gets typed: "drzava" for "Država" on any keyboard without a Serbian layout. The
4591
+ * backend's own product search is unaccented for the same reason.
4592
+ */
4593
+ this.offered = computed(() => {
4594
+ const needle = foldForSearch(this.addSearch());
4595
+ if (!needle)
4596
+ return this.addable();
4597
+ return this.addable().filter((option) => foldForSearch(option.label).includes(needle));
4598
+ });
4599
+ /**
4600
+ * What "+ Filter" offers: every declared filter that is not already on the bar. Sourced from the
4601
+ * DEFINITIONS, so a filter reaches this list whether or not it has a column, and whether or not
4602
+ * that column is visible. That is the whole reason the bar exists.
4603
+ *
4604
+ * `offered: false` filters stay out — they have a dedicated control of their own on the page
4605
+ * (PACMS's order search box), so the generic entry point would give one question two homes.
4606
+ * Their chips still render: the applied list above reads no such flag.
4607
+ */
4608
+ this.addable = computed(() => {
4609
+ const onBar = new Set(this.filters().applied().map((chip) => chip.id));
4610
+ return Object.entries(this.filters().definitions)
4611
+ .filter(([id, definition]) => !onBar.has(id) && definition.offered !== false)
4612
+ .map(([id, definition]) => ({ id, ...definition }));
4613
+ });
4614
+ /**
4615
+ * The menu's rows: each option decorated with its ACTIVE direction's arrow (any key, not
4616
+ * just the primary), or null. A `computed`, not a per-row method call in the binding — the
4617
+ * popover's content is projected, so it change-detects even while closed, and a `find` per
4618
+ * option per pass is exactly what `operatorChoices` documents against.
4619
+ */
4620
+ this.sortRows = computed(() => {
4621
+ const active = new Map(this.sort().map((key) => [key.field, key.descending]));
4622
+ return this.sortOptions().map((option) => {
4623
+ const descending = active.get(option.field);
4624
+ return {
4625
+ ...option,
4626
+ arrow: descending == null ? null : descending ? '↓' : '↑',
4627
+ };
4628
+ });
4629
+ });
4630
+ /**
4631
+ * The open editor's operator options, already translated — p-select takes labels, not keys.
4632
+ *
4178
4633
  * A `computed`, not a method in the binding: both inputs are fixed for the life of an open
4179
4634
  * editor, and PrimeNG's `Select.options` setter deep-compares whatever it is handed — so a
4180
4635
  * freshly mapped array per change-detection pass paid for a recursive structural compare to
@@ -5501,7 +5956,10 @@ class SpiderlyDataTableComponent {
5501
5956
  * reading exactly the keys they already wrote.
5502
5957
  */
5503
5958
  get viewScope() {
5504
- return this.activeViewId ? `:${this.activeViewId}` : '';
5959
+ return this.viewScopeOf(this.activeViewId);
5960
+ }
5961
+ viewScopeOf(viewId) {
5962
+ return viewId ? `:${viewId}` : '';
5505
5963
  }
5506
5964
  /**
5507
5965
  * Where the applied filters live. Follows `stateStorage` rather than the layout's always-local
@@ -5511,8 +5969,11 @@ class SpiderlyDataTableComponent {
5511
5969
  * durable preference like a column's width.
5512
5970
  */
5513
5971
  get filtersStateKey() {
5972
+ return this.filtersStateKeyOf(this.activeViewId);
5973
+ }
5974
+ filtersStateKeyOf(viewId) {
5514
5975
  return this.resolvedStateKey
5515
- ? `${this.resolvedStateKey}${this.viewScope}:filters`
5976
+ ? `${this.resolvedStateKey}${this.viewScopeOf(viewId)}:filters`
5516
5977
  : null;
5517
5978
  }
5518
5979
  get filterStorage() {
@@ -5605,17 +6066,18 @@ class SpiderlyDataTableComponent {
5605
6066
  * the empty state the operator left — they cleared it, and F5 must give the cleared state
5606
6067
  * back. On a table with no views, `view` is null and this is a plain restore of the
5607
6068
  * `persistAppliedFilters` snapshot.
6069
+ *
6070
+ * `viewFilterPayload` passes a scratch store and another view's key, to read what entering
6071
+ * that view would send.
5608
6072
  */
5609
- enterViewFilters(view, semantics) {
5610
- if (!this.filters)
6073
+ enterViewFilters(view, semantics, store = this.filters, key = this.filtersStateKey) {
6074
+ if (!store)
5611
6075
  return;
5612
- const stored = !view?.transient && this.filtersStateKey
5613
- ? readStoredJson(this.filterStorage, this.filtersStateKey)
5614
- : null;
6076
+ const stored = !view?.transient && key ? readStoredJson(this.filterStorage, key) : null;
5615
6077
  if (stored)
5616
- this.filters.restore(stored);
6078
+ store.restore(stored);
5617
6079
  else if (view?.transient || semantics === 'click')
5618
- view?.apply?.(this.filters);
6080
+ view?.apply?.(store);
5619
6081
  }
5620
6082
  get layoutStateKey() {
5621
6083
  return this.resolvedStateKey
@@ -6035,6 +6497,39 @@ class SpiderlyDataTableComponent {
6035
6497
  this.refetchFromFirstPage();
6036
6498
  }
6037
6499
  }
6500
+ /**
6501
+ * The `filters` payload a view's own list request carries — the same shape the store's
6502
+ * `toFilterPayload()` emits — so a consumer's count query can narrow each view by the bar
6503
+ * filters the operator saved on it, and a `TableView.count` stays the number of rows that tab
6504
+ * lists: on screen for the active view, after a click for the others (spiderly#439).
6505
+ *
6506
+ * - The ACTIVE view answers with the live filters. Not with what a click would enter: clicking
6507
+ * the active tab re-asks its question when the operator has cleared the bar, but the list on
6508
+ * screen, and so its count, has not.
6509
+ * - Any other view answers with what a click would enter (`enterViewFilters`): the filters
6510
+ * saved on it, else its `apply` — always `apply` for a transient view. That runs on a scratch
6511
+ * `createFilterStore` of the same declarations, so nothing switches, persists or re-queries.
6512
+ * Untracked: the scratch store's signal writes would otherwise make a read inside a
6513
+ * `computed` throw NG0600.
6514
+ *
6515
+ * `{}` on a table without `[filters]`, whose requests carry none; `null` for an id that is not
6516
+ * in `[views]`.
6517
+ */
6518
+ viewFilterPayload(viewId) {
6519
+ const view = this.viewById(viewId);
6520
+ if (!view)
6521
+ return null;
6522
+ if (!this.filters)
6523
+ return {};
6524
+ if (view.id === this.activeViewId)
6525
+ return this.filters.toFilterPayload();
6526
+ const definitions = this.filters.definitions;
6527
+ return untracked(() => {
6528
+ const scratch = createFilterStore(definitions);
6529
+ this.enterViewFilters(view, 'click', scratch, this.filtersStateKeyOf(view.id));
6530
+ return scratch.toFilterPayload();
6531
+ });
6532
+ }
6038
6533
  /**
6039
6534
  * The filter this column stands for. Defaults to the column's own `field`, because a store id
6040
6535
  * IS a backend property name — `toFilterPayload` emits it straight into `Filter.filters`. So
@@ -6933,490 +7428,35 @@ class Action {
6933
7428
  class Column {
6934
7429
  constructor({ name, field, filterType, filterId, showMatchModes, filterField, dropdownOrMultiselectValues, actions, editable, showTime, decimalPlaces, sortable, visible, lockVisible, width, onCellClick, } = {}) {
6935
7430
  this.name = name;
6936
- this.field = field;
6937
- this.filterType = filterType;
6938
- this.filterId = filterId;
6939
- this.showMatchModes = showMatchModes;
6940
- this.filterField = filterField;
6941
- this.dropdownOrMultiselectValues = dropdownOrMultiselectValues;
6942
- this.actions = actions;
6943
- this.editable = editable;
6944
- this.showTime = showTime;
6945
- this.decimalPlaces = decimalPlaces;
6946
- this.sortable = sortable;
6947
- this.visible = visible;
6948
- this.lockVisible = lockVisible;
6949
- this.width = width;
6950
- this.onCellClick = onCellClick;
6951
- }
6952
- }
6953
- class RowClickEvent {
6954
- constructor({ index, id, additionalIndexes, } = {}) {
6955
- this.index = index;
6956
- this.id = id;
6957
- this.additionalIndexes = additionalIndexes;
6958
- }
6959
- }
6960
- class AllClickEvent {
6961
- constructor({ checked, additionalIndexes, } = {}) {
6962
- this.checked = checked;
6963
- this.additionalIndexes = additionalIndexes;
6964
- }
6965
- }
6966
-
6967
- var MatchModeCodes;
6968
- (function (MatchModeCodes) {
6969
- MatchModeCodes["StartsWith"] = "startsWith";
6970
- MatchModeCodes["Contains"] = "contains";
6971
- MatchModeCodes["Equals"] = "equals";
6972
- MatchModeCodes["LessThan"] = "lessThan";
6973
- MatchModeCodes["GreaterThan"] = "greaterThan";
6974
- MatchModeCodes["In"] = "in";
6975
- })(MatchModeCodes || (MatchModeCodes = {}));
6976
-
6977
- /**
6978
- * What each value type may be asked — the ONE operator table, mirroring what the generated
6979
- * paginator implements per type (`PaginatedResultGenerator`): `In` on a string column answers
6980
- * with InvalidMatchMode, PrimeNG's own text list adds three modes the backend 400s, and so on.
6981
- *
6982
- * Deliberately a LEAF module (imports nothing but the codes) so every consumer can derive from
6983
- * it without a cycle: the store's runtime validation and offered lists (`filter-store.ts`), the
6984
- * compile-time `FilterRule.matchMode` union (`entities/filter-rule.ts`), and the data view's
6985
- * kind-level lists (via `operatorOptionsForKind`). These used to be hand-kept copies; edit the
6986
- * table here and every surface follows.
6987
- *
6988
- * Tuple order is DISPLAY order for every operator picker (Equals, then "less/before", then
6989
- * "more/after" — the order the header dropdowns always shipped).
6990
- */
6991
- const ALLOWED_OPERATORS = {
6992
- text: [
6993
- MatchModeCodes.StartsWith,
6994
- MatchModeCodes.Contains,
6995
- MatchModeCodes.Equals,
6996
- ],
6997
- number: [
6998
- MatchModeCodes.Equals,
6999
- MatchModeCodes.LessThan,
7000
- MatchModeCodes.GreaterThan,
7001
- MatchModeCodes.In,
7002
- ],
7003
- boolean: [MatchModeCodes.Equals],
7004
- date: [
7005
- MatchModeCodes.Equals,
7006
- MatchModeCodes.LessThan,
7007
- MatchModeCodes.GreaterThan,
7008
- ],
7009
- };
7010
-
7011
- const OPERATOR_WORDS = {
7012
- text: {
7013
- [MatchModeCodes.StartsWith]: {
7014
- pickerKey: 'StartsWith',
7015
- phraseKey: 'FilterChipStartsWith',
7016
- },
7017
- [MatchModeCodes.Contains]: {
7018
- pickerKey: 'Contains',
7019
- phraseKey: 'FilterChipContains',
7020
- },
7021
- [MatchModeCodes.Equals]: {
7022
- pickerKey: 'Equals',
7023
- phraseKey: 'FilterChipEquals',
7024
- },
7025
- // No `In` for text: ALLOWED_OPERATORS.text does not carry it, so the entry was unreachable —
7026
- // and it made `textFilter({ options })` look supported when the store throws on it.
7027
- },
7028
- number: {
7029
- [MatchModeCodes.Equals]: {
7030
- pickerKey: 'Equals',
7031
- phraseKey: 'FilterChipEquals',
7032
- },
7033
- [MatchModeCodes.GreaterThan]: {
7034
- pickerKey: 'MoreThan',
7035
- phraseKey: 'FilterChipGreaterThan',
7036
- },
7037
- [MatchModeCodes.LessThan]: {
7038
- pickerKey: 'LessThan',
7039
- phraseKey: 'FilterChipLessThan',
7040
- },
7041
- // The picker never renders for a pick-list (one operator), but the CHIP always does.
7042
- [MatchModeCodes.In]: { pickerKey: 'In', phraseKey: 'FilterChipIn' },
7043
- },
7044
- boolean: {
7045
- [MatchModeCodes.Equals]: {
7046
- pickerKey: 'Equals',
7047
- phraseKey: 'FilterChipEquals',
7048
- },
7049
- },
7050
- date: {
7051
- [MatchModeCodes.Equals]: {
7052
- pickerKey: 'OnDate',
7053
- phraseKey: 'FilterChipEquals',
7054
- },
7055
- [MatchModeCodes.LessThan]: {
7056
- pickerKey: 'DatesBefore',
7057
- phraseKey: 'FilterChipBefore',
7058
- },
7059
- [MatchModeCodes.GreaterThan]: {
7060
- pickerKey: 'DatesAfter',
7061
- phraseKey: 'FilterChipAfter',
7062
- },
7063
- },
7064
- };
7065
- /**
7066
- * `In` and options are the same fact seen from two sides: `In` needs a list of values, and the
7067
- * editor only draws one when the filter declares options. So options mean `In` and nothing else,
7068
- * and their absence rules `In` out — offering it on a plain number filter would hand the
7069
- * operator a mode with no control behind it.
7070
- *
7071
- * Widened first: the per-kind tuples are literal, so `boolean`'s `[Equals]` makes the `In`
7072
- * comparison a "no overlap" error rather than an empty result.
7073
- */
7074
- function baseOperators(kind, hasOptions) {
7075
- const accepted = ALLOWED_OPERATORS[kind];
7076
- return accepted.filter((value) => hasOptions ? value === MatchModeCodes.In : value !== MatchModeCodes.In);
7077
- }
7078
- function toOperatorOptions(kind, operators) {
7079
- return operators.map((value) => ({
7080
- value,
7081
- labelKey: OPERATOR_WORDS[kind][value]?.pickerKey ?? value,
7082
- }));
7083
- }
7084
- /**
7085
- * A KIND's operator picker options, for callers with no filter behind them — today that is
7086
- * `spiderly-data-view`, whose own filter config derives its operator lists here instead of
7087
- * hand-keeping copies (the drift `allowed-operators.ts` exists to end). Per-FILTER narrowing
7088
- * lives in the store (`createFilterStore` resolves it once per declaration), so this cannot be
7089
- * handed a fake definition to satisfy — that shape shipped once and interpolated an empty
7090
- * filter name into its own error message.
7091
- */
7092
- function operatorOptionsForKind(kind, hasOptions = false) {
7093
- return toOperatorOptions(kind, baseOperators(kind, hasOptions));
7094
- }
7095
- /**
7096
- * The operator applied when nobody picked one, which has to be what a person MEANS by filling in
7097
- * the control. `Contains` for a fragment typed into a box; `GreaterThan` for a date, never
7098
- * `Equals` — a date equality against a TIMESTAMP matches only the row written in that exact
7099
- * second, and answers with an empty grid rather than an error (PACMS ran into this on all three
7100
- * of its date columns, Filip 2026-08-28).
7101
- */
7102
- const DEFAULT_OPERATOR = {
7103
- text: MatchModeCodes.Contains,
7104
- number: MatchModeCodes.Equals,
7105
- boolean: MatchModeCodes.Equals,
7106
- date: MatchModeCodes.GreaterThan,
7107
- };
7108
- function textFilter(config) {
7109
- return {
7110
- kind: 'text',
7111
- label: config.label,
7112
- operators: config.operators,
7113
- offered: config.offered,
7114
- };
7115
- }
7116
- /**
7117
- * Only a NUMBER filter accepts options, and the asymmetry is forced rather than chosen: options
7118
- * mean `In`, and `In` is the one operator ALLOWED_OPERATORS grants to numbers alone — because the
7119
- * generated paginator answers it with InvalidMatchMode on a string column. A text pick-list used
7120
- * to type-check and then throw at the first `set`.
7121
- */
7122
- function numberFilter(config) {
7123
- return {
7124
- kind: 'number',
7125
- label: config.label,
7126
- options: config.options,
7127
- operators: config.operators,
7128
- offered: config.offered,
7129
- };
7130
- }
7131
- function booleanFilter(config) {
7132
- return {
7133
- kind: 'boolean',
7134
- label: config.label,
7135
- operators: config.operators,
7136
- offered: config.offered,
7137
- };
7138
- }
7139
- function dateFilter(config) {
7140
- return {
7141
- kind: 'date',
7142
- label: config.label,
7143
- operators: config.operators,
7144
- offered: config.offered,
7145
- };
7146
- }
7147
- /** The shape `Date.toJSON` writes, which is the only value here JSON cannot round-trip itself. */
7148
- const ISO_DATE = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/;
7149
- function reviveValue(value) {
7150
- if (typeof value === 'string' && ISO_DATE.test(value))
7151
- return new Date(value);
7152
- if (Array.isArray(value))
7153
- return value.map(reviveValue);
7154
- return value;
7155
- }
7156
- /**
7157
- * A blanked value is NOT a constraint. `contains ''` matches every row, so emitting one would
7158
- * narrow nothing while the bar claims the grid is filtered.
7159
- */
7160
- function isBlank(value) {
7161
- if (value === null || value === undefined)
7162
- return true;
7163
- if (typeof value === 'string')
7164
- return value.trim() === '';
7165
- if (Array.isArray(value))
7166
- return value.length === 0;
7167
- // A half-typed datepicker yields an Invalid Date, which JSON.stringify writes as `null`.
7168
- if (value instanceof Date)
7169
- return Number.isNaN(value.getTime());
7170
- return false;
7171
- }
7172
- /**
7173
- * Whether two constraint values are the same QUESTION. Identity is not it: every `set` builds a
7174
- * fresh draft, a multiselect hands back a fresh array per change, and two Dates at one instant
7175
- * are one constraint. This is what `commit` bails on, so a paste-over-itself or a double Apply
7176
- * cannot spend a request — the rule PACMS's order list used to hand-roll as `lastCommittedTerm`.
7177
- */
7178
- function valueEquals(a, b) {
7179
- if (a === b)
7180
- return true;
7181
- if (a instanceof Date && b instanceof Date)
7182
- return a.getTime() === b.getTime();
7183
- if (Array.isArray(a) && Array.isArray(b)) {
7184
- return a.length === b.length && a.every((item, i) => valueEquals(item, b[i]));
7431
+ this.field = field;
7432
+ this.filterType = filterType;
7433
+ this.filterId = filterId;
7434
+ this.showMatchModes = showMatchModes;
7435
+ this.filterField = filterField;
7436
+ this.dropdownOrMultiselectValues = dropdownOrMultiselectValues;
7437
+ this.actions = actions;
7438
+ this.editable = editable;
7439
+ this.showTime = showTime;
7440
+ this.decimalPlaces = decimalPlaces;
7441
+ this.sortable = sortable;
7442
+ this.visible = visible;
7443
+ this.lockVisible = lockVisible;
7444
+ this.width = width;
7445
+ this.onCellClick = onCellClick;
7185
7446
  }
7186
- return false;
7187
7447
  }
7188
- function constraintEquals(a, b) {
7189
- return a != null && a.operator === b.operator && valueEquals(a.value, b.value);
7448
+ class RowClickEvent {
7449
+ constructor({ index, id, additionalIndexes, } = {}) {
7450
+ this.index = index;
7451
+ this.id = id;
7452
+ this.additionalIndexes = additionalIndexes;
7453
+ }
7190
7454
  }
7191
- /**
7192
- * The filter engine. It knows nothing about columns: a filter has an id, a value type and a
7193
- * constraint, and that is the entire vocabulary.
7194
- */
7195
- function createFilterStore(definitions) {
7196
- // Per-filter offered operators, resolved ONCE per declaration — so an invalid narrowing is
7197
- // said once, not on every editor open — and re-resolved only where the inputs can actually
7198
- // change: `setOptions`, because options' PRESENCE flips a filter between pick-list (`In`) and
7199
- // plain operators. A per-store Map rather than the module-level WeakMap this first shipped
7200
- // as: that memo was keyed on a definition object consumers mutate, so late-filled options
7201
- // could freeze a filter's operators at the wrong answer.
7202
- const resolveOffered = (id) => {
7203
- const definition = definitions[id];
7204
- const base = baseOperators(definition.kind, definition.options != null);
7205
- let codes = base;
7206
- if (definition.operators?.length) {
7207
- const survivors = definition.operators.filter((operator) => base.includes(operator));
7208
- // Same polarity as the column path's computeMatchModes: a bad entry is dropped and said
7209
- // out loud, and a narrowing that leaves nothing falls back to the full list — an editor
7210
- // with no operators would be unusable, which is worse than an unwanted one. Mostly
7211
- // unreachable now that FilterConfig types `operators` per kind; kept for dynamically
7212
- // built arrays.
7213
- if (survivors.length < definition.operators.length) {
7214
- console.error(`Filter '${definition.label}' narrows to operators its ${definition.kind} kind does not ` +
7215
- `offer (${definition.operators.join(', ')}; offered: ${base.join(', ')}). ` +
7216
- `Unsupported entries are ignored${survivors.length ? '' : '; falling back to the full list'}.`);
7217
- }
7218
- codes = survivors.length ? survivors : base;
7219
- }
7220
- return { codes, options: toOperatorOptions(definition.kind, codes) };
7221
- };
7222
- const offered = new Map();
7223
- for (const id of Object.keys(definitions)) {
7224
- offered.set(id, resolveOffered(id));
7455
+ class AllClickEvent {
7456
+ constructor({ checked, additionalIndexes, } = {}) {
7457
+ this.checked = checked;
7458
+ this.additionalIndexes = additionalIndexes;
7225
7459
  }
7226
- /**
7227
- * The operator applied when nobody picked one. Options mean `In`, whatever the kind's default;
7228
- * a narrowed filter defaults to its FIRST declared operator.
7229
- */
7230
- const defaultOperatorFor = (id) => {
7231
- const definition = definitions[id];
7232
- if (definition.options != null)
7233
- return MatchModeCodes.In;
7234
- return definition.operators?.length
7235
- ? offered.get(id).codes[0]
7236
- : DEFAULT_OPERATOR[definition.kind];
7237
- };
7238
- // Two stores, and the split is the whole point: `set` writes a DRAFT, `commit` publishes it.
7239
- // A chip drawn off a draft would repeat the mistake the header's filter icon already shipped —
7240
- // claiming the grid is narrowed on the first keystroke. Controls that apply on change
7241
- // (multiselect, boolean, date) call both at once; a text box commits on Enter or blur.
7242
- const drafts = signal(new Map());
7243
- const writeDraft = (id, constraint) => {
7244
- const next = new Map(drafts());
7245
- if (constraint === null)
7246
- next.delete(id);
7247
- else
7248
- next.set(id, constraint);
7249
- drafts.set(next);
7250
- };
7251
- const committed = signal(new Map());
7252
- const applied = computed(() => [...committed()].map(([id, constraint]) => ({
7253
- id: id,
7254
- label: definitions[id].label,
7255
- kind: definitions[id].kind,
7256
- operator: constraint.operator,
7257
- operatorPhraseKey: OPERATOR_WORDS[definitions[id].kind][constraint.operator]?.phraseKey ??
7258
- constraint.operator,
7259
- value: constraint.value,
7260
- })));
7261
- const store = {
7262
- definitions,
7263
- applied,
7264
- /**
7265
- * A handle bound to one filter. This is the PLACEMENT api: it needs nothing from the component
7266
- * tree, so the same filter can be driven from the bar, from a header cell, or from a drawer
7267
- * rendered at app root. `value` is the DRAFT — a control shows what was typed, not what was
7268
- * applied; `applied()` is the other question and the bar asks that one.
7269
- */
7270
- get(id) {
7271
- return {
7272
- id: id,
7273
- label: definitions[id].label,
7274
- kind: definitions[id].kind,
7275
- value: computed(() => drafts().get(id)?.value),
7276
- operator: computed(() => drafts().get(id)?.operator),
7277
- // Getters, not snapshots: options can land AFTER a handle is taken — the bar's editor
7278
- // holds one for the life of the popover, and PACMS fills its lookups asynchronously —
7279
- // and a snapshot left an already-open pick-list empty until it was reopened. The
7280
- // operator list rides along, since setOptions can flip it to `In`.
7281
- get operators() {
7282
- return offered.get(id).options;
7283
- },
7284
- get defaultOperator() {
7285
- return defaultOperatorFor(id);
7286
- },
7287
- get options() {
7288
- return definitions[id].options;
7289
- },
7290
- set: (constraint) => store.set(id, constraint),
7291
- commit: () => store.commit(id),
7292
- reset: () => store.reset(id),
7293
- };
7294
- },
7295
- /**
7296
- * The ONE seam for choices that arrive after the store is built (PACMS fills its pick-lists
7297
- * from lookups that race a deploy). Never write `definitions[id].options` by hand: options'
7298
- * PRESENCE is what makes a filter a pick-list, so the setter also re-resolves the offered
7299
- * operators — a hand write would leave a late-filled filter offering `Equals` on a control
7300
- * that emits a list.
7301
- */
7302
- setOptions(id, options) {
7303
- definitions[id].options = options;
7304
- offered.set(id, resolveOffered(id));
7305
- },
7306
- set(id, constraint) {
7307
- const kind = definitions[id].kind;
7308
- // Throws rather than logging and ignoring, which is what `computeMatchModes` does for a
7309
- // column's OFFERED match modes. Different stakes: that one narrows a dropdown, so a bad
7310
- // entry merely fails to show, while this one assembles a QUERY — dropping it silently would
7311
- // leave the operator looking at unfiltered rows believing they are filtered.
7312
- if (!ALLOWED_OPERATORS[kind].includes(constraint.operator)) {
7313
- throw new Error(`Filter '${String(id)}' is ${kind}, which does not accept the '${constraint.operator}' ` +
7314
- `operator (allowed: ${ALLOWED_OPERATORS[kind].join(', ')}). The generated paginator ` +
7315
- `answers an unsupported match mode with InvalidMatchMode.`);
7316
- }
7317
- writeDraft(id, constraint);
7318
- },
7319
- /**
7320
- * `set` + `commit` in one breath — the shape every PROGRAMMATIC write takes (a view's
7321
- * `apply`, the sheet flow). The split API stays for controls, where draft-on-type /
7322
- * publish-on-Apply is the point; but a bare `set` whose `commit` is forgotten (or written
7323
- * with the wrong id — the pair repeats the string twice) fails silently: the draft is
7324
- * written, nothing is published, and a view narrows by nothing under a labelled tab.
7325
- */
7326
- setAndCommit(id, constraint) {
7327
- store.set(id, constraint);
7328
- store.commit(id);
7329
- },
7330
- /**
7331
- * Publishes the draft. A blanked draft REMOVES the constraint rather than committing an empty
7332
- * one, so `applied()` and the payload agree: no chip, no key, no `contains ''`.
7333
- */
7334
- commit(id) {
7335
- const draft = drafts().get(id);
7336
- const current = committed();
7337
- const shouldRemove = draft === undefined || isBlank(draft.value);
7338
- // A new Map is a new identity even with identical contents, and everything downstream
7339
- // reacts to identity — so a no-op commit would spend a request. The bail compares the
7340
- // CONSTRAINT, not the draft object: every set() builds a fresh draft, and the case that
7341
- // matters is an identical value re-committed (see valueEquals).
7342
- if (shouldRemove ? !current.has(id) : constraintEquals(current.get(id), draft))
7343
- return;
7344
- const next = new Map(current);
7345
- if (shouldRemove)
7346
- next.delete(id);
7347
- else
7348
- next.set(id, draft);
7349
- committed.set(next);
7350
- },
7351
- /**
7352
- * The chip's `x`. Drops the DRAFT as well as the committed constraint: a surviving draft
7353
- * leaves the control still showing the cleared text, and the next commit would restore a
7354
- * filter nobody re-typed.
7355
- */
7356
- reset(id) {
7357
- if (drafts().has(id))
7358
- writeDraft(id, null);
7359
- if (!committed().has(id))
7360
- return;
7361
- const next = new Map(committed());
7362
- next.delete(id);
7363
- committed.set(next);
7364
- },
7365
- /**
7366
- * The applied set, plain enough to store. Drafts are deliberately NOT included: a half-typed
7367
- * value is not a state anyone should come back to, and the bar's whole claim is that what it
7368
- * shows is what narrows the grid.
7369
- */
7370
- snapshot() {
7371
- const snapshot = {};
7372
- for (const [id, constraint] of committed()) {
7373
- snapshot[id] = {
7374
- operator: constraint.operator,
7375
- value: constraint.value,
7376
- };
7377
- }
7378
- return snapshot;
7379
- },
7380
- /**
7381
- * Replaces the applied set. The drafts are written too, so reopening a restored chip shows
7382
- * the value it is filtering by rather than an empty control.
7383
- *
7384
- * Ids the store no longer declares are dropped rather than carried: a filter removed from the
7385
- * declaration in a later release would otherwise ride every request from anyone whose storage
7386
- * still held it, with no chip able to name it and no way to clear it from the UI.
7387
- */
7388
- restore(snapshot) {
7389
- const next = new Map();
7390
- for (const [id, constraint] of Object.entries(snapshot ?? {})) {
7391
- if (!(id in definitions))
7392
- continue;
7393
- next.set(id, {
7394
- operator: constraint.operator,
7395
- value: reviveValue(constraint.value),
7396
- });
7397
- }
7398
- drafts.set(new Map(next));
7399
- committed.set(next);
7400
- },
7401
- /** The bar's "Clear all". `reset` for every filter, drafts included. */
7402
- clear() {
7403
- if (drafts().size > 0)
7404
- drafts.set(new Map());
7405
- if (committed().size > 0)
7406
- committed.set(new Map());
7407
- },
7408
- /** The `filters` half of `Filter` — what the generated paginator reads. */
7409
- toFilterPayload() {
7410
- const payload = {};
7411
- for (const [id, constraint] of committed()) {
7412
- payload[id] = [
7413
- { matchMode: constraint.operator, value: constraint.value },
7414
- ];
7415
- }
7416
- return payload;
7417
- },
7418
- };
7419
- return store;
7420
7460
  }
7421
7461
 
7422
7462
  class SpiderlyDataViewComponent {