pict-section-recordset 1.9.6 → 1.11.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.
Files changed (40) hide show
  1. package/README.md +38 -0
  2. package/package.json +2 -2
  3. package/source/Pict-Section-RecordSet.js +1 -0
  4. package/source/providers/Column-Data-Provider.js +219 -0
  5. package/source/providers/RecordSet-RecordProvider-Base.js +64 -1
  6. package/source/providers/RecordSet-RecordProvider-MeadowEndpoints.js +92 -16
  7. package/source/services/RecordsSet-MetaController.js +23 -1
  8. package/source/templates/Pict-Template-FilterInstanceViews.js +4 -0
  9. package/source/views/RecordSet-Filters.js +140 -3
  10. package/source/views/filters/RecordSet-Filter-DistinctSelectedValueList.js +233 -0
  11. package/source/views/filters/index.js +2 -0
  12. package/source/views/list/RecordSet-List-ColumnChooser.js +345 -0
  13. package/source/views/list/RecordSet-List-RecordListEntry.js +4 -1
  14. package/source/views/list/RecordSet-List.js +390 -15
  15. package/source/views/read/RecordSet-Read.js +65 -6
  16. package/types/Pict-Section-RecordSet.d.ts +1 -0
  17. package/types/providers/Column-Data-Provider.d.ts +115 -0
  18. package/types/providers/Column-Data-Provider.d.ts.map +1 -0
  19. package/types/providers/RecordSet-DynamicRecordsetSolver.d.ts +3 -0
  20. package/types/providers/RecordSet-DynamicRecordsetSolver.d.ts.map +1 -1
  21. package/types/providers/RecordSet-RecordProvider-Base.d.ts +110 -0
  22. package/types/providers/RecordSet-RecordProvider-Base.d.ts.map +1 -1
  23. package/types/providers/RecordSet-RecordProvider-MeadowEndpoints.d.ts +51 -1
  24. package/types/providers/RecordSet-RecordProvider-MeadowEndpoints.d.ts.map +1 -1
  25. package/types/providers/RecordSet-Router.d.ts +1 -0
  26. package/types/providers/RecordSet-Router.d.ts.map +1 -1
  27. package/types/services/RecordsSet-MetaController.d.ts.map +1 -1
  28. package/types/templates/Pict-Template-FilterInstanceViews.d.ts.map +1 -1
  29. package/types/views/RecordSet-Filters.d.ts +61 -0
  30. package/types/views/RecordSet-Filters.d.ts.map +1 -1
  31. package/types/views/RecordSet-RecordBaseView.d.ts +1 -0
  32. package/types/views/RecordSet-RecordBaseView.d.ts.map +1 -1
  33. package/types/views/filters/RecordSet-Filter-EntityReference-Base.d.ts.map +1 -1
  34. package/types/views/list/RecordSet-List-ColumnChooser.d.ts +68 -0
  35. package/types/views/list/RecordSet-List-ColumnChooser.d.ts.map +1 -0
  36. package/types/views/list/RecordSet-List-RecordListEntry.d.ts.map +1 -1
  37. package/types/views/list/RecordSet-List.d.ts +167 -2
  38. package/types/views/list/RecordSet-List.d.ts.map +1 -1
  39. package/types/views/read/RecordSet-Read.d.ts +8 -0
  40. package/types/views/read/RecordSet-Read.d.ts.map +1 -1
package/README.md CHANGED
@@ -43,6 +43,44 @@ The control type is inferred from the field's clause (text for a string match, a
43
43
 
44
44
  **Turning it off.** A single record set opts out with `QuickFilters: false`. To make quick filters **opt-in** across a whole app — only record sets with an explicit `QuickFilters` array show the bar — set the filter view's flag once: `pict.views['PRSP-Filters'].quickFiltersAutoDefault = false`.
45
45
 
