pict-section-recordset 1.5.0 → 1.6.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/README.md CHANGED
@@ -11,6 +11,36 @@ The only thing that's really required for this to operate is a Record/Record
11
11
  Set provider... and given a Meadow Endpoint, the `/1.0/Entity/Schema` endpoint
12
12
  can be used to programmatically create a functional provider.
13
13
 
14
+ ## Quick Filters
15
+
16
+ A curated, one-interaction filter bar at the top of the filter drawer — the handful of filters people reach for most often (fuzzy-search the title, "added in this range", "added by …"), pre-wired so there's no "Add filter → pick a field → pick a clause type" hunt. Each control writes a real filter clause, so it serializes, applies, and clears like anything in the full list.
17
+
18
+ **Clever by default.** With no configuration the bar is derived from the entity schema:
19
+
20
+ | Field present | Quick control |
21
+ |---|---|
22
+ | `Title` (else `Name`) | fuzzy text |
23
+ | `CreateDate` / `UpdateDate` | date range |
24
+ | `CreatingIDUser` / `UpdatingIDUser` | entity picker (the related entity) |
25
+
26
+ **Curated by config.** Set `QuickFilters` on the RecordSet config to control which filters appear, their labels, and their order. Entries are a field name, or an object for a custom label / explicit clause:
27
+
28
+ ```javascript
29
+ {
30
+ "RecordSet": "Book",
31
+ "RecordSetMeadowEntity": "Book",
32
+ "QuickFilters":
33
+ [
34
+ "Title",
35
+ "Genre",
36
+ { "Field": "CreateDate", "Label": "Added" },
37
+ { "Field": "CreatingIDUser", "Label": "Added by" }
38
+ ]
39
+ }
40
+ ```
41
+
42
+ The control type is inferred from the field's clause (text for a string match, a from/to pair for a date range, a [pict-section-picker](https://github.com/fable-retold/pict-section-picker) for an entity reference). A quick clause lives in the same filter state as every other clause (tagged so the full clause list doesn't show it twice), and is removed when its value is cleared.
43
+
14
44
  ## Related Packages
15
45
 
