pict-section-recordset 1.9.6 → 1.10.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 (38) hide show
  1. package/README.md +38 -0
  2. package/package.json +1 -1
  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 +51 -0
  6. package/source/providers/RecordSet-RecordProvider-MeadowEndpoints.js +55 -3
  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 +54 -1
  10. package/source/views/list/RecordSet-List-ColumnChooser.js +345 -0
  11. package/source/views/list/RecordSet-List-RecordListEntry.js +4 -1
  12. package/source/views/list/RecordSet-List.js +390 -15
  13. package/source/views/read/RecordSet-Read.js +65 -6
  14. package/types/Pict-Section-RecordSet.d.ts +1 -0
  15. package/types/providers/Column-Data-Provider.d.ts +115 -0
  16. package/types/providers/Column-Data-Provider.d.ts.map +1 -0
  17. package/types/providers/RecordSet-DynamicRecordsetSolver.d.ts +3 -0
  18. package/types/providers/RecordSet-DynamicRecordsetSolver.d.ts.map +1 -1
  19. package/types/providers/RecordSet-RecordProvider-Base.d.ts +110 -0
  20. package/types/providers/RecordSet-RecordProvider-Base.d.ts.map +1 -1
  21. package/types/providers/RecordSet-RecordProvider-MeadowEndpoints.d.ts +51 -1
  22. package/types/providers/RecordSet-RecordProvider-MeadowEndpoints.d.ts.map +1 -1
  23. package/types/providers/RecordSet-Router.d.ts +1 -0
  24. package/types/providers/RecordSet-Router.d.ts.map +1 -1
  25. package/types/services/RecordsSet-MetaController.d.ts.map +1 -1
  26. package/types/templates/Pict-Template-FilterInstanceViews.d.ts.map +1 -1
  27. package/types/views/RecordSet-Filters.d.ts +61 -0
  28. package/types/views/RecordSet-Filters.d.ts.map +1 -1
  29. package/types/views/RecordSet-RecordBaseView.d.ts +1 -0
  30. package/types/views/RecordSet-RecordBaseView.d.ts.map +1 -1
  31. package/types/views/filters/RecordSet-Filter-EntityReference-Base.d.ts.map +1 -1
  32. package/types/views/list/RecordSet-List-ColumnChooser.d.ts +68 -0
  33. package/types/views/list/RecordSet-List-ColumnChooser.d.ts.map +1 -0
  34. package/types/views/list/RecordSet-List-RecordListEntry.d.ts.map +1 -1
  35. package/types/views/list/RecordSet-List.d.ts +167 -2
  36. package/types/views/list/RecordSet-List.d.ts.map +1 -1
  37. package/types/views/read/RecordSet-Read.d.ts +8 -0
  38. 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.10.0",
4
4
  "description": "Pict dynamic record set management views",
5
5
  "main": "source/Pict-Section-RecordSet.js",
6
6
  "files": [
@@ -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;
@@ -490,6 +490,57 @@ class RecordSetProviderBase extends libPictProvider
490
490
  }
491
491
  }
492
492
 
493
+ /**
494
+ * The entity's soft-delete column name. Subclasses with schema access override this
495
+ * (the Meadow provider resolves the column whose Type is 'Deleted').
496
+ * @return {string}
497
+ */
498
+ getDeletedField()
499
+ {
500
+ return 'Deleted';
501
+ }
502
+
503
+ /** @return {boolean} Whether the show-deleted clause is currently active for this record set. */
504
+ getShowDeletedFilterValue()
505
+ {
506
+ return this.getFilterClauses().some((pClause) => pClause.ShowDeletedKey === 'ShowDeleted');
507
+ }
508
+
509
+ /**
510
+ * Toggle the show-deleted clause: a RawFilter stanza that references the Deleted column
511
+ * explicitly, which suppresses the automatic `Deleted = 0` delete tracking so soft-deleted
512
+ * rows enumerate. It is a real clause in the active filter state, so it serializes into the
513
+ * filter experience (and the route URL) and clears with Clear like any other clause. The
514
+ * clause list UI skips it (no filter view for RawFilter) — the drawer checkbox is its face.
515
+ *
516
+ * @param {boolean} pOn - Whether deleted records should be included.
517
+ * @return {boolean} The resulting state.
518
+ */
519
+ setShowDeletedFilterValue(pOn)
520
+ {
521
+ const tmpClauses = this.getFilterClauses();
522
+ const tmpIndex = tmpClauses.findIndex((pClause) => pClause.ShowDeletedKey === 'ShowDeleted');
523
+ if (pOn === true)
524
+ {
525
+ if (tmpIndex < 0)
526
+ {
527
+ tmpClauses.push(
528
+ {
529
+ Type: 'RawFilter',
530
+ ShowDeletedKey: 'ShowDeleted',
531
+ Label: 'Show deleted',
532
+ Value: `FBL~${this.getDeletedField()}~INN~0,1`,
533
+ });
534
+ }
535
+ return true;
536
+ }
537
+ if (tmpIndex >= 0)
538
+ {
539
+ tmpClauses.splice(tmpIndex, 1);
540
+ }
541
+ return false;
542
+ }
543
+
493
544
  /** @param {string} pField @return {any} The current value of a field's quick-filter clause, or ''. */