46
+ ## Column Chooser
47
+
48
+ An opt-in, per-record-set "Columns" button above the list table that lets the user show/hide columns. Three tiers of candidates:
49
+
50
+ - **Curated** — the columns the host declared (manifest `Descriptors` or `RecordSetListColumns`), in declared order. Default visible; a column or descriptor can ship hidden-by-default with `DefaultHidden: true` (proper display name + cell template, one click away).
51
+ - **Schema** — every remaining scalar column on the entity (identity/audit fields and blob `Text`/`JSON` columns excluded), listed under "More columns", default hidden, rendered with the generic `{~ProcessCell~}` template (entity-reference `ID*` columns resolve names automatically).
52
+ - **Audit** — the identity pair and audit stamps with friendly labels (ID, GUID, Created, Created by, Updated, Updated by, Deleted, Deleted on, Deleted by), listed under "Audit columns", default hidden. The user-reference stamps resolve to user names like any entity column.
53
+
54
+ ```javascript
55
+ {
56
+ "RecordSet": "Book",
57
+ "RecordSetListColumnChooser": true,
58
+ "RecordSetListColumns":
59
+ [
60
+ { "Key": "Title" },
61
+ { "Key": "Genre" },
62
+ { "Key": "ISBN", "DefaultHidden": true }
63
+ ]
64
+ }
65
+ ```
66
+
67
+ Toggling a column repaints only the rows + pagination (the filter bar and its state are never re-rendered). Under `RecordSetListLiteFetch`, showing a schema-tier column whose data wasn't fetched triggers exactly one refetch with the projection widened to include it.
68
+
69
+ **Persistence.** Choices persist per browser in localStorage (`Column_Meta_{RecordSet}_List`), with a session mirror at `pict.Bundle._ActiveColumnState`. To persist somewhere else (e.g. a per-user server preference), register your own provider as `ColumnDataProvider` **before** `PictSectionRecordSet.initialize()` — the section only registers the built-in one when none exists. The class is exported as `PictSectionRecordSet.ColumnDataProvider` for subclassing.
70
+
71
+ **Host hook note.** `onBeforeRenderList` is (re-)invoked on every paint, including column-visibility repaints. `TableCells` is rebuilt from pristine candidates each paint so cell-level mutations apply exactly once — but record decoration done in the hook must be idempotent. Cells the hook appends are unmanaged by the chooser: they always render and never appear in the chooser list.
72
+
73
+ ## Show Deleted
74
+
75
+ Meadow soft-deletes: lists normally return only `Deleted = 0` rows. Set `RecordSetListShowDeletedFilter: true` on a record set to add a **"Show deleted"** checkbox to the filter drawer's footer (next to Clear / Reset / Apply). The switch is a **real clause** — a `RawFilter` referencing the Deleted column (`FBL~Deleted~INN~0,1`), which takes over FoxHound's automatic delete tracking so active and deleted rows enumerate together. Because it's a clause:
76
+
77
+ - It **serializes into the route URL** (the FilterExperience segment) — reloads and shared links keep it.
78
+ - Toggling applies through the normal search flow (the URL always changes, so the fetch always fires).
79
+ - **Clear** drops it like any other clause; the drawer's clause list skips it (the checkbox is its face).
80
+ - Records and counts stay in sync (both flow through the same clause assembly).
81
+
82
+ Deleted rows render at reduced opacity (`prsp-row-deleted`), and their default **View** link routes to `/PSRS/:RecordSet/ViewDeleted/:GUID` — a read route whose lookup explicitly includes soft-deleted records (a plain View would find nothing) and which renders a "This record has been deleted" banner above the record. Pair with the audit tier's *Deleted / Deleted on / Deleted by* columns in the column chooser.
83
+
46
84
  ## Related Packages
47
85
 
