pict-section-recordset 1.4.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 +30 -0
- package/package.json +1 -1
- package/source/providers/RecordSet-RecordProvider-Base.js +245 -0
- package/source/providers/RecordSet-RecordProvider-MeadowEndpoints.js +7 -1
- package/source/templates/Pict-Template-FilterInstanceViews.js +4 -0
- package/source/views/RecordSet-Filters.js +207 -0
- package/source/views/filters/RecordSet-Filter-EntityReference-Base.js +13 -0
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
|
@@ -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
|
-
|
|
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' && !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');
|
|
@@ -158,6 +158,19 @@ class ViewRecordSetSUBSETFilterEntityReferenceBase extends ViewRecordSetSUBSETFi
|
|
|
158
158
|
|| pRecord.ExternalFilterByColumns || (pRecord.ExternalFilterByColumn ? [ pRecord.ExternalFilterByColumn ] : [ 'Name' ]);
|
|
159
159
|
pRecord.ClauseDescriptor.PictForm.ValueArrayAddress = pRecord.ClauseValuesAddress;
|
|
160
160
|
pRecord.ClauseDescriptor.PictForm.GetContextScopeFilter = () => this.getContextScopeFilter(this.getInformaryScopedValue(pRecord.ClauseAddress) || pRecord);
|
|
161
|
+
// JoinEntity compound display (host opt-in on the clause): show each searched row joined to a
|
|
162
|
+
// parent entity's field — e.g. a LineItem disambiguated by its Project. The picker fetch-then-
|
|
163
|
+
// merges the join (Meadow can't join in one read). Forwarded straight through; no-op when unset.
|
|
164
|
+
if (pRecord.JoinEntity || pRecord.ClauseDescriptor.PictForm.JoinEntity)
|
|
165
|
+
{
|
|
166
|
+
const tmpPF = pRecord.ClauseDescriptor.PictForm;
|
|
167
|
+
tmpPF.JoinEntity = tmpPF.JoinEntity || pRecord.JoinEntity;
|
|
168
|
+
tmpPF.JoinField = tmpPF.JoinField || pRecord.JoinField;
|
|
169
|
+
tmpPF.JoinEntityValueField = tmpPF.JoinEntityValueField || pRecord.JoinEntityValueField;
|
|
170
|
+
tmpPF.JoinEntityDisplayField = tmpPF.JoinEntityDisplayField || pRecord.JoinEntityDisplayField;
|
|
171
|
+
if (tmpPF.JoinEntityFirst === undefined && pRecord.JoinEntityFirst !== undefined) { tmpPF.JoinEntityFirst = pRecord.JoinEntityFirst; }
|
|
172
|
+
if (tmpPF.JoinSeparator === undefined && pRecord.JoinSeparator !== undefined) { tmpPF.JoinSeparator = pRecord.JoinSeparator; }
|
|
173
|
+
}
|
|
161
174
|
// Saved-filter seeding: mirror the live clause's Values array into the csv `.StringArrayValue`
|
|
162
175
|
// the input reads, so a reloaded/persisted filter shows its current selections on render.
|
|
163
176
|
const tmpLiveClause = this.getInformaryScopedValue(pRecord.ClauseAddress);
|