16
46
  - [pict](https://github.com/fable-retold/pict) - MVC application framework
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pict-section-recordset",
3
- "version": "1.5.0",
3
+ "version": "1.6.0",
4
4
  "description": "Pict dynamic record set management views",
5
5
  "main": "source/Pict-Section-RecordSet.js",
6
6
  "files": [
@@ -368,6 +368,251 @@ class RecordSetProviderBase extends libPictProvider
368
368
  return tmpClauses;
369
369
  }
370
370
 
371
+ // --- Quick Filters -----------------------------------------------------------------------------
372
+ // A curated, one-interaction set of filters surfaced at the top of the drawer (fuzzy-search Title,
373
+ // Created range, Created-by, …) so common filtering skips the "Add filter → pick field → pick type"
374
+ // hunt. Each quick filter reuses a real clause descriptor from the field's AvailableClauses; its live
375
+ // clause lives in the same FilterClauses array (tagged `QuickFilter:true`, keyed by `QuickFilterKey`)
376
+ // so it serializes / applies / clears like any other clause — the drawer's clause list just skips it.
377
+
378
+ /**
379
+ * Resolve the quick-filter definitions for this record set: the host's `QuickFilters` config if
380
+ * present, else clever defaults derived from the schema. Each definition is resolved to a concrete
381
+ * clause descriptor (selected from the field's `AvailableClauses`) so quick filters never invent a
382
+ * parallel clause path.
383
+ *
384
+ * @return {Array<{Field:string, Label:string, Control:string, ClauseKey:string}>}
385
+ */
386
+ getQuickFilterDefinitions()
387
+ {
388
+ const tmpEntries = Array.isArray(this.options.QuickFilters) ? this.options.QuickFilters : this._deriveDefaultQuickFilters();
389
+ const tmpDefinitions = [];
390
+ for (let i = 0; i < tmpEntries.length; i++)
391
+ {
392
+ const tmpEntry = (typeof tmpEntries[i] === 'string') ? { Field: tmpEntries[i] } : (tmpEntries[i] || {});
393
+ const tmpDefinition = this._resolveQuickFilterDefinition(tmpEntry);
394
+ if (tmpDefinition) { tmpDefinitions.push(tmpDefinition); }
395
+ }
396
+ return tmpDefinitions;
397
+ }
398
+
399
+ /**
400
+ * Clever defaults (zero config): the common things people filter on, detected by canonical Meadow
401
+ * column names on the fetched schema. Primary text (Title→Name), the audit date ranges, the audit
402
+ * user references. Absent `_Schema` (non-Meadow provider) → no defaults.
403
+ *
404
+ * @return {Array<{Field:string, Label?:string}>}
405
+ */
406
+ _deriveDefaultQuickFilters()
407
+ {
408
+ const tmpProperties = (this._Schema && this._Schema.properties) || null;
409
+ if (!tmpProperties) { return []; }
410
+ const fHas = (pField) => Object.prototype.hasOwnProperty.call(tmpProperties, pField);
411
+ const tmpDefaults = [];
412
+ // `RequireControl` pins a default to its intended control: a "Created" default is only useful as
413
+ // a date range, a "Created by" only as an entity picker. If the field can't offer that (e.g. a
414
+ // schema that types its dates as plain strings), the default is dropped rather than degrading to
415
+ // an odd text-search-a-timestamp. Host-configured QuickFilters carry no RequireControl, so they
416
+ // take whatever clause the field offers.
417
+ if (fHas('Title')) { tmpDefaults.push({ Field: 'Title' }); }
418
+ else if (fHas('Name')) { tmpDefaults.push({ Field: 'Name' }); }
419
+ if (fHas('CreateDate')) { tmpDefaults.push({ Field: 'CreateDate', Label: 'Created', RequireControl: 'daterange' }); }
420
+ if (fHas('UpdateDate')) { tmpDefaults.push({ Field: 'UpdateDate', Label: 'Updated', RequireControl: 'daterange' }); }
421
+ if (fHas('CreatingIDUser')) { tmpDefaults.push({ Field: 'CreatingIDUser', Label: 'Created by', RequireControl: 'entity' }); }
422
+ if (fHas('UpdatingIDUser')) { tmpDefaults.push({ Field: 'UpdatingIDUser', Label: 'Updated by', RequireControl: 'entity' }); }
423
+ return tmpDefaults;
424
+ }
425
+
426
+ /**
427
+ * Resolve one quick-filter config entry to a renderable definition: pick the clause (explicit
428
+ * `ClauseKey`, else the best for a quick filter) from the field's AvailableClauses + classify it to a
429
+ * control type. Returns null when the field has no usable clause.
430
+ *
431
+ * @param {Record<string, any>} pEntry @return {{Field:string, Label:string, Control:string, ClauseKey:string}|null}
432
+ */
433
+ _resolveQuickFilterDefinition(pEntry)
434
+ {
435
+ if (!pEntry || !pEntry.Field) { return null; }
436
+ const tmpFieldSchema = this.getFilterClauseSchemaForKey(pEntry.Field);
437
+ const tmpAvailableClauses = tmpFieldSchema && Array.isArray(tmpFieldSchema.AvailableClauses) ? tmpFieldSchema.AvailableClauses : null;
438
+ if (!tmpAvailableClauses || tmpAvailableClauses.length < 1) { return null; }
439
+ const tmpClause = pEntry.ClauseKey
440
+ ? tmpAvailableClauses.find((pClause) => pClause.ClauseKey === pEntry.ClauseKey)
441
+ : this._pickQuickClause(tmpAvailableClauses);
442
+ if (!tmpClause) { return null; }
443
+ const tmpControl = this._controlForClauseType(tmpClause.Type);
444
+ if (!tmpControl) { return null; }
445
+ // A default that demands a specific control (e.g. date range) is dropped when the field can't offer it.
446
+ if (pEntry.RequireControl && tmpControl !== pEntry.RequireControl) { return null; }
447
+ return {
448
+ Field: pEntry.Field,
449
+ Label: pEntry.Label || tmpFieldSchema.DisplayName || pEntry.Field,
450
+ Control: tmpControl,
451
+ ClauseKey: tmpClause.ClauseKey,
452
+ };
453
+ }
454
+
455
+ /**
456
+ * Choose the clause a quick filter should drive for a field: an entity selected-value, else a fuzzy
457
+ * string match, else a range — i.e. the simplest one-interaction clause the field offers.
458
+ *
459
+ * @param {Array<Record<string, any>>} pAvailableClauses @return {Record<string, any>|undefined}
460
+ */
461
+ _pickQuickClause(pAvailableClauses)
462
+ {
463
+ return pAvailableClauses.find((pClause) => pClause.Type === 'InternalJoinSelectedValue' || pClause.Type === 'InternalJoinSelectedValueList')
464
+ || pAvailableClauses.find((pClause) => pClause.Type === 'StringMatch' && pClause.ExactMatch === false)
465
+ || pAvailableClauses.find((pClause) => pClause.Type === 'DateRange')
466
+ || pAvailableClauses.find((pClause) => pClause.Type === 'NumericRange')
467
+ || pAvailableClauses[0];
468
+ }
469
+
470
+ /**
471
+ * Map a clause Type to the quick-bar control that drives it.
472
+ * @param {string} pType @return {string|null}
473
+ */
474
+ _controlForClauseType(pType)
475
+ {
476
+ switch (pType)
477
+ {
478
+ case 'StringMatch': return 'text';
479
+ case 'DateRange': return 'daterange';
480
+ case 'InternalJoinSelectedValue':
481
+ case 'InternalJoinSelectedValueList': return 'entity';
482
+ default: return null;
483
+ }
484
+ }
485
+
486
+ /** @param {string} pField @return {any} The current value of a field's quick-filter clause, or ''. */
487
+ getQuickFilterClauseValue(pField)
488
+ {
489
+ const tmpClause = this.getFilterClauses().find((pClause) => pClause.QuickFilterKey === `Quick-${pField}`);
490
+ return tmpClause ? tmpClause.Value : '';
491
+ }
492
+
493
+ /**
494
+ * Upsert (or, when the value is empty, remove) a field's quick-filter clause with a scalar value.
495
+ * Creates the clause from the field's descriptor on first use, tagged so it renders in the quick bar
496
+ * and serializes/applies like any clause.
497
+ *
498
+ * @param {string} pField @param {string} pClauseKey @param {any} pValue
499
+ */
500
+ upsertQuickFilterClauseValue(pField, pClauseKey, pValue)
501
+ {
502
+ const tmpClauses = this.getFilterClauses();
503
+ const tmpQuickFilterKey = `Quick-${pField}`;
504
+ const tmpExistingIndex = tmpClauses.findIndex((pClause) => pClause.QuickFilterKey === tmpQuickFilterKey);
505
+ const tmpEmpty = (pValue === undefined || pValue === null || pValue === '');
506
+ if (tmpEmpty)
507
+ {
508
+ if (tmpExistingIndex >= 0) { tmpClauses.splice(tmpExistingIndex, 1); }
509
+ return;
510
+ }
511
+ if (tmpExistingIndex >= 0)
512
+ {
513
+ tmpClauses[tmpExistingIndex].Value = pValue;
514
+ return;
515
+ }
516
+ const tmpClause = this._createQuickFilterClause(pField, pClauseKey, tmpQuickFilterKey);
517
+ if (!tmpClause) { return; }
518
+ tmpClause.Value = pValue;
519
+ tmpClauses.push(tmpClause);
520
+ }
521
+
522
+ /** @param {string} pField @return {{Start:any, End:any}} The current DateRange quick-filter bounds. */
523
+ getQuickFilterDateRangeValue(pField)
524
+ {
525
+ const tmpClause = this.getFilterClauses().find((pClause) => pClause.QuickFilterKey === `Quick-${pField}`);
526
+ const tmpValues = (tmpClause && tmpClause.Values && typeof tmpClause.Values === 'object' && !Array.isArray(tmpClause.Values)) ? tmpClause.Values : {};
527
+ return { Start: tmpValues.Start || '', End: tmpValues.End || '' };
528
+ }
529
+
530
+ /**
531
+ * Set one bound of a field's DateRange quick-filter clause (create on first use, remove when BOTH
532
+ * bounds are empty). Bounds live at `clause.Values.Start` / `.End` (the DateRange contract).
533
+ *
534
+ * @param {string} pField @param {string} pClauseKey @param {'start'|'end'} pWhich @param {any} pValue
535
+ */
536
+ upsertQuickFilterDateRange(pField, pClauseKey, pWhich, pValue)
537
+ {
538
+ const tmpClauses = this.getFilterClauses();
539
+ const tmpQuickFilterKey = `Quick-${pField}`;
540
+ let tmpClause = tmpClauses.find((pClause) => pClause.QuickFilterKey === tmpQuickFilterKey);
541
+ const tmpValue = (pValue === undefined || pValue === null) ? '' : pValue;
542
+ if (!tmpClause)
543
+ {
544
+ if (tmpValue === '') { return; }
545
+ tmpClause = this._createQuickFilterClause(pField, pClauseKey, tmpQuickFilterKey);
546
+ if (!tmpClause) { return; }
547
+ tmpClause.Values = {};
548
+ tmpClauses.push(tmpClause);
549
+ }
550
+ if (!tmpClause.Values || typeof tmpClause.Values !== 'object' || Array.isArray(tmpClause.Values)) { tmpClause.Values = {}; }
551
+ if (pWhich === 'start') { tmpClause.Values.Start = tmpValue; }
552
+ else { tmpClause.Values.End = tmpValue; }
553
+ const fEmpty = (pVal) => (pVal === undefined || pVal === null || pVal === '');
554
+ if (fEmpty(tmpClause.Values.Start) && fEmpty(tmpClause.Values.End))
555
+ {
556
+ const tmpIndex = tmpClauses.findIndex((pClause) => pClause.QuickFilterKey === tmpQuickFilterKey);
557
+ if (tmpIndex >= 0) { tmpClauses.splice(tmpIndex, 1); }
558
+ }
559
+ }
560
+
561
+ /** @param {string} pField @return {Array<any>} The current entity quick-filter selected value(s). */
562
+ getQuickFilterEntityValue(pField)
563
+ {
564
+ const tmpClause = this.getFilterClauses().find((pClause) => pClause.QuickFilterKey === `Quick-${pField}`);
565
+ return (tmpClause && Array.isArray(tmpClause.Values)) ? tmpClause.Values : [];
566
+ }
567
+
568
+ /**
569
+ * Set a field's entity quick-filter clause to the picked value(s) (create on first use, remove when
570
+ * empty). Selected ids live at `clause.Values` (the entity-reference contract).
571
+ *
572
+ * @param {string} pField @param {string} pClauseKey @param {any} pValue scalar or array
573
+ */
574
+ upsertQuickFilterEntity(pField, pClauseKey, pValue)
575
+ {
576
+ const tmpClauses = this.getFilterClauses();
577
+ const tmpQuickFilterKey = `Quick-${pField}`;
578
+ const tmpArray = (Array.isArray(pValue) ? pValue : ((pValue === undefined || pValue === null || pValue === '') ? [] : [ pValue ]))
579
+ .filter((pVal) => pVal !== undefined && pVal !== null && pVal !== '');
580
+ const tmpIndex = tmpClauses.findIndex((pClause) => pClause.QuickFilterKey === tmpQuickFilterKey);
581
+ if (tmpArray.length === 0)
582
+ {
583
+ if (tmpIndex >= 0) { tmpClauses.splice(tmpIndex, 1); }
584
+ return;
585
+ }
586
+ if (tmpIndex >= 0) { tmpClauses[tmpIndex].Values = tmpArray; return; }
587
+ const tmpClause = this._createQuickFilterClause(pField, pClauseKey, tmpQuickFilterKey);
588
+ if (!tmpClause) { return; }
589
+ tmpClause.Values = tmpArray;
590
+ tmpClauses.push(tmpClause);
591
+ }
592
+
593
+ /**
594
+ * Clone a field's clause descriptor into a fresh, tagged quick-filter clause (no value set yet), or
595
+ * null when the descriptor is missing. Shared by the text / date / entity upserts.
596
+ *
597
+ * @param {string} pField @param {string} pClauseKey @param {string} pQuickFilterKey
598
+ * @return {Record<string, any>|null}
599
+ */
600
+ _createQuickFilterClause(pField, pClauseKey, pQuickFilterKey)
601
+ {
602
+ const tmpDescriptor = this.getFilterClauseSchemaForKey(pField)?.AvailableClauses?.find?.((pClause) => pClause.ClauseKey === pClauseKey);
603
+ if (!tmpDescriptor)
604
+ {
605
+ this.pict.log.error(`RecordSetProviderBase quick filter: no clause schema for field ${pField} / clause ${pClauseKey}.`);
606
+ return null;
607
+ }
608
+ const tmpClause = JSON.parse(JSON.stringify(tmpDescriptor));
609
+ tmpClause.Hash = `${pField}-${pClauseKey}-${this.pict.getUUID()}`;
610
+ tmpClause.Label = tmpClause.Label || tmpClause.DisplayName;
611
+ tmpClause.QuickFilter = true;
612
+ tmpClause.QuickFilterKey = pQuickFilterKey;
613
+ return tmpClause;
614
+ }
615
+
371
616
  /**
372
617
  * @param {string} pSpecificFilterClauseHash - The hash of the specific filter clause to move.
373
618
  * @param {number} pOrdinal - The ordinal position to move the filter clause to.
@@ -517,6 +517,7 @@ class MeadowEndpointsRecordSetProvider extends libRecordSetProviderBase
517
517
  case 'datetime':
518
518
  case 'createdate':
519
519
  case 'updatedate':
520
+ case 'deletedate':
520
521
  tmpFieldFilterClauses.push({ FilterKey: pSchemaField, ClauseKey: `${pSchemaField}_Match_Exact`, Label: `${tmpFieldHumanName} Exact Match`, DisplayName: `Exact Match`, Type: 'DateMatch', FilterByColumn: pSchemaField, ExactMatch: true , Ordinal: tmpFieldFilterClauses.length + 1 });
521
522
  tmpFieldFilterClauses.push({ FilterKey: pSchemaField, ClauseKey: `${pSchemaField}_Match_Fuzzy`, Label: `${tmpFieldHumanName} Partial Match`, DisplayName: `Partial Match`, Type: 'DateMatch', FilterByColumn: pSchemaField, ExactMatch: false , Ordinal: tmpFieldFilterClauses.length + 1 });
522
523
  tmpRangeClause = { FilterKey: pSchemaField, ClauseKey: `${pSchemaField}_Range`, Label: `${tmpFieldHumanName} In Range`, DisplayName: `In Range`, Type: 'DateRange', FilterByColumn: pSchemaField , Ordinal: tmpFieldFilterClauses.length + 1 };
@@ -745,7 +746,12 @@ class MeadowEndpointsRecordSetProvider extends libRecordSetProviderBase
745
746
  }
746
747
  ++tmpOrdinal;
747
748
  const tmpColumn = tmpProperties[tmpSchemaField];
748
- const tmpMeadowSchemaField = tmpSchema.MeadowSchema?.Schema?.find?.((f) => f.Column === tmpSchemaField);
749
+ // The Meadow schema endpoint nests its canonical column array (the one carrying each column's
750
+ // semantic Type — CreateDate/UpdateDate/CreateIDUser/… — which is what distinguishes a date
751
+ // column from a plain string; the JSON-schema `type` flattens both to "string"). Read the
752
+ // nested path, falling back to the legacy flat path for older endpoints.
753
+ const tmpMeadowSchemaArray = tmpSchema.MeadowSchema?.MeadowSchema?.Schema || tmpSchema.MeadowSchema?.Schema;
754
+ const tmpMeadowSchemaField = Array.isArray(tmpMeadowSchemaArray) ? tmpMeadowSchemaArray.find((f) => f.Column === tmpSchemaField) : undefined;
749
755
  let tmpFieldFilterSchema = this._FilterSchema[tmpSchemaField];
750
756
  if (!tmpFieldFilterSchema)
751
757
  {
@@ -74,6 +74,10 @@ class PictTemplateFilterInstanceViewInstruction extends libPictTemplate
74
74
 
75
75
  _getViewForFilterClause(pClause)
76
76
  {
77
+ // Quick-filter clauses are surfaced in the Quick Filters bar (RecordSet-Filters._renderQuickFilters),
78
+ // not the drawer's clause list — skip them here so they aren't rendered twice. Both the sync and
79
+ // async render loops treat a null view as "skip this clause".
80
+ if (pClause && pClause.QuickFilter) { return null; }
77
81
  let tmpViewHash = `PRSP-FilterType-${pClause.Type}`;
78
82
  const tmpCustomViewHash = `${tmpViewHash}-${pClause.CustomFilterViewHash}`;
79
83
  if (tmpCustomViewHash in this.pict.views)
@@ -66,6 +66,25 @@ const _DEFAULT_CONFIGURATION_SUBSET_Filter =
66
66
  .prsp-filters-experiences { flex: 0 1 auto; min-width: 0; }
67
67
  .prsp-filters-actions { flex: 0 0 auto; display: flex; align-items: center; gap: 0.5rem; }
68
68
 
69
+ /* Quick Filters — a curated, one-interaction bar at the top of the drawer (above the clause list).
70
+ Painted post-render into #PRSP_QuickFilters; hidden via :empty when the record set has none. */
71
+ .prsp-quickfilters { display: flex; align-items: center; flex-wrap: wrap; gap: 0.55rem 0.9rem;
72
+ margin-bottom: 0.7rem; padding-bottom: 0.75rem; border-bottom: 1px solid var(--theme-color-border-light, #e8ebf0); }
73
+ .prsp-quickfilters:empty { display: none; }
74
+ .prsp-quickfilters-label { font-size: 0.72rem; font-weight: 700; letter-spacing: 0.04em; text-transform: uppercase;
75
+ color: var(--theme-color-text-muted, #6b7686); }
76
+ .prsp-quickfilter { display: inline-flex; flex-direction: column; gap: 0.2rem; min-width: 0; }
77
+ .prsp-quickfilter-name { font-size: 0.72rem; font-weight: 600; color: var(--theme-color-text-secondary, #45505f); }
78
+ .prsp-quickfilter-input { font: inherit; font-size: 0.9rem; width: 13rem; max-width: 100%; padding: 0.4rem 0.7rem; border-radius: 8px;
79
+ border: 1px solid var(--theme-color-border-default, #d7dce3); background: var(--theme-color-background-primary, #fff); color: var(--theme-color-text-primary, #1f2733); }
80
+ .prsp-quickfilter-input:focus { outline: none; border-color: var(--theme-color-brand-primary, #156dd1);
81
+ box-shadow: 0 0 0 3px color-mix(in srgb, var(--theme-color-brand-primary, #156dd1) 16%, transparent); }
82
+ .prsp-quickfilter-daterange { display: inline-flex; align-items: center; gap: 0.35rem; }
83
+ .prsp-quickfilter-date { width: 9.5rem; }
84
+ .prsp-quickfilter-dash { color: var(--theme-color-text-muted, #6b7686); }
85
+ /* Entity quick control: the pict-section-picker mounts into this host (its own .pps chrome themes it). */
86
+ .prsp-quickfilter-entityhost { display: inline-block; width: 14rem; max-width: 100%; vertical-align: middle; }
87
+
69
88
  /* Module-owned "Add filter" popover (replaces the old native <select> pickers). */
70
89
  /* Fixed (viewport-anchored) + JS-positioned on open, so no ancestor overflow:hidden — the filter card,
71
90
  the slide-out drawer — can clip it, whatever the host's layout. */
@@ -114,6 +133,9 @@ const _DEFAULT_CONFIGURATION_SUBSET_Filter =
114
133
  </form>
115
134
  <div class="prsp-filters-drawer" id="PRSP_Filter_Drawer">
116
135
  <div class="prsp-filters-drawer-inner">
136
+ <!-- Quick Filters: a curated, one-interaction bar painted post-render into this host
137
+ (empty when the record set has no quick filters → CSS :empty hides it). -->
138
+ <div class="prsp-quickfilters" id="PRSP_QuickFilters"></div>
117
139
  <div id="PRSP_Filter_Instances" class="prsp-filters-clauses" onkeydown="if (event.key === 'Enter' &amp;&amp; !event.target.closest('.pps')) { event.preventDefault(); _Pict.views['PRSP-Filters'].handleSearch(event, '{~D:Record.RecordSet~}', '{~D:Record.ViewContext~}'); }">
118
140
  {~FIV:Record~}
119
141
  </div>
@@ -235,6 +257,59 @@ const _DEFAULT_CONFIGURATION_SUBSET_Filter =
235
257
  <span>{~D:Record.DisplayName~}</span>
236
258
  </button>
237
259
  <!-- DefaultPackage end view template: [PRSP-AddFilter-Clause] -->
260
+ `
261
+ },
262
+ {
263
+ // Quick Filters bar contents (painted into #PRSP_QuickFilters post-render). A small label +
264
+ // one compact control per definition; each control drives a real, tagged clause.
265
+ Hash: 'PRSP-QuickFilters-Bar',
266
+ Template: /*html*/`
267
+ <span class="prsp-quickfilters-label">Quick filters</span>
268
+ {~TS:PRSP-QuickFilter-Item:Record.Filters~}
269
+ `
270
+ },
271
+ {
272
+ // One quick filter: a name + exactly one control, chosen by the single-element-array slot
273
+ // (text / date range / entity picker) populated for this item's control type.
274
+ Hash: 'PRSP-QuickFilter-Item',
275
+ Template: /*html*/`
276
+ <div class="prsp-quickfilter">
277
+ <span class="prsp-quickfilter-name">{~D:Record.Label~}</span>
278
+ {~TS:PRSP-QuickFilter-Text:Record.TextSlot~}
279
+ {~TS:PRSP-QuickFilter-Date:Record.DateSlot~}
280
+ {~TS:PRSP-QuickFilter-Entity:Record.EntitySlot~}
281
+ </div>
282
+ `
283
+ },
284
+ {
285
+ // Text control (fuzzy match) — commits on blur / Enter (never per-keystroke, so the
286
+ // re-render that an apply triggers can't steal focus mid-type).
287
+ Hash: 'PRSP-QuickFilter-Text',
288
+ Template: /*html*/`
289
+ <input class="prsp-quickfilter-input" type="text" value="{~D:Record.Value~}" placeholder="{~D:Record.Placeholder~}" autocomplete="off"
290
+ onkeydown="if (event.key === 'Enter') { event.preventDefault(); this.blur(); }"
291
+ onchange="_Pict.views['PRSP-Filters'].applyQuickFilterText('{~D:Record.RecordSet~}', '{~D:Record.ViewContext~}', '{~D:Record.Field~}', '{~D:Record.ClauseKey~}', this.value)">
292
+ `
293
+ },
294
+ {
295
+ // Date-range control — a from/to pair driving one DateRange clause (Values.Start / .End).
296
+ Hash: 'PRSP-QuickFilter-Date',
297
+ Template: /*html*/`
298
+ <span class="prsp-quickfilter-daterange">
299
+ <input type="date" class="prsp-quickfilter-input prsp-quickfilter-date" value="{~D:Record.StartValue~}" aria-label="{~D:Record.Label~} from"
300
+ onchange="_Pict.views['PRSP-Filters'].applyQuickFilterDate('{~D:Record.RecordSet~}', '{~D:Record.ViewContext~}', '{~D:Record.Field~}', '{~D:Record.ClauseKey~}', 'start', this.value)">
301
+ <span class="prsp-quickfilter-dash">–</span>
302
+ <input type="date" class="prsp-quickfilter-input prsp-quickfilter-date" value="{~D:Record.EndValue~}" aria-label="{~D:Record.Label~} to"
303
+ onchange="_Pict.views['PRSP-Filters'].applyQuickFilterDate('{~D:Record.RecordSet~}', '{~D:Record.ViewContext~}', '{~D:Record.Field~}', '{~D:Record.ClauseKey~}', 'end', this.value)">
304
+ </span>
305
+ `
306
+ },
307
+ {
308
+ // Entity control — a pict-section-picker mounts into this host (post-render, in
309
+ // _mountQuickFilterEntity), so the quick filter reuses the real entity picker.
310
+ Hash: 'PRSP-QuickFilter-Entity',
311
+ Template: /*html*/`
312
+ <span class="prsp-quickfilter-entityhost" id="{~D:Record.HostID~}"></span>
238
313
  `
239
314
  },
240
315
  ],
@@ -527,6 +602,137 @@ class ViewRecordSetSUBSETFilters extends libPictView
527
602
  if (tmpInput) { tmpInput.value = this._searchTermFromURL(); }
528
603
  }
529
604
 
605
+ /**
606
+ * Paint the Quick Filters bar into #PRSP_QuickFilters from the record set's quick-filter definitions
607
+ * (host config or clever defaults), each control seeded with its clause's current value. Phase 1
608
+ * renders the text controls; date/entity controls follow. Empty → the bar's :empty CSS hides it.
609
+ *
610
+ * @param {string} pRecordSet @param {string} pViewContext
611
+ */
612
+ _renderQuickFilters(pRecordSet, pViewContext)
613
+ {
614
+ if (!document.getElementById('PRSP_QuickFilters')) { return; }
615
+ const tmpProvider = this.pict.providers['RSP-Provider-' + pRecordSet];
616
+ if (!tmpProvider || typeof tmpProvider.getQuickFilterDefinitions !== 'function')
617
+ {
618
+ this.pict.ContentAssignment.assignContent('#PRSP_QuickFilters', '');
619
+ return;
620
+ }
621
+ // Build one item per definition, populating exactly one control slot (text / date / entity).
622
+ const tmpEntityMounts = [];
623
+ const tmpItems = tmpProvider.getQuickFilterDefinitions().map((pDefinition) =>
624
+ {
625
+ const tmpBase = { Field: pDefinition.Field, ClauseKey: pDefinition.ClauseKey, Label: pDefinition.Label, RecordSet: pRecordSet, ViewContext: pViewContext };
626
+ const tmpItem = { Label: pDefinition.Label, TextSlot: [], DateSlot: [], EntitySlot: [] };
627
+ if (pDefinition.Control === 'text')
628
+ {
629
+ tmpItem.TextSlot = [ Object.assign({}, tmpBase, { Value: tmpProvider.getQuickFilterClauseValue(pDefinition.Field), Placeholder: `Search ${pDefinition.Label}…` }) ];
630
+ }
631
+ else if (pDefinition.Control === 'daterange')
632
+ {
633
+ const tmpRange = tmpProvider.getQuickFilterDateRangeValue(pDefinition.Field);
634
+ tmpItem.DateSlot = [ Object.assign({}, tmpBase, { StartValue: tmpRange.Start, EndValue: tmpRange.End }) ];
635
+ }
636
+ else if (pDefinition.Control === 'entity')
637
+ {
638
+ const tmpHostID = `PRSP_QuickEntity_${pRecordSet}_${pDefinition.Field}`;
639
+ tmpItem.EntitySlot = [ Object.assign({}, tmpBase, { HostID: tmpHostID }) ];
640
+ tmpEntityMounts.push(Object.assign({}, tmpBase, { HostID: tmpHostID }));
641
+ }
642
+ return tmpItem;
643
+ });
644
+ const tmpHTML = (tmpItems.length > 0) ? this.pict.parseTemplateByHash('PRSP-QuickFilters-Bar', { Filters: tmpItems }) : '';
645
+ this.pict.ContentAssignment.assignContent('#PRSP_QuickFilters', tmpHTML);
646
+ // Entity controls: mount (or re-mount) a picker into each host after the wholesale re-render.
647
+ tmpEntityMounts.forEach((pMount) => this._mountQuickFilterEntity(pRecordSet, pViewContext, pMount));
648
+ }
649
+
650
+ /**
651
+ * Mount (idempotently) a pict-section-picker into a quick-filter entity host, configured from the
652
+ * field's entity clause descriptor (RemoteTable / search columns / value column), single-select.
653
+ * On change it upserts the clause + applies. Re-runs after each render (the bar repaints wholesale),
654
+ * mirroring the form adapter's re-mount pattern. No-op if the picker module isn't registered.
655
+ *
656
+ * @param {string} pRecordSet @param {string} pViewContext @param {Record<string, any>} pMount
657
+ */
658
+ _mountQuickFilterEntity(pRecordSet, pViewContext, pMount)
659
+ {
660
+ if (!document.getElementById(pMount.HostID)) { return; }
661
+ const tmpPickerProvider = this.pict.providers['Pict-Section-Picker'];
662
+ const tmpProvider = this.pict.providers['RSP-Provider-' + pRecordSet];
663
+ if (!tmpPickerProvider || typeof tmpPickerProvider.createEntityPicker !== 'function' || !tmpProvider) { return; }
664
+ const tmpDescriptor = tmpProvider.getFilterClauseSchemaForKey(pMount.Field)?.AvailableClauses?.find?.((pClause) => pClause.ClauseKey === pMount.ClauseKey);
665
+ if (!tmpDescriptor || !tmpDescriptor.RemoteTable) { return; }
666
+ const tmpSearchFields = Array.isArray(tmpDescriptor.ExternalFilterByColumns) && tmpDescriptor.ExternalFilterByColumns.length > 0 ? tmpDescriptor.ExternalFilterByColumns : [ 'Name' ];
667
+ const tmpView = tmpPickerProvider.createEntityPicker(`Quick-Picker-${pRecordSet}-${pMount.Field}`,
668
+ {
669
+ DestinationAddress: `#${pMount.HostID}`,
670
+ // Quick filters stay single-select for a fast pick (the full drawer entity filter can be multi).
671
+ Mode: 'single',
672
+ Entity: tmpDescriptor.RemoteTable,
673
+ ValueField: tmpDescriptor.JoinExternalConnectionColumn || `ID${tmpDescriptor.RemoteTable}`,
674
+ SearchFields: tmpSearchFields,
675
+ TextField: tmpSearchFields[0],
676
+ Placeholder: `Select ${pMount.Label}…`,
677
+ OnChange: (pValue) => this.applyQuickFilterEntity(pRecordSet, pViewContext, pMount.Field, pMount.ClauseKey, pValue),
678
+ });
679
+ if (!tmpView) { return; }
680
+ tmpView.render();
681
+ const tmpCurrent = (typeof tmpProvider.getQuickFilterEntityValue === 'function') ? tmpProvider.getQuickFilterEntityValue(pMount.Field) : [];
682
+ tmpView.setValue((Array.isArray(tmpCurrent) && tmpCurrent.length > 0) ? tmpCurrent[0] : '');
683
+ }
684
+
685
+ /**
686
+ * Apply a text quick filter: upsert (or clear) its tagged clause, then run the standard search +
687
+ * serialize path. Commits on blur / Enter (not per-keystroke) so the re-render never steals focus.
688
+ *
689
+ * @param {string} pRecordSet @param {string} pViewContext @param {string} pField @param {string} pClauseKey @param {string} pValue
690
+ */
691
+ applyQuickFilterText(pRecordSet, pViewContext, pField, pClauseKey, pValue)
692
+ {
693
+ this.bumpRenderEpoch();
694
+ const tmpProvider = this.pict.providers['RSP-Provider-' + pRecordSet];
695
+ if (tmpProvider && typeof tmpProvider.upsertQuickFilterClauseValue === 'function')
696
+ {
697
+ tmpProvider.upsertQuickFilterClauseValue(pField, pClauseKey, (pValue === undefined || pValue === null) ? '' : String(pValue).trim());
698
+ }
699
+ this.handleSearch(null, pRecordSet, pViewContext);
700
+ }
701
+
702
+ /**
703
+ * Apply a date-range quick filter: set one bound (`start`/`end`) of the field's DateRange clause
704
+ * (removed when both bounds clear), then run the search.
705
+ *
706
+ * @param {string} pRecordSet @param {string} pViewContext @param {string} pField @param {string} pClauseKey @param {'start'|'end'} pWhich @param {string} pValue
707
+ */
708
+ applyQuickFilterDate(pRecordSet, pViewContext, pField, pClauseKey, pWhich, pValue)
709
+ {
710
+ this.bumpRenderEpoch();
711
+ const tmpProvider = this.pict.providers['RSP-Provider-' + pRecordSet];
712
+ if (tmpProvider && typeof tmpProvider.upsertQuickFilterDateRange === 'function')
713
+ {
714
+ tmpProvider.upsertQuickFilterDateRange(pField, pClauseKey, pWhich, (pValue === undefined || pValue === null) ? '' : pValue);
715
+ }
716
+ this.handleSearch(null, pRecordSet, pViewContext);
717
+ }
718
+
719
+ /**
720
+ * Apply an entity quick filter: set the field's entity clause to the picked value (removed when
721
+ * cleared), then run the search. Called from the quick-bar picker's OnChange.
722
+ *
723
+ * @param {string} pRecordSet @param {string} pViewContext @param {string} pField @param {string} pClauseKey @param {any} pValue
724
+ */
725
+ applyQuickFilterEntity(pRecordSet, pViewContext, pField, pClauseKey, pValue)
726
+ {
727
+ this.bumpRenderEpoch();
728
+ const tmpProvider = this.pict.providers['RSP-Provider-' + pRecordSet];
729
+ if (tmpProvider && typeof tmpProvider.upsertQuickFilterEntity === 'function')
730
+ {
731
+ tmpProvider.upsertQuickFilterEntity(pField, pClauseKey, pValue);
732
+ }
733
+ this.handleSearch(null, pRecordSet, pViewContext);
734
+ }
735
+
530
736
  /**
531
737
  * @param {Event} pEvent - The DOM event that triggered the search
532
738
  * @param {string} pRecordSet - The record set being filtered
@@ -921,6 +1127,7 @@ class ViewRecordSetSUBSETFilters extends libPictView
921
1127
  if (tmpFilterRecordSet)
922
1128
  {
923
1129
  this._paintFilterControls(tmpFilterRecordSet);
1130
+ this._renderQuickFilters(tmpFilterRecordSet, tmpFilterViewContext);
924
1131
  // (Re)render the experiences dropdown only when its container is empty — i.e. on
925
1132
  // a fresh filter render — not on every sub-render (add-filter dropdown, etc.).
926
1133
  const tmpExpContainer = document.getElementById('FilterPersistenceView-Container');