48
86
  - [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.9.6",
3
+ "version": "1.11.0",
4
4
  "description": "Pict dynamic record set management views",
5
5
  "main": "source/Pict-Section-RecordSet.js",
6
6
  "files": [
@@ -37,7 +37,7 @@
37
37
  "browser-env": "^3.3.0",
38
38
  "eslint": "^9.28.0",
39
39
  "jquery": "^3.7.1",
40
- "pict": "^1.0.374",
40
+ "pict": "^1.0.384",
41
41
  "pict-application": "^1.0.34",
42
42
  "pict-docuserve": "^1.4.19",
43
43
  "pict-service-commandlineutility": "^1.0.19",
@@ -10,3 +10,4 @@ module.exports.PictRecordSetApplication = require('./application/Pict-Applicatio
10
10
  // Export the providers
11
11
  module.exports.RecordSetProviderBase = require('./providers/RecordSet-RecordProvider-Base.js');
12
12
  module.exports.RecordSetProviderMeadowEndpoints = require('./providers/RecordSet-RecordProvider-MeadowEndpoints.js');
13
+ module.exports.ColumnDataProvider = require('./providers/Column-Data-Provider.js');
@@ -0,0 +1,219 @@
1
+ const libPictProvider = require('pict-provider');
2
+
3
+ const _DEFAULT_PROVIDER_CONFIGURATION =
4
+ {
5
+ ProviderIdentifier: 'ColumnDataProvider',
6
+ AutoInitialize: true,
7
+ AutoInitializeOrdinal: 0,
8
+ };
9
+
10
+ /** Terminology for Column Data Provider (to avoid confusion):
11
+ * A "Record Set" is a collection of records rendered as a list with columns.
12
+ * A "Column Visibility Override" is a per-column user choice (true = show, false = hide)
13
+ * that overrides the column's default visibility (visible unless DefaultHidden).
14
+ * Columns with no override entry render at their default visibility.
15
+
16
+ * Behavior Summary:
17
+ * - Save the per-recordset override map to LocalStorage under a key derived from
18
+ * Record Set and View Context.
19
+ * - Mirror the override map into pict.Bundle._ActiveColumnState[RecordSet] so reads
20
+ * are synchronous and consistent within a session (the Meadow record provider reads
21
+ * this at fetch time to widen Lite projections before the list view composes columns).
22
+ * - Clear both on "Reset to defaults".
23
+
24
+ * Storage Key Structure:
25
+ * - Column_Meta_{RecordSet}_{ViewContext} : stores the Column Meta JSON.
26
+
27
+ * Object Shape for Column Meta:
28
+ * {
29
+ * RecordSet: string, (auto-filled on save)
30
+ * ViewContext: string, (auto-filled on save; 'List' for the list view)
31
+ * Overrides: { [ColumnKey: string]: boolean },
32
+ * LastModifiedDate: string (ISO date) (auto-filled on save)
33
+ * }
34
+
35
+ * Host override contract:
36
+ * - To persist column choices somewhere other than LocalStorage (e.g. a per-user
37
+ * server-side preference), register your own provider AS 'ColumnDataProvider'
38
+ * BEFORE PictSectionRecordSet.initialize() runs — the section only registers this
39
+ * one when no provider with that hash exists yet. Load remote prefs at app start
40
+ * and seed them through setColumnVisibilityOverride (or write the Bundle mirror
41
+ * directly); the read methods here must stay synchronous.
42
+ */
43
+
44
+ class ColumnDataProvider extends libPictProvider
45
+ {
46
+ /**
47
+ * @param {import('pict')} pFable - The Fable instance
48
+ * @param {Record<string, any>} [pOptions] - The options for the provider
49
+ * @param {string} [pServiceHash] - The service hash for the provider
50
+ */
51
+ constructor(pFable, pOptions, pServiceHash)
52
+ {
53
+ let tmpOptions = Object.assign({}, _DEFAULT_PROVIDER_CONFIGURATION, pOptions);
54
+ super(pFable, tmpOptions, pServiceHash);
55
+
56
+ // This allows unit tests to run
57
+ this.storageProvider = this;
58
+ this.keyCache = {};
59
+ if ((typeof(window) === 'object') && (typeof(window.localStorage) === 'object'))
60
+ {
61
+ this.storageProvider = window.localStorage;
62
+ }
63
+ }
64
+
65
+ /**
66
+ * Resolve the LocalStorage key for a record set's column visibility overrides.
67
+ *
68
+ * @param {string} pRecordSet - The record set the overrides belong to
69
+ * @param {string} [pViewContext] - The view context (defaults to 'List')
70
+ * @return {string} The storage key for the column meta record.
71
+ */
72
+ getColumnStorageKey(pRecordSet, pViewContext)
73
+ {
74
+ return `Column_Meta_${pRecordSet}_${pViewContext || 'List'}`;
75
+ }
76
+
77
+ /**
78
+ * Get the column visibility override map for a record set.
79
+ *
80
+ * Bundle-first: the session mirror in pict.Bundle._ActiveColumnState wins; on a
81
+ * miss the stored meta is read and seeded into the Bundle so every later read
82
+ * (including the Meadow provider's fetch-time read) is synchronous and consistent.
83
+ *
84
+ * @param {string} pRecordSet - The record set to get overrides for
85
+ * @param {string} [pViewContext] - The view context (defaults to 'List')
86
+ * @return {Record<string, boolean>} The override map (empty object when none).
87
+ */
88
+ getColumnVisibilityOverrides(pRecordSet, pViewContext)
89
+ {
90
+ if (!pRecordSet)
91
+ {
92
+ return {};
93
+ }
94
+ const tmpActiveColumnState = this.pict.Bundle._ActiveColumnState;
95
+ if (tmpActiveColumnState && tmpActiveColumnState[pRecordSet] && tmpActiveColumnState[pRecordSet].Overrides)
96
+ {
97
+ return tmpActiveColumnState[pRecordSet].Overrides;
98
+ }
99
+ /** @type {Record<string, boolean>} */
100
+ let tmpOverrides = {};
101
+ const tmpColumnMetaJSON = this.storageProvider.getItem(this.getColumnStorageKey(pRecordSet, pViewContext));
102
+ if (tmpColumnMetaJSON)
103
+ {
104
+ try
105
+ {
106
+ const tmpColumnMeta = JSON.parse(tmpColumnMetaJSON);
107
+ if (tmpColumnMeta && typeof(tmpColumnMeta.Overrides) === 'object' && tmpColumnMeta.Overrides !== null)
108
+ {
109
+ tmpOverrides = tmpColumnMeta.Overrides;
110
+ }
111
+ }
112
+ catch (pError)
113
+ {
114
+ this.pict.log.warn(`ColumnDataProvider: could not parse stored column meta for [${pRecordSet}]: ${pError.message}`);
115
+ }
116
+ }
117
+ this._seedBundleColumnState(pRecordSet, tmpOverrides);
118
+ return tmpOverrides;
119
+ }
120
+
121
+ /**
122
+ * Set (and persist) a single column visibility override for a record set.
123
+ *
124
+ * @param {string} pRecordSet - The record set the column belongs to
125
+ * @param {string} pViewContext - The view context ('List' for the list view; falsy defaults to 'List')
126
+ * @param {string} pKey - The column key
127
+ * @param {boolean} pVisible - Whether the column should be visible
128
+ * @return {Record<string, boolean>} The updated override map.
129
+ */
130
+ setColumnVisibilityOverride(pRecordSet, pViewContext, pKey, pVisible)
131
+ {
132
+ const tmpOverrides = this.getColumnVisibilityOverrides(pRecordSet, pViewContext);
133
+ tmpOverrides[pKey] = (pVisible === true);
134
+ this._seedBundleColumnState(pRecordSet, tmpOverrides);
135
+ const tmpColumnMeta =
136
+ {
137
+ RecordSet: pRecordSet,
138
+ ViewContext: pViewContext || 'List',
139
+ Overrides: tmpOverrides,
140
+ LastModifiedDate: new Date().toISOString(),
141
+ };
142
+ this.storageProvider.setItem(this.getColumnStorageKey(pRecordSet, pViewContext), JSON.stringify(tmpColumnMeta));
143
+ return tmpOverrides;
144
+ }
145
+
146
+ /**
147
+ * Clear all column visibility overrides for a record set (Reset to defaults).
148
+ *
149
+ * @param {string} pRecordSet - The record set to clear overrides for
150
+ * @param {string} [pViewContext] - The view context (defaults to 'List')
151
+ * @return {boolean} True when the overrides have been cleared.
152
+ */
153
+ clearColumnVisibilityOverrides(pRecordSet, pViewContext)
154
+ {
155
+ this.storageProvider.removeItem(this.getColumnStorageKey(pRecordSet, pViewContext));
156
+ if (this.pict.Bundle._ActiveColumnState && this.pict.Bundle._ActiveColumnState[pRecordSet])
157
+ {
158
+ delete this.pict.Bundle._ActiveColumnState[pRecordSet];
159
+ }
160
+ return true;
161
+ }
162
+
163
+ /**
164
+ * Write the session mirror of a record set's override map into the Bundle.
165
+ *
166
+ * @param {string} pRecordSet - The record set the overrides belong to
167
+ * @param {Record<string, boolean>} pOverrides - The override map to mirror
168
+ */
169
+ _seedBundleColumnState(pRecordSet, pOverrides)
170
+ {
171
+ if (!this.pict.Bundle._ActiveColumnState)
172
+ {
173
+ this.pict.Bundle._ActiveColumnState = {};
174
+ }
175
+ this.pict.Bundle._ActiveColumnState[pRecordSet] = { Overrides: pOverrides };
176
+ }
177
+
178
+ /** ===== SIMPLE KEY-VALUE CACHE ============= */
179
+
180
+ /**
181
+ * @param {string} pKey - The key to get from the cache
182
+ * @return {any} - The value associated with the key, or null if not found
183
+ */
184
+ getItem(pKey)
185
+ {
186
+ if (pKey in this.keyCache)
187
+ {
188
+ return this.keyCache[pKey];
189
+ }
190
+ return null;
191
+ }
192
+
193
+ /**
194
+ * @param {string} pKey - The key to set in the cache
195
+ * @param {any} pValue - The value to associate with the key
196
+ */
197
+ setItem(pKey, pValue)
198
+ {
199
+ this.keyCache[pKey] = pValue;
200
+ return true;
201
+ }
202
+
203
+ /**
204
+ * @param {string} pKey - The key to remove from the cache
205
+ * @return {boolean} - True if the item was removed, false if it was not found
206
+ */
207
+ removeItem(pKey)
208
+ {
209
+ if (pKey in this.keyCache)
210
+ {
211
+ delete this.keyCache[pKey];
212
+ return true;
213
+ }
214
+ return false;
215
+ }
216
+ }
217
+
218
+ module.exports = ColumnDataProvider;
219
+ module.exports.default_configuration = _DEFAULT_PROVIDER_CONFIGURATION;
@@ -316,6 +316,12 @@ class RecordSetProviderBase extends libPictProvider
316
316
  tmpClause = JSON.parse(JSON.stringify(tmpClause));
317
317
  tmpClause.Hash = `${pFilterKey}-${pClauseKey}-${this.pict.getUUID()}`;
318
318
  tmpClause.Label = tmpClause.Label || tmpClause.DisplayName;
319
+ // Stamp the owning recordset so per-clause re-renders (which rebuild the render record
320
+ // from the live clause alone) can still resolve their provider and remove control.
321
+ if (!tmpClause.RecordSet && this.options.RecordSet)
322
+ {
323
+ tmpClause.RecordSet = this.options.RecordSet;
324
+ }
319
325
  const tmpClauses = this.getFilterClauses();
320
326
  tmpClauses.push(tmpClause);
321
327
  }
@@ -467,7 +473,8 @@ class RecordSetProviderBase extends libPictProvider
467
473
  */
468
474
  _pickQuickClause(pAvailableClauses)
469
475
  {
470
- return pAvailableClauses.find((pClause) => pClause.Type === 'InternalJoinSelectedValue' || pClause.Type === 'InternalJoinSelectedValueList')
476
+ return pAvailableClauses.find((pClause) => pClause.Type === 'DistinctSelectedValueList')
477
+ || pAvailableClauses.find((pClause) => pClause.Type === 'InternalJoinSelectedValue' || pClause.Type === 'InternalJoinSelectedValueList')
471
478
  || pAvailableClauses.find((pClause) => pClause.Type === 'StringMatch' && pClause.ExactMatch === false)
472
479
  || pAvailableClauses.find((pClause) => pClause.Type === 'DateRange')
473
480
  || pAvailableClauses.find((pClause) => pClause.Type === 'NumericRange')
@@ -484,12 +491,64 @@ class RecordSetProviderBase extends libPictProvider
484
491
  {
485
492
  case 'StringMatch': return 'text';
486
493
  case 'DateRange': return 'daterange';
494
+ case 'DistinctSelectedValueList': return 'distinct';
487
495
  case 'InternalJoinSelectedValue':
488
496
  case 'InternalJoinSelectedValueList': return 'entity';
489
497
  default: return null;
490
498
  }
491
499
  }
492
500
 
501
+ /**
502
+ * The entity's soft-delete column name. Subclasses with schema access override this
503
+ * (the Meadow provider resolves the column whose Type is 'Deleted').
504
+ * @return {string}
505
+ */
506
+ getDeletedField()
507
+ {
508
+ return 'Deleted';
509
+ }
510
+
511
+ /** @return {boolean} Whether the show-deleted clause is currently active for this record set. */
512
+ getShowDeletedFilterValue()
513
+ {
514
+ return this.getFilterClauses().some((pClause) => pClause.ShowDeletedKey === 'ShowDeleted');
515
+ }
516
+
517
+ /**
518
+ * Toggle the show-deleted clause: a RawFilter stanza that references the Deleted column
519
+ * explicitly, which suppresses the automatic `Deleted = 0` delete tracking so soft-deleted
520
+ * rows enumerate. It is a real clause in the active filter state, so it serializes into the
521
+ * filter experience (and the route URL) and clears with Clear like any other clause. The
522
+ * clause list UI skips it (no filter view for RawFilter) — the drawer checkbox is its face.
523
+ *
524
+ * @param {boolean} pOn - Whether deleted records should be included.
525
+ * @return {boolean} The resulting state.
526
+ */
527
+ setShowDeletedFilterValue(pOn)
528
+ {
529
+ const tmpClauses = this.getFilterClauses();
530
+ const tmpIndex = tmpClauses.findIndex((pClause) => pClause.ShowDeletedKey === 'ShowDeleted');
531
+ if (pOn === true)
532
+ {
533
+ if (tmpIndex < 0)
534
+ {
535
+ tmpClauses.push(
536
+ {
537
+ Type: 'RawFilter',
538
+ ShowDeletedKey: 'ShowDeleted',
539
+ Label: 'Show deleted',
540
+ Value: `FBL~${this.getDeletedField()}~INN~0,1`,
541
+ });
542
+ }
543
+ return true;
544
+ }
545
+ if (tmpIndex >= 0)
546
+ {
547
+ tmpClauses.splice(tmpIndex, 1);
548
+ }
549
+ return false;
550
+ }
551
+
493
552
  /** @param {string} pField @return {any} The current value of a field's quick-filter clause, or ''. */
494
553
  getQuickFilterClauseValue(pField)
495
554
  {
@@ -617,6 +676,10 @@ class RecordSetProviderBase extends libPictProvider
617
676
  tmpClause.Label = tmpClause.Label || tmpClause.DisplayName;
618
677
  tmpClause.QuickFilter = true;
619
678
  tmpClause.QuickFilterKey = pQuickFilterKey;
679
+ if (!tmpClause.RecordSet && this.options.RecordSet)
680
+ {
681
+ tmpClause.RecordSet = this.options.RecordSet;
682
+ }
620
683
  return tmpClause;
621
684
  }
622
685
 
@@ -68,35 +68,50 @@ class MeadowEndpointsRecordSetProvider extends libRecordSetProviderBase
68
68
 
69
69
  /**
70
70
  * Fetch (and cache) the DISTINCT values of a column present in this recordset's data, via
71
- * Meadow's `<Entity>s/Distinct/<Column>` endpoint. Drives the `ScopeToRecordSet` filter knob:
72
- * an entity picker can be limited to `FBL~<Column>~INN~<these values>` so it only lists the
73
- * entities the data actually references, not the whole remote table. Cached per column.
71
+ * Meadow's `<Entity>s/Distinct/<Column>` endpoint. Drives the `ScopeToRecordSet` filter knob
72
+ * (an entity picker limited to `FBL~<Column>~INN~<these values>`) and the
73
+ * `DistinctSelectedValueList` filter type (a dropdown whose options ARE these values).
74
+ * Cached per column; the cache is cleared on create/update/delete through this provider.
74
75
  *
75
- * @param {string} pColumn @param {(pError: Error|null, pValues: Array<any>) => void} fCallback
76
+ * @param {string} pColumn
77
+ * @param {{ Filter?: string } | ((pError: Error|null, pValues: Array<any>) => void)} [pOptions] - Optional; `Filter` is a FoxHound stanza appended as `/FilteredTo/<Filter>` on the distinct query.
78
+ * @param {(pError: Error|null, pValues: Array<any>) => void} [fCallback]
76
79
  */
77
- getRecordSetColumnDistinct(pColumn, fCallback)
80
+ getRecordSetColumnDistinct(pColumn, pOptions, fCallback)
78
81
  {
82
+ // Back-compat: (pColumn, fCallback)
83
+ let tmpOptions = pOptions;
84
+ let tmpCallback = fCallback;
85
+ if (typeof tmpOptions === 'function')
86
+ {
87
+ tmpCallback = tmpOptions;
88
+ tmpOptions = {};
89
+ }
90
+ tmpOptions = tmpOptions || {};
91
+ // Unfiltered fetches keep the bare-column key (synchronous peeks elsewhere read it);
92
+ // filtered fetches get their own key so the two never cross-pollinate.
93
+ const tmpCacheKey = tmpOptions.Filter ? `${pColumn}::${tmpOptions.Filter}` : pColumn;
79
94
  this._scopeDistinctCache = this._scopeDistinctCache || {};
80
- if (Array.isArray(this._scopeDistinctCache[pColumn]))
95
+ if (Array.isArray(this._scopeDistinctCache[tmpCacheKey]))
81
96
  {
82
- return fCallback(null, this._scopeDistinctCache[pColumn]);
97
+ return tmpCallback(null, this._scopeDistinctCache[tmpCacheKey]);
83
98
  }
84
99
  if (!this.options.Entity || !this.entityProvider || !this.entityProvider.restClient)
85
100
  {
86
- return fCallback(new Error('RecordSet provider cannot resolve a distinct request (missing Entity or rest client).'), []);
101
+ return tmpCallback(new Error('RecordSet provider cannot resolve a distinct request (missing Entity or rest client).'), []);
87
102
  }
88
- const tmpURL = `${this.options.URLPrefix || ''}${this.options.Entity}s/Distinct/${pColumn}`;
103
+ const tmpURL = `${this.options.URLPrefix || ''}${this.options.Entity}s/Distinct/${pColumn}${tmpOptions.Filter ? `/FilteredTo/${tmpOptions.Filter}` : ''}`;
89
104
  this.entityProvider.restClient.getJSON(tmpURL, (pError, pResponse, pBody) =>
90
105
  {
91
106
  if (pError || (pResponse && pResponse.statusCode > 299) || !Array.isArray(pBody))
92
107
  {
93
108
  this.pict.log.warn(`RecordSet [${this.options.RecordSet || this.options.Entity}] distinct fetch for [${pColumn}] failed; the scoped filter falls back to unscoped.`, { Error: pError && pError.message, URL: tmpURL });
94
- this._scopeDistinctCache[pColumn] = [];
95
- return fCallback(pError || new Error('distinct fetch returned a non-array'), []);
109
+ this._scopeDistinctCache[tmpCacheKey] = [];
110
+ return tmpCallback(pError || new Error('distinct fetch returned a non-array'), []);
96
111
  }
97
112
  const tmpValues = [ ...new Set(pBody.map((pRecord) => pRecord && pRecord[pColumn]).filter((pValue) => pValue != null)) ];
98
- this._scopeDistinctCache[pColumn] = tmpValues;
99
- return fCallback(null, tmpValues);
113
+ this._scopeDistinctCache[tmpCacheKey] = tmpValues;
114
+ return tmpCallback(null, tmpValues);
100
115
  });
101
116
  }
102
117
 
@@ -161,7 +176,7 @@ class MeadowEndpointsRecordSetProvider extends libRecordSetProviderBase
161
176
  }
162
177
 
163
178
  getIDField()
164
- {
179
+ {
165
180
  if (this._Schema?.MeadowSchema?.Schema?.length)
166
181
  {
167
182
  for (let field of this._Schema.MeadowSchema.Schema)
@@ -175,12 +190,29 @@ class MeadowEndpointsRecordSetProvider extends libRecordSetProviderBase
175
190
  return `ID${ this.options.Entity }`;
176
191
  }
177
192
 
193
+ getDeletedField()
194
+ {
195
+ if (this._Schema?.MeadowSchema?.Schema?.length)
196
+ {
197
+ for (let field of this._Schema.MeadowSchema.Schema)
198
+ {
199
+ if (field.Type == 'Deleted')
200
+ {
201
+ return field.Column;
202
+ }
203
+ }
204
+ }
205
+ return 'Deleted';
206
+ }
207
+
178
208
  /**
179
209
  * Get a record by its ID or GUID.
180
210
  *
181
211
  * @param {string|number} pGuid - The ID or GUID of the record.
212
+ * @param {boolean} [pIncludeDeleted] - When true, also match soft-deleted records (the explicit
213
+ * Deleted filter suppresses the automatic `Deleted = 0`).
182
214
  */
183
- async getRecordByGUID(pGuid)
215
+ async getRecordByGUID(pGuid, pIncludeDeleted)
184
216
  {
185
217
  if (!this.options.Entity)
186
218
  {
@@ -190,9 +222,14 @@ class MeadowEndpointsRecordSetProvider extends libRecordSetProviderBase
190
222
  {
191
223
  this.pict.log.info(`Reading ${this.options.Entity} record by GUID`, { GUID: pGuid });
192
224
  }
225
+ let tmpFilterString = `FBV~${ this.getGUIDField() }~EQ~${encodeURIComponent(pGuid)}`;
226
+ if (pIncludeDeleted === true)
227
+ {
228
+ tmpFilterString += `~FBL~${ this.getDeletedField() }~INN~0,1`;
229
+ }
193
230
  return new Promise((resolve, reject) =>
194
231
  {
195
- this.entityProvider.getEntitySet(this.options.Entity, `FBV~${ this.getGUIDField() }~EQ~${encodeURIComponent(pGuid)}`, (pError, pResult) =>
232
+ this.entityProvider.getEntitySet(this.options.Entity, tmpFilterString, (pError, pResult) =>
196
233
  {
197
234
  if (pError)
198
235
  {
@@ -223,6 +260,9 @@ class MeadowEndpointsRecordSetProvider extends libRecordSetProviderBase
223
260
  {
224
261
  tmpClauses.push({ Type: 'RawFilter', Value: pOptions.FilterString });
225
262
  }
263
+ // (The show-deleted switch needs no special handling here: it is a real RawFilter clause in
264
+ // the active filter state — see setShowDeletedFilterValue on the base provider — so it rides
265
+ // the FilterClauses concat above into both the records and count fetches.)
226
266
  return [ tmpClauses, tmpExperience ];
227
267
  }
228
268
 
@@ -280,6 +320,33 @@ class MeadowEndpointsRecordSetProvider extends libRecordSetProviderBase
280
320
  tmpColumns.push(tmpKey);
281
321
  }
282
322
  }
323
+ // Column chooser: union in user-shown schema-tier columns so a toggled-on column's data is
324
+ // actually fetched. Same gauntlet as the descriptors; gated on the config flag so stale
325
+ // stored overrides can never widen queries for lists that don't use the chooser.
326
+ if (tmpConfig && tmpConfig.RecordSetListColumnChooser === true && this.pict.providers.ColumnDataProvider)
327
+ {
328
+ const tmpRecordSet = (pOptions && pOptions.RecordSet) || tmpConfig.RecordSet || pEntity;
329
+ const tmpOverrides = this.pict.providers.ColumnDataProvider.getColumnVisibilityOverrides(tmpRecordSet, 'List');
330
+ for (const tmpKey of Object.keys(tmpOverrides))
331
+ {
332
+ if (tmpOverrides[tmpKey] !== true)
333
+ {
334
+ continue;
335
+ }
336
+ if (tmpKey.startsWith('ID') || tmpKey.startsWith('GUID') || tmpKey === 'CreatingIDUser' || tmpKey === 'UpdateDate')
337
+ {
338
+ continue;
339
+ }
340
+ if (!(tmpKey in tmpColumnType) || tmpBlobTypes[tmpColumnType[tmpKey]])
341
+ {
342
+ continue;
343
+ }
344
+ if (!tmpColumns.includes(tmpKey))
345
+ {
346
+ tmpColumns.push(tmpKey);
347
+ }
348
+ }
349
+ }
283
350
  return tmpColumns;
284
351
  }
285
352
 
@@ -495,6 +562,9 @@ class MeadowEndpointsRecordSetProvider extends libRecordSetProviderBase
495
562
  }
496
563
  // A new record changes the total; drop the cached count so the next render re-counts.
497
564
  this._RecordSetCountCache = null;
565
+ // Mutations can introduce/retire column values; drop the distinct cache so
566
+ // ScopeToRecordSet scoping and DistinctSelectedValueList dropdowns refresh.
567
+ this._scopeDistinctCache = null;
498
568
  // Drop this list's scoped cache too, so the next render re-fetches fresh.
499
569
  if (typeof this.pict.EntityProvider.clearScope === 'function')
500
570
  {
@@ -529,6 +599,9 @@ class MeadowEndpointsRecordSetProvider extends libRecordSetProviderBase
529
599
  }
530
600
  // An edit can move a record in or out of the active filter; drop the cached count to be safe.
531
601
  this._RecordSetCountCache = null;
602
+ // Mutations can introduce/retire column values; drop the distinct cache so
603
+ // ScopeToRecordSet scoping and DistinctSelectedValueList dropdowns refresh.
604
+ this._scopeDistinctCache = null;
532
605
  // Drop this list's scoped cache too, so the next render re-fetches fresh.
533
606
  if (typeof this.pict.EntityProvider.clearScope === 'function')
534
607
  {
@@ -563,6 +636,9 @@ class MeadowEndpointsRecordSetProvider extends libRecordSetProviderBase
563
636
  }
564
637
  // A delete changes the total; drop the cached count so the next render re-counts.
565
638
  this._RecordSetCountCache = null;
639
+ // Mutations can introduce/retire column values; drop the distinct cache so
640
+ // ScopeToRecordSet scoping and DistinctSelectedValueList dropdowns refresh.
641
+ this._scopeDistinctCache = null;
566
642
  // Drop this list's scoped cache too, so the next render re-fetches fresh.
567
643
  if (typeof this.pict.EntityProvider.clearScope === 'function')
568
644
  {
@@ -11,6 +11,7 @@ const ProviderBase = require('../providers/RecordSet-RecordProvider-Base.js');
11
11
  const ProviderMeadowEndpoints = require('../providers/RecordSet-RecordProvider-MeadowEndpoints.js');
12
12
 
13
13
  const ProviderLinkManager = require('../providers/RecordSet-Link-Manager.js');
14
+ const libProviderColumnData = require('../providers/Column-Data-Provider.js');
14
15
 
15
16
  const ProviderRouter = require('../providers/RecordSet-Router.js');
16
17
 
@@ -101,9 +102,20 @@ class RecordSetMetacontroller extends libFableServiceProviderBase
101
102
  resolve(pResult);
102
103
  });
103
104
  });
105
+ // A dangling reference (the target row no longer exists — e.g. a system user id that
106
+ // was never seeded) comes back as the endpoint's error body rather than an entity.
107
+ // Render the raw id, unlinked: it carries more information than a blank cell, and
108
+ // matches how a found-but-nameless entity already renders.
109
+ if (!entity || (entity.Error && entity.StatusCode))
110
+ {
111
+ return fCallback(null, value);
112
+ }
104
113
  if (remote === 'User')
105
114
  {
106
- value = `${ entity.NameFirst } ${ entity.NameLast }`;
115
+ // Compose what the user record actually has; fall back through the common
116
+ // name fields rather than printing "undefined undefined".
117
+ const tmpUserName = [ entity.NameFirst, entity.NameLast ].filter((pPart) => (typeof(pPart) === 'string') && pPart.trim().length > 0).join(' ');
118
+ value = tmpUserName || entity.FullName || entity.Name || entity.LoginID || value;
107
119
  }
108
120
  else if (entity?.Name)
109
121
  {
@@ -440,6 +452,13 @@ class RecordSetMetacontroller extends libFableServiceProviderBase
440
452
 
441
453
  this.fable.addProvider('RecordSetLinkManager', {}, ProviderLinkManager);
442
454
 
455
+ // Column visibility persistence — only register the built-in localStorage provider when the
456
+ // host hasn't supplied its own (the documented seam for server-side per-user persistence).
457
+ if (!('ColumnDataProvider' in this.fable.providers))
458
+ {
459
+ this.fable.addProvider('ColumnDataProvider', libProviderColumnData.default_configuration, libProviderColumnData);
460
+ }
461
+
443
462
  // Add the subviews internally and externally
444
463
  this.pict.addTemplate(require('../templates/Pict-Template-FilterView.js'));
445
464
  this.pict.addTemplate(require('../templates/Pict-Template-FilterInstanceViews.js'));
@@ -583,6 +602,9 @@ class RecordSetMetacontroller extends libFableServiceProviderBase
583
602
  DisplayName: descriptor.Name || key,
584
603
  ManifestHash: pManifest.Scope,
585
604
  PictDashboard: tmpPictDashboard,
605
+ // Column-chooser default visibility: a descriptor can ship hidden-by-default and be
606
+ // turned on by the user (when the recordset enables RecordSetListColumnChooser).
607
+ DefaultHidden: (descriptor.DefaultHidden === true),
586
608
  };
587
609
  });
588
610
  pManifest.TableCells = tmpTableCells;
@@ -78,6 +78,10 @@ class PictTemplateFilterInstanceViewInstruction extends libPictTemplate
78
78
  // not the drawer's clause list — skip them here so they aren't rendered twice. Both the sync and
79
79
  // async render loops treat a null view as "skip this clause".
80
80
  if (pClause && pClause.QuickFilter) { return null; }
81
+ // The show-deleted switch is a RawFilter clause whose face is the drawer-footer checkbox
82
+ // (RecordSet-Filters._renderShowDeletedControl) — without this skip it would fall through to
83
+ // the Base filter card, which renders a debug dump for types with no real filter view.
84
+ if (pClause && pClause.ShowDeletedKey) { return null; }
81
85
  let tmpViewHash = `PRSP-FilterType-${pClause.Type}`;
82
86
  const tmpCustomViewHash = `${tmpViewHash}-${pClause.CustomFilterViewHash}`;
83
87
  if (tmpCustomViewHash in this.pict.views)