494
545
  getQuickFilterClauseValue(pField)
495
546
  {
@@ -161,7 +161,7 @@ class MeadowEndpointsRecordSetProvider extends libRecordSetProviderBase
161
161
  }
162
162
 
163
163
  getIDField()
164
- {
164
+ {
165
165
  if (this._Schema?.MeadowSchema?.Schema?.length)
166
166
  {
167
167
  for (let field of this._Schema.MeadowSchema.Schema)
@@ -175,12 +175,29 @@ class MeadowEndpointsRecordSetProvider extends libRecordSetProviderBase
175
175
  return `ID${ this.options.Entity }`;
176
176
  }
177
177
 
178
+ getDeletedField()
179
+ {
180
+ if (this._Schema?.MeadowSchema?.Schema?.length)
181
+ {
182
+ for (let field of this._Schema.MeadowSchema.Schema)
183
+ {
184
+ if (field.Type == 'Deleted')
185
+ {
186
+ return field.Column;
187
+ }
188
+ }
189
+ }
190
+ return 'Deleted';
191
+ }
192
+
178
193
  /**
179
194
  * Get a record by its ID or GUID.
180
195
  *
181
196
  * @param {string|number} pGuid - The ID or GUID of the record.
197
+ * @param {boolean} [pIncludeDeleted] - When true, also match soft-deleted records (the explicit
198
+ * Deleted filter suppresses the automatic `Deleted = 0`).
182
199
  */
183
- async getRecordByGUID(pGuid)
200
+ async getRecordByGUID(pGuid, pIncludeDeleted)
184
201
  {
185
202
  if (!this.options.Entity)
186
203
  {
@@ -190,9 +207,14 @@ class MeadowEndpointsRecordSetProvider extends libRecordSetProviderBase
190
207
  {
191
208
  this.pict.log.info(`Reading ${this.options.Entity} record by GUID`, { GUID: pGuid });
192
209
  }
210
+ let tmpFilterString = `FBV~${ this.getGUIDField() }~EQ~${encodeURIComponent(pGuid)}`;
211
+ if (pIncludeDeleted === true)
212
+ {
213
+ tmpFilterString += `~FBL~${ this.getDeletedField() }~INN~0,1`;
214
+ }
193
215
  return new Promise((resolve, reject) =>
194
216
  {
195
- this.entityProvider.getEntitySet(this.options.Entity, `FBV~${ this.getGUIDField() }~EQ~${encodeURIComponent(pGuid)}`, (pError, pResult) =>
217
+ this.entityProvider.getEntitySet(this.options.Entity, tmpFilterString, (pError, pResult) =>
196
218
  {
197
219
  if (pError)
198
220
  {
@@ -223,6 +245,9 @@ class MeadowEndpointsRecordSetProvider extends libRecordSetProviderBase
223
245
  {
224
246
  tmpClauses.push({ Type: 'RawFilter', Value: pOptions.FilterString });
225
247
  }
248
+ // (The show-deleted switch needs no special handling here: it is a real RawFilter clause in
249
+ // the active filter state — see setShowDeletedFilterValue on the base provider — so it rides
250
+ // the FilterClauses concat above into both the records and count fetches.)
226
251
  return [ tmpClauses, tmpExperience ];
227
252
  }
228
253
 
@@ -280,6 +305,33 @@ class MeadowEndpointsRecordSetProvider extends libRecordSetProviderBase
280
305
  tmpColumns.push(tmpKey);
281
306
  }
282
307
  }
308
+ // Column chooser: union in user-shown schema-tier columns so a toggled-on column's data is
309
+ // actually fetched. Same gauntlet as the descriptors; gated on the config flag so stale
310
+ // stored overrides can never widen queries for lists that don't use the chooser.
311
+ if (tmpConfig && tmpConfig.RecordSetListColumnChooser === true && this.pict.providers.ColumnDataProvider)
312
+ {
313
+ const tmpRecordSet = (pOptions && pOptions.RecordSet) || tmpConfig.RecordSet || pEntity;
314
+ const tmpOverrides = this.pict.providers.ColumnDataProvider.getColumnVisibilityOverrides(tmpRecordSet, 'List');
315
+ for (const tmpKey of Object.keys(tmpOverrides))
316
+ {
317
+ if (tmpOverrides[tmpKey] !== true)
318
+ {
319
+ continue;
320
+ }
321
+ if (tmpKey.startsWith('ID') || tmpKey.startsWith('GUID') || tmpKey === 'CreatingIDUser' || tmpKey === 'UpdateDate')
322
+ {
323
+ continue;
324
+ }
325
+ if (!(tmpKey in tmpColumnType) || tmpBlobTypes[tmpColumnType[tmpKey]])
326
+ {
327
+ continue;
328
+ }
329
+ if (!tmpColumns.includes(tmpKey))
330
+ {
331
+ tmpColumns.push(tmpKey);
332
+ }
333
+ }
334
+ }
283
335
  return tmpColumns;
284
336
  }
285
337
 
@@ -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)
@@ -88,6 +88,12 @@ const _DEFAULT_CONFIGURATION_SUBSET_Filter =
88
88
  .prsp-quickfilter-dash { color: var(--theme-color-text-muted, #6b7686); }
89
89
  /* Entity quick control: the pict-section-picker mounts into this host (its own .pps chrome themes it). */
90
90
  .prsp-quickfilter-entityhost { display: inline-block; width: 14rem; max-width: 100%; vertical-align: middle; }
91
+ /* Show-deleted switch (RecordSetListShowDeletedFilter): a labeled checkbox in the drawer actions row.
92
+ Painted post-render into the host label; :empty hides it for record sets that haven't opted in. */
93
+ .prsp-filters-showdeleted { display: inline-flex; align-items: center; gap: 0.4rem; cursor: pointer; user-select: none;
94
+ font-size: 0.88rem; color: var(--theme-color-text-secondary, #45505f); margin-right: 0.35rem; }
95
+ .prsp-filters-showdeleted:empty { display: none; }
96
+ .prsp-filters-showdeleted-checkbox { width: 1.05rem; height: 1.05rem; cursor: pointer; accent-color: var(--theme-color-brand-primary, #156dd1); }
91
97
 
92
98
  /* Module-owned "Add filter" popover (replaces the old native <select> pickers). */
93
99
  /* Fixed (viewport-anchored) + JS-positioned on open, so no ancestor overflow:hidden — the filter card,
@@ -196,6 +202,7 @@ const _DEFAULT_CONFIGURATION_SUBSET_Filter =
196
202
  Template: /*html*/`
197
203
  <!-- DefaultPackage pict view template: [PRSP-SUBSET-Filters-Template-DrawerActions-Fieldset] -->
198
204
  <div class="prsp-filters-actions">
205
+ <label class="prsp-filters-showdeleted" id="PRSP_ShowDeleted_Host" title="Include soft-deleted records in the results"></label>
199
206
  <button type="button" class="prsp-filters-btn-text" id="PRSP_Filter_Button_Clear" title="Clear all filters to a blank state" onclick="_Pict.views['PRSP-Filters'].handleClear(event, '{~D:Record.RecordSet~}', '{~D:Record.ViewContext~}')">Clear</button>
200
207
  <button type="button" class="prsp-filters-btn-text" id="PRSP_Filter_Button_Reset" title="Reset all filters to the last saved/defaulted state" onclick="_Pict.views['PRSP-Filters'].handleReset(event, '{~D:Record.RecordSet~}', '{~D:Record.ViewContext~}')">Reset</button>
201
208
  <button type="button" class="prsp-filters-apply" id="PRSP_Filter_Button_ApplyDrawer" onclick="_Pict.views['PRSP-Filters'].handleSearch(event, '{~D:Record.RecordSet~}', '{~D:Record.ViewContext~}')">Apply</button>
@@ -719,6 +726,51 @@ class ViewRecordSetSUBSETFilters extends libPictView
719
726
  *
720
727
  * @param {string} pRecordSet @param {string} pViewContext @param {string} pField @param {string} pClauseKey @param {string} pValue
721
728
  */
729
+ /**
730
+ * Flip the show-deleted switch (the drawer-footer checkbox, RecordSetListShowDeletedFilter
731
+ * recordsets). The switch is a REAL clause — a RawFilter referencing the Deleted column, which
732
+ * suppresses the automatic `Deleted = 0` so soft-deleted rows enumerate — upserted into the
733
+ * active filter state and applied through the normal search flow. Because the clause changes
734
+ * the serialized filter experience, the route URL always changes: the fetch reliably fires,
735
+ * and the state survives reloads and shared links. Clear/Reset drop it like any clause.
736
+ *
737
+ * @param {string} pRecordSet - The record set the toggle belongs to
738
+ * @param {string} pViewContext - The view context (List, Dashboard)
739
+ * @param {boolean} pChecked - Whether deleted records should be included
740
+ */
741
+ toggleShowDeletedFilter(pRecordSet, pViewContext, pChecked)
742
+ {
743
+ const tmpProvider = this.pict.providers['RSP-Provider-' + pRecordSet];
744
+ if (!tmpProvider || typeof tmpProvider.setShowDeletedFilterValue !== 'function')
745
+ {
746
+ return;
747
+ }
748
+ tmpProvider.setShowDeletedFilterValue(pChecked === true);
749
+ this.handleSearch(null, pRecordSet, pViewContext);
750
+ }
751
+
752
+ /**
753
+ * Paint the show-deleted checkbox into its drawer-footer host (next to Clear/Reset/Apply),
754
+ * seeded from the clause's presence. Painted post-render like the quick bar; empty when the
755
+ * record set hasn't opted in via RecordSetListShowDeletedFilter.
756
+ *
757
+ * @param {string} pRecordSet @param {string} pViewContext
758
+ */
759
+ _renderShowDeletedControl(pRecordSet, pViewContext)
760
+ {
761
+ if (!document.getElementById('PRSP_ShowDeleted_Host')) { return; }
762
+ const tmpRecordSetConfiguration = this.pict.PictSectionRecordSet?.recordSetProviderConfigurations?.[pRecordSet] || {};
763
+ if (tmpRecordSetConfiguration.RecordSetListShowDeletedFilter !== true)
764
+ {
765
+ this.pict.ContentAssignment.assignContent('#PRSP_ShowDeleted_Host', '');
766
+ return;
767
+ }
768
+ const tmpProvider = this.pict.providers['RSP-Provider-' + pRecordSet];
769
+ const tmpChecked = (tmpProvider && typeof tmpProvider.getShowDeletedFilterValue === 'function' && tmpProvider.getShowDeletedFilterValue()) ? 'checked' : '';
770
+ this.pict.ContentAssignment.assignContent('#PRSP_ShowDeleted_Host',
771
+ `<input class="prsp-filters-showdeleted-checkbox" type="checkbox" ${tmpChecked} onchange="_Pict.views['PRSP-Filters'].toggleShowDeletedFilter('${pRecordSet}', '${pViewContext}', this.checked)"> Show deleted`);
772
+ }
773
+
722
774
  applyQuickFilterText(pRecordSet, pViewContext, pField, pClauseKey, pValue)
723
775
  {
724
776
  this.bumpRenderEpoch();
@@ -1162,7 +1214,8 @@ class ViewRecordSetSUBSETFilters extends libPictView
1162
1214
  if (tmpFilterRecordSet)
1163
1215
  {
1164
1216
  this._paintFilterControls(tmpFilterRecordSet);
1165
- this._renderQuickFilters(tmpFilterRecordSet, tmpFilterViewContext);
1217
+ this._renderQuickFilters(tmpFilterRecordSet, tmpFilterViewContext);
1218
+ this._renderShowDeletedControl(tmpFilterRecordSet, tmpFilterViewContext);
1166
1219
  // (Re)render the experiences dropdown only when its container is empty — i.e. on
1167
1220
  // a fresh filter render — not on every sub-render (add-filter dropdown, etc.).
1168
1221
  const tmpExpContainer = document.getElementById('FilterPersistenceView-Container');