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
@@ -8,6 +8,7 @@ const viewRecordList = require('./RecordSet-List-RecordList.js');
8
8
  const viewRecordListHeader = require('./RecordSet-List-RecordListHeader.js');
9
9
  const viewRecordListEntry = require('./RecordSet-List-RecordListEntry.js');
10
10
  const viewPaginationBottom = require('./RecordSet-List-PaginationBottom.js');
11
+ const viewColumnChooser = require('./RecordSet-List-ColumnChooser.js');
11
12
 
12
13
  /** @type {Record<string, any>} */
13
14
  const _DEFAULT_CONFIGURATION__List = (
@@ -32,10 +33,18 @@ const _DEFAULT_CONFIGURATION__List = (
32
33
  AutoSolveOrdinal: 0,
33
34
 
34
35
  CSS: /*css*/`
35
- .prsp-list-loading { display: flex; align-items: center; justify-content: center; min-height: 240px; width: 100%; }
36
- .prsp-list-loading-inner { display: inline-flex; align-items: center; gap: 0.6em; color: var(--theme-color-text-muted, #64748b); font-size: 1.05rem; }
36
+ .prsp-list-loading { width: 100%; padding: 0.25rem 0 0.5rem; }
37
+ .prsp-list-loading-inner { display: inline-flex; align-items: center; gap: 0.55em; color: var(--theme-color-text-muted, #64748b); font-size: 1rem; padding: 0.5rem 0.25rem 0.7rem; }
37
38
  .prsp-list-spinner { display: inline-flex; animation: prsp-list-spin 0.9s linear infinite; }
38
39
  @keyframes prsp-list-spin { from { transform: rotate(0deg); } to { transform: rotate(360deg); } }
40
+ /* Skeleton ghost rows: a few light, theme-colored bars that fill the loading area so the preserved
41
+ row height doesn't read as a white void. A bottom fade blends the last rows into the page. */
42
+ .prsp-list-skeleton { position: relative; animation: prsp-list-skeleton-pulse 1.8s ease-in-out infinite; }
43
+ @keyframes prsp-list-skeleton-pulse { 0%, 100% { opacity: 1; } 50% { opacity: 0.7; } }
44
+ .prsp-list-skeleton-row { height: 2.6rem; margin: 0 0 0.7rem; border-radius: 8px; background: var(--theme-color-background-tertiary, #eceef2); }
45
+ .prsp-list-skeleton-row:nth-child(4n) { width: 94%; }
46
+ .prsp-list-skeleton-row:nth-child(5n) { width: 97%; }
47
+ .prsp-list-skeleton-fade { position: absolute; left: 0; right: 0; bottom: 0; height: 4.5rem; background: linear-gradient(to bottom, transparent, var(--theme-color-background-primary, #fff)); pointer-events: none; }
39
48
  `,
40
49
  CSSPriority: 500,
41
50
 
@@ -51,6 +60,7 @@ const _DEFAULT_CONFIGURATION__List = (
51
60
  <section id="PRSP_Filters_Container">
52
61
  {~FV:PRSP-Filters:List~}
53
62
  </section>
63
+ <div id="PRSP_ColumnChooser_Container">{~V:PRSP-List-ColumnChooser~}</div>
54
64
  <div id="PRSP_PaginationTop_Container">{~V:PRSP-List-PaginationTop~}</div>
55
65
  <div id="PRSP_RecordList_Container">{~V:PRSP-List-RecordList~}</div>
56
66
  <div id="PRSP_PaginationBottom_Container">{~V:PRSP-List-PaginationBottom~}</div>
@@ -67,13 +77,21 @@ const _DEFAULT_CONFIGURATION__List = (
67
77
  {
68
78
  Hash: 'PRSP-List-LoadingShell',
69
79
  Template: /*html*/`
70
- <section id="PRSP_List_Loading" class="prsp-list-loading">
80
+ <div id="PRSP_List_Loading" class="prsp-list-loading">
71
81
  <div class="prsp-list-loading-inner">
72
82
  <span class="prsp-list-spinner" aria-hidden="true">{~I:Refresh~}</span>
73
83
  <span class="prsp-list-loading-label">Loading…</span>
74
84
  </div>
75
- </section>
85
+ <div class="prsp-list-skeleton" aria-hidden="true">
86
+ {~TS:PRSP-List-Skeleton-Row:Record.SkeletonRows~}
87
+ <div class="prsp-list-skeleton-fade"></div>
88
+ </div>
89
+ </div>
76
90
  `
91
+ },
92
+ {
93
+ Hash: 'PRSP-List-Skeleton-Row',
94
+ Template: /*html*/`<div class="prsp-list-skeleton-row"></div>`
77
95
  }
78
96
  ],
79
97
 
@@ -104,13 +122,20 @@ class viewRecordSetList extends libPictRecordSetRecordView
104
122
  recordList: null,
105
123
  recordListHeader: null,
106
124
  recordListEntry: null,
107
- paginationBottom: null
125
+ paginationBottom: null,
126
+ columnChooser: null
108
127
  };
109
128
 
110
129
  // Identity (`RecordSet::FilterString::FilterExperience`) of the list currently painted into the DOM.
111
130
  // When a route only changes the page (Offset/PageSize) and this still matches, we re-render just the
112
131
  // rows + pagination instead of the whole view — see handleRecordSetListRoute / _paintRecordList.
113
132
  this._renderedListIdentity = null;
133
+
134
+ // The last fully-composed list data (carrying the pristine ColumnCandidates) and the arguments of
135
+ // the last render call — together they let a column-visibility toggle repaint the rows from data
136
+ // already in hand (or, when a Lite fetch is missing the column, rerun the same render to refetch).
137
+ this._lastRecordListData = null;
138
+ this._lastListRenderArgs = null;
114
139
  }
115
140
 
116
141
  handleRecordSetListRoute(pRoutePayload)
@@ -195,19 +220,27 @@ class viewRecordSetList extends libPictRecordSetRecordView
195
220
  const tmpRowsElements = this.pict.ContentAssignment.getElement('#PRSP_RecordList_Container');
196
221
  const tmpRowsContainerPresent = tmpRowsElements.length > 0;
197
222
  const tmpDestination = tmpRowsContainerPresent ? '#PRSP_RecordList_Container' : pRecordListData.RenderDestination;
223
+ // Fill roughly the visible viewport with ghost rows (each skeleton row occupies ~53px), capped to
224
+ // the preserved height so a short list doesn't over-fill, and clamped to a sane range.
225
+ const tmpViewportHeight = (typeof window !== 'undefined' && window.innerHeight) ? window.innerHeight : 800;
226
+ let tmpFillHeight = tmpViewportHeight + 200;
198
227
  if (tmpRowsContainerPresent)
199
228
  {
200
- // Pin the rows area to its current height before swapping in the (short) spinner, so the page
201
- // doesn't collapse and yank the content below it — pagination, the page's footer/colored fill —
202
- // up into the fold and back. The floor is released once the real rows render (see _paintRecordList).
229
+ // Pin the rows area to its current height before swapping in the spinner, so the page doesn't
230
+ // collapse and yank the content below it — pagination, the page's footer/colored fill — up into
231
+ // the fold and back. The floor is released once the real rows render (see _paintRecordList).
203
232
  const tmpCurrentHeight = tmpRowsElements[0].offsetHeight;
204
233
  if (tmpCurrentHeight > 0)
205
234
  {
206
235
  tmpRowsElements[0].style.minHeight = `${ tmpCurrentHeight }px`;
236
+ tmpFillHeight = Math.min(tmpCurrentHeight, tmpFillHeight);
207
237
  }
208
238
  }
239
+ const tmpSkeletonRowCount = Math.max(6, Math.min(60, Math.ceil(tmpFillHeight / 53)));
209
240
  this.pict.CSSMap.injectCSS();
210
- this.pict.ContentAssignment.assignContent(tmpDestination, this.pict.parseTemplateByHash('PRSP-List-LoadingShell', pRecordListData));
241
+ // Render the spinner + enough skeleton ghost rows to fill the visible area (so no white void shows).
242
+ const tmpLoadingShellData = { SkeletonRows: new Array(tmpSkeletonRowCount).fill(0) };
243
+ this.pict.ContentAssignment.assignContent(tmpDestination, this.pict.parseTemplateByHash('PRSP-List-LoadingShell', tmpLoadingShellData));
211
244
  }
212
245
  catch (pError)
213
246
  {
@@ -216,13 +249,19 @@ class viewRecordSetList extends libPictRecordSetRecordView
216
249
  }
217
250
  }
218
251
 
219
- dynamicallyGenerateColumns(pRecordListData)
252
+ /**
253
+ * The schema columns that never become list columns automatically: the entity's own
254
+ * identity pair plus the audit stamps. (Hosts that want one of these in the column
255
+ * chooser declare it as a curated column, optionally with DefaultHidden.)
256
+ *
257
+ * @param {string} pEntity - The entity name (for the ID/GUID identity columns)
258
+ * @return {Array<string>} The excluded column names.
259
+ */
260
+ _getExcludedSchemaColumns(pEntity)
220
261
  {
221
- pRecordListData.TableCells = [];
222
- const tmpEntity = pRecordListData.RecordSetConfiguration.Entity;
223
- this.excludedByDefaultCells = [
224
- 'ID' + tmpEntity,
225
- 'GUID' + tmpEntity,
262
+ return [
263
+ 'ID' + pEntity,
264
+ 'GUID' + pEntity,
226
265
  'CreateDate',
227
266
  'CreatingIDUser',
228
267
  'DeleteDate',
@@ -231,6 +270,13 @@ class viewRecordSetList extends libPictRecordSetRecordView
231
270
  'UpdateDate',
232
271
  'UpdatingIDUser',
233
272
  ];
273
+ }
274
+
275
+ dynamicallyGenerateColumns(pRecordListData)
276
+ {
277
+ pRecordListData.TableCells = [];
278
+ const tmpEntity = pRecordListData.RecordSetConfiguration.Entity;
279
+ this.excludedByDefaultCells = this._getExcludedSchemaColumns(tmpEntity);
234
280
 
235
281
  const tmpSchema = pRecordListData.RecordSchema;
236
282
  const tmpProperties = tmpSchema?.properties;
@@ -255,6 +301,190 @@ class viewRecordSetList extends libPictRecordSetRecordView
255
301
  return pRecordListData;
256
302
  }
257
303
 
304
+ /**
305
+ * Map column name -> Meadow column Type from the entity schema, when available. The schema
306
+ * endpoint nests the canonical column array at MeadowSchema.MeadowSchema.Schema (with a
307
+ * legacy flat MeadowSchema.Schema fallback). Returns null when neither is present (e.g.
308
+ * non-Meadow providers) so callers can skip type-based exclusions.
309
+ *
310
+ * @param {Record<string, any>} pRecordSchema - The schema from getRecordSchema()
311
+ * @return {Record<string, string>|null} Column name -> Type map, or null.
312
+ */
313
+ _getMeadowColumnTypes(pRecordSchema)
314
+ {
315
+ const tmpSchemaColumns = pRecordSchema?.MeadowSchema?.MeadowSchema?.Schema || pRecordSchema?.MeadowSchema?.Schema;
316
+ if (!Array.isArray(tmpSchemaColumns) || tmpSchemaColumns.length < 1)
317
+ {
318
+ return null;
319
+ }
320
+ /** @type {Record<string, string>} */
321
+ const tmpColumnTypes = {};
322
+ for (const tmpColumn of tmpSchemaColumns)
323
+ {
324
+ if (tmpColumn && tmpColumn.Column)
325
+ {
326
+ tmpColumnTypes[tmpColumn.Column] = tmpColumn.Type;
327
+ }
328
+ }
329
+ return tmpColumnTypes;
330
+ }
331
+
332
+ /**
333
+ * Whether a column candidate is effectively visible: an explicit user override wins,
334
+ * otherwise the candidate's default (visible unless DefaultHidden).
335
+ *
336
+ * @param {Record<string, any>} pCandidate - A ColumnCandidates entry
337
+ * @param {Record<string, boolean>} pOverrides - The per-recordset override map
338
+ * @return {boolean}
339
+ */
340
+ _effectiveColumnVisibility(pCandidate, pOverrides)
341
+ {
342
+ if (pOverrides && (pCandidate.Key in pOverrides))
343
+ {
344
+ return (pOverrides[pCandidate.Key] === true);
345
+ }
346
+ return (pCandidate.DefaultHidden !== true);
347
+ }
348
+
349
+ /**
350
+ * Compute the visible TableCells for a paint from the pristine candidate list + the user's
351
+ * current overrides. Cells are per-paint shallow copies so host hooks can mutate them without
352
+ * bleeding into the candidates. An override set that hides everything falls back to the
353
+ * default-visible set (a fully empty table is a confusing dead end).
354
+ *
355
+ * @param {Array<Record<string, any>>} pCandidates - The pristine ColumnCandidates
356
+ * @param {string} pRecordSet - The record set (for the override lookup)
357
+ * @return {Array<Record<string, any>>} The visible cells, in candidate order.
358
+ */
359
+ _computeVisibleTableCells(pCandidates, pRecordSet)
360
+ {
361
+ const tmpColumnProvider = this.pict.providers.ColumnDataProvider;
362
+ const tmpOverrides = tmpColumnProvider ? tmpColumnProvider.getColumnVisibilityOverrides(pRecordSet, 'List') : {};
363
+ let tmpVisibleCells = pCandidates
364
+ .filter((pCandidate) => this._effectiveColumnVisibility(pCandidate, tmpOverrides))
365
+ .map((pCell) => Object.assign({}, pCell));
366
+ if (tmpVisibleCells.length < 1)
367
+ {
368
+ this.log.warn(`RecordSetList: column visibility overrides for [${pRecordSet}] hid every column; rendering the default-visible set instead.`);
369
+ tmpVisibleCells = pCandidates
370
+ .filter((pCandidate) => pCandidate.DefaultHidden !== true)
371
+ .map((pCell) => Object.assign({}, pCell));
372
+ }
373
+ return tmpVisibleCells;
374
+ }
375
+
376
+ /**
377
+ * Compose the column-chooser candidate pool and reduce TableCells to the visible subset.
378
+ *
379
+ * No-op unless the recordset opts in with RecordSetListColumnChooser: true — the flag off
380
+ * leaves TableCells exactly as the existing paths computed it (including the manifest's
381
+ * shared array reference).
382
+ *
383
+ * Candidates are two tiers, in order:
384
+ * - Curated: the host-declared columns (manifest Descriptors or RecordSetListColumns),
385
+ * shallow-copied (the shared manifest TableCells entries are never mutated), default
386
+ * visible unless the column/descriptor declares DefaultHidden: true.
387
+ * - Schema: remaining scalar entity columns (identity/audit fields and blob Text/JSON
388
+ * columns excluded), default hidden, rendered via the generic ProcessCell template
389
+ * (entity-reference ID* columns resolve names exactly like dynamic columns do).
390
+ *
391
+ * The pristine candidates ride on pRecordListData.ColumnCandidates (module-owned — host
392
+ * hooks must not mutate it); TableCells becomes per-paint copies of the visible subset.
393
+ *
394
+ * @param {Record<string, any>} pRecordListData - The list data (TableCells already computed)
395
+ * @return {Record<string, any>} The same list data, candidates composed.
396
+ */
397
+ _composeColumnCandidates(pRecordListData)
398
+ {
399
+ // Always an array (empty = render nothing): a missing address would make the chooser
400
+ // button's {~TS:~} iterate the record object's own keys instead of rendering nothing.
401
+ pRecordListData.ColumnChooserSlot = [];
402
+ const tmpConfig = pRecordListData.RecordSetConfiguration;
403
+ if (!tmpConfig || tmpConfig.RecordSetListColumnChooser !== true)
404
+ {
405
+ return pRecordListData;
406
+ }
407
+
408
+ // Curated tier: shallow copies of whatever the host declared, flagged with source + default.
409
+ const tmpCandidates = (pRecordListData.TableCells || []).map((pCell) =>
410
+ Object.assign({}, pCell, { Source: 'Curated', DefaultHidden: (pCell.DefaultHidden === true) }));
411
+ const tmpCuratedKeys = {};
412
+ for (const tmpCell of tmpCandidates)
413
+ {
414
+ tmpCuratedKeys[tmpCell.Key] = true;
415
+ }
416
+
417
+ // Schema tier: every remaining scalar entity column, default hidden.
418
+ const tmpExcludedColumns = this._getExcludedSchemaColumns(tmpConfig.Entity);
419
+ const tmpProperties = pRecordListData.RecordSchema?.properties;
420
+ const tmpMeadowColumnTypes = this._getMeadowColumnTypes(pRecordListData.RecordSchema);
421
+ const tmpBlobTypes = { 'Text': true, 'JSON': true };
422
+ const tmpSchemaCandidates = [];
423
+ for (const tmpColumn in tmpProperties)
424
+ {
425
+ if (!tmpProperties.hasOwnProperty(tmpColumn) || tmpCuratedKeys[tmpColumn] || tmpExcludedColumns.includes(tmpColumn))
426
+ {
427
+ continue;
428
+ }
429
+ // When the Meadow schema is available, only offer real non-blob columns — a JSON-schema-only
430
+ // property is not fetchable by a Lite projection, and blob columns are why Lite exists.
431
+ if (tmpMeadowColumnTypes && (!(tmpColumn in tmpMeadowColumnTypes) || tmpBlobTypes[tmpMeadowColumnTypes[tmpColumn]]))
432
+ {
433
+ continue;
434
+ }
435
+ tmpSchemaCandidates.push(
436
+ {
437
+ Key: tmpColumn,
438
+ DisplayName: tmpProperties[tmpColumn].title || tmpColumn,
439
+ ManifestHash: 'Default',
440
+ PictDashboard: { ValueTemplate: '{~ProcessCell:Record.Data.Key~}' },
441
+ Source: 'Schema',
442
+ DefaultHidden: true,
443
+ });
444
+ }
445
+ tmpSchemaCandidates.sort((pA, pB) => String(pA.DisplayName).localeCompare(String(pB.DisplayName)));
446
+
447
+ // Audit tier: the identity pair + audit stamps, with friendly labels, trailing the schema
448
+ // tier. Default hidden; the create/update/delete user references resolve names via the same
449
+ // ProcessCell path as any other entity-reference column. (Pair with the show-deleted filter
450
+ // — RecordSetListShowDeletedFilter — to make the three Deleted* columns meaningful.)
451
+ const tmpAuditColumnLabels =
452
+ {
453
+ [`ID${tmpConfig.Entity}`]: 'ID',
454
+ [`GUID${tmpConfig.Entity}`]: 'GUID',
455
+ 'CreateDate': 'Created',
456
+ 'CreatingIDUser': 'Created by',
457
+ 'UpdateDate': 'Updated',
458
+ 'UpdatingIDUser': 'Updated by',
459
+ 'Deleted': 'Deleted',
460
+ 'DeleteDate': 'Deleted on',
461
+ 'DeletingIDUser': 'Deleted by',
462
+ };
463
+ const tmpAuditCandidates = [];
464
+ for (const tmpColumn of Object.keys(tmpAuditColumnLabels))
465
+ {
466
+ if (!tmpProperties || !tmpProperties.hasOwnProperty(tmpColumn) || tmpCuratedKeys[tmpColumn])
467
+ {
468
+ continue;
469
+ }
470
+ tmpAuditCandidates.push(
471
+ {
472
+ Key: tmpColumn,
473
+ DisplayName: tmpAuditColumnLabels[tmpColumn],
474
+ ManifestHash: 'Default',
475
+ PictDashboard: { ValueTemplate: '{~ProcessCell:Record.Data.Key~}' },
476
+ Source: 'Audit',
477
+ DefaultHidden: true,
478
+ });
479
+ }
480
+
481
+ pRecordListData.ColumnCandidates = tmpCandidates.concat(tmpSchemaCandidates, tmpAuditCandidates);
482
+ pRecordListData.TableCells = this._computeVisibleTableCells(pRecordListData.ColumnCandidates, pRecordListData.RecordSet);
483
+ // One-or-zero-element array driving the chooser button render (the {~TS:~} conditional trick).
484
+ pRecordListData.ColumnChooserSlot = [ { RecordSet: pRecordListData.RecordSet } ];
485
+ return pRecordListData;
486
+ }
487
+
258
488
  /**
259
489
  * @param {Record<string, any>} pRecordSetConfiguration
260
490
  * @param {string} pProviderHash
@@ -275,6 +505,10 @@ class viewRecordSetList extends libPictRecordSetRecordView
275
505
  return;
276
506
  }
277
507
 
508
+ // Remember how this list was rendered so a column-visibility change can rerun the exact same
509
+ // render (the manifest delegation below overwrites this with its own, which is what we want).
510
+ this._lastListRenderArgs = { Method: 'renderList', Args: [ pRecordSetConfiguration, pProviderHash, pFilterString, pSerializedFilterExperience, pOffset, pPageSize ] };
511
+
278
512
  if (pRecordSetConfiguration.RecordSetListManifestOnly)
279
513
  {
280
514
  const tmpManifestHash = pRecordSetConfiguration.RecordSetListDefaultManifest || pRecordSetConfiguration.RecordSetListManifests?.[0];
@@ -502,6 +736,8 @@ class viewRecordSetList extends libPictRecordSetRecordView
502
736
  {
503
737
  this.dynamicallyGenerateColumns(tmpRecordListData);
504
738
  }
739
+ this._composeColumnCandidates(tmpRecordListData);
740
+ this._lastRecordListData = tmpRecordListData;
505
741
  tmpRecordListData = this.onBeforeRenderList(tmpRecordListData);
506
742
 
507
743
  this._paintRecordList(tmpRecordListData, pBodyOnly);
@@ -533,6 +769,11 @@ class viewRecordSetList extends libPictRecordSetRecordView
533
769
  return;
534
770
  }
535
771
 
772
+ // Remember how this list was rendered so a column-visibility change can rerun the exact same
773
+ // render. When renderList delegated here this overwrite wins — a rerun skips re-resolving the
774
+ // manifest — and hosts that call renderListFromManifest directly are covered the same way.
775
+ this._lastListRenderArgs = { Method: 'renderListFromManifest', Args: [ pManifest, pRecordSetConfiguration, pProviderHash, pFilterString, pSerializedFilterExperience, pOffset, pPageSize ] };
776
+
536
777
  let tmpTitle = pRecordSetConfiguration.Title || pRecordSetConfiguration.RecordSet;
537
778
  if (pManifest && pManifest.TitleTemplate)
538
779
  {
@@ -750,6 +991,8 @@ class viewRecordSetList extends libPictRecordSetRecordView
750
991
  }
751
992
  }
752
993
 
994
+ this._composeColumnCandidates(tmpRecordListData);
995
+ this._lastRecordListData = tmpRecordListData;
753
996
  tmpRecordListData = this.onBeforeRenderList(tmpRecordListData);
754
997
 
755
998
  this.pict.providers.DynamicRecordsetSolver.solveDashboard(pManifest, tmpRecordListData.Records.Records);
@@ -815,6 +1058,136 @@ class viewRecordSetList extends libPictRecordSetRecordView
815
1058
  }.bind(this));
816
1059
  }
817
1060
 
1061
+ /**
1062
+ * Set a column's visibility for the currently rendered list (called by the column chooser).
1063
+ *
1064
+ * Persists the override, then repaints the rows + pagination body-only from the data already
1065
+ * in hand — except when a Lite-fetched list is showing a schema-tier column whose values were
1066
+ * never fetched, in which case the same render is rerun so the provider widens the projection.
1067
+ *
1068
+ * @param {string} pRecordSet - The record set the column belongs to (stale-chooser guard)
1069
+ * @param {string} pKey - The column key
1070
+ * @param {boolean} pVisible - Whether the column should be visible
1071
+ * @return {boolean} The column's resulting visibility.
1072
+ */
1073
+ setColumnVisibility(pRecordSet, pKey, pVisible)
1074
+ {
1075
+ const tmpListData = this._lastRecordListData;
1076
+ if (!tmpListData || tmpListData.RecordSet !== pRecordSet || !Array.isArray(tmpListData.ColumnCandidates))
1077
+ {
1078
+ this.log.warn(`RecordSetList: setColumnVisibility for [${pRecordSet}.${pKey}] ignored — that list is not the one currently rendered.`);
1079
+ return false;
1080
+ }
1081
+ const tmpCandidate = tmpListData.ColumnCandidates.find((pCandidate) => pCandidate.Key === pKey);
1082
+ if (!tmpCandidate)
1083
+ {
1084
+ this.log.warn(`RecordSetList: setColumnVisibility for unknown column [${pKey}] on [${pRecordSet}] ignored.`);
1085
+ return false;
1086
+ }
1087
+ const tmpColumnProvider = this.pict.providers.ColumnDataProvider;
1088
+ if (!tmpColumnProvider)
1089
+ {
1090
+ return this._effectiveColumnVisibility(tmpCandidate, {});
1091
+ }
1092
+ // Keep at least one column visible — an all-hidden table is a confusing dead end.
1093
+ if (pVisible !== true)
1094
+ {
1095
+ const tmpOverrides = tmpColumnProvider.getColumnVisibilityOverrides(pRecordSet, 'List');
1096
+ const tmpVisibleCount = tmpListData.ColumnCandidates.filter((pCandidate) => this._effectiveColumnVisibility(pCandidate, tmpOverrides)).length;
1097
+ if ((tmpVisibleCount <= 1) && this._effectiveColumnVisibility(tmpCandidate, tmpOverrides))
1098
+ {
1099
+ this.log.warn(`RecordSetList: refusing to hide the last visible column [${pKey}] on [${pRecordSet}].`);
1100
+ return true;
1101
+ }
1102
+ }
1103
+ tmpColumnProvider.setColumnVisibilityOverride(pRecordSet, 'List', pKey, (pVisible === true));
1104
+
1105
+ // Lite projections omit unrequested columns entirely, so a newly shown schema- or audit-tier
1106
+ // column with no data in the fetched records needs one refetch (the provider reads the override
1107
+ // map at fetch time and widens the projection). Curated/manifest columns are always fetched or
1108
+ // solved, and schema/audit keys are flat — a first-record key check is sound. (The identity pair,
1109
+ // CreatingIDUser, and UpdateDate ride free in every Lite record, so they pass the key check.)
1110
+ const tmpRecords = (tmpListData.Records && Array.isArray(tmpListData.Records.Records)) ? tmpListData.Records.Records : [];
1111
+ const tmpNeedsRefetch = (pVisible === true)
1112
+ && (tmpCandidate.Source !== 'Curated')
1113
+ && (tmpListData.RecordSetConfiguration && tmpListData.RecordSetConfiguration.RecordSetListLiteFetch === true)
1114
+ && (tmpRecords.length > 0)
1115
+ && !(pKey in tmpRecords[0]);
1116
+ if (tmpNeedsRefetch)
1117
+ {
1118
+ this._rerunLastListRender();
1119
+ }
1120
+ else
1121
+ {
1122
+ this._repaintWithColumnState();
1123
+ }
1124
+ return (pVisible === true);
1125
+ }
1126
+
1127
+ /**
1128
+ * Clear every column-visibility override for the currently rendered list and repaint with the
1129
+ * defaults (called by the column chooser's Reset). Never needs a refetch: resetting only
1130
+ * restores curated columns (always fetched) and hides schema extras.
1131
+ *
1132
+ * @param {string} pRecordSet - The record set to reset (stale-chooser guard)
1133
+ * @return {boolean} True when the reset happened.
1134
+ */
1135
+ resetColumnVisibility(pRecordSet)
1136
+ {
1137
+ const tmpListData = this._lastRecordListData;
1138
+ if (!tmpListData || tmpListData.RecordSet !== pRecordSet)
1139
+ {
1140
+ this.log.warn(`RecordSetList: resetColumnVisibility for [${pRecordSet}] ignored — that list is not the one currently rendered.`);
1141
+ return false;
1142
+ }
1143
+ const tmpColumnProvider = this.pict.providers.ColumnDataProvider;
1144
+ if (tmpColumnProvider)
1145
+ {
1146
+ tmpColumnProvider.clearColumnVisibilityOverrides(pRecordSet, 'List');
1147
+ }
1148
+ this._repaintWithColumnState();
1149
+ return true;
1150
+ }
1151
+
1152
+ /**
1153
+ * Repaint the rows + pagination (body-only) from the last composed list data, with TableCells
1154
+ * recomputed from the pristine candidates + current overrides. onBeforeRenderList is re-invoked
1155
+ * — it is the documented seam where hosts append custom cells, and rebuilding TableCells from
1156
+ * candidates each paint means hook mutations apply exactly once per paint. (Hosts that decorate
1157
+ * Records in the hook must keep that decoration idempotent; the hook already re-runs on every
1158
+ * page change.) No loading shell: the data is already in hand, so the swap is immediate.
1159
+ *
1160
+ * @return {void}
1161
+ */
1162
+ _repaintWithColumnState()
1163
+ {
1164
+ const tmpSourceData = this._lastRecordListData;
1165
+ if (!tmpSourceData || !Array.isArray(tmpSourceData.ColumnCandidates))
1166
+ {
1167
+ return;
1168
+ }
1169
+ let tmpPaintData = Object.assign({}, tmpSourceData);
1170
+ tmpPaintData.TableCells = this._computeVisibleTableCells(tmpSourceData.ColumnCandidates, tmpSourceData.RecordSet);
1171
+ tmpPaintData = this.onBeforeRenderList(tmpPaintData);
1172
+ this._paintRecordList(tmpPaintData, true);
1173
+ }
1174
+
1175
+ /**
1176
+ * Rerun the last list render with the same arguments (body-only — the list shell and filters
1177
+ * are already on screen). Used when a column toggle needs a refetch under Lite.
1178
+ *
1179
+ * @return {Promise<void>|undefined}
1180
+ */
1181
+ _rerunLastListRender()
1182
+ {
1183
+ if (!this._lastListRenderArgs)
1184
+ {
1185
+ return;
1186
+ }
1187
+ const tmpArgs = this._lastListRenderArgs.Args.concat([ true ]);
1188
+ return this[this._lastListRenderArgs.Method](...tmpArgs);
1189
+ }
1190
+
818
1191
  onInitialize()
819
1192
  {
820
1193
  this.childViews.headerList = this.pict.addView('PRSP-List-HeaderList', viewHeaderList.default_configuration, viewHeaderList);
@@ -825,6 +1198,7 @@ class viewRecordSetList extends libPictRecordSetRecordView
825
1198
  this.childViews.recordListHeader = this.pict.addView('PRSP-List-RecordListHeader', viewRecordListHeader.default_configuration, viewRecordListHeader);
826
1199
  this.childViews.recordListEntry = this.pict.addView('PRSP-List-RecordListEntry', viewRecordListEntry.default_configuration, viewRecordListEntry);
827
1200
  this.childViews.paginationBottom = this.pict.addView('PRSP-List-PaginationBottom', viewPaginationBottom.default_configuration, viewPaginationBottom);
1201
+ this.childViews.columnChooser = this.pict.addView('PRSP-List-ColumnChooser', viewColumnChooser.default_configuration, viewColumnChooser);
828
1202
 
829
1203
  // Initialize the subviews
830
1204
  this.childViews.headerList.initialize();
@@ -834,6 +1208,7 @@ class viewRecordSetList extends libPictRecordSetRecordView
834
1208
  this.childViews.recordListHeader.initialize();
835
1209
  this.childViews.recordListEntry.initialize();
836
1210
  this.childViews.paginationBottom.initialize();
1211
+ this.childViews.columnChooser.initialize();
837
1212
 
838
1213
  return super.onInitialize();
839
1214
  }
@@ -29,7 +29,12 @@ const _DEFAULT_CONFIGURATION__Read = (
29
29
  AutoSolveOrdinal: 0,
30
30
 
31
31
  CSS: /*css*/`
32
- .prsp-audit-header { display: flex; align-items: center; justify-content: space-between; gap: 1rem; flex-wrap: wrap; margin: 0 0 1rem; }
32
+ /* Soft-deleted record banner (the ViewDeleted route) quiet alarm, theme-token driven. */
33
+ .prsp-read-deleted-banner { display: flex; align-items: center; gap: 0.5rem; margin: 0 0 0.85rem; padding: 0.55rem 0.85rem;
34
+ border: 1px solid var(--theme-color-status-error, #c0504d); border-radius: 8px; font-size: 0.92rem;
35
+ color: var(--theme-color-status-error, #c0504d);
36
+ background: color-mix(in srgb, var(--theme-color-status-error, #c0504d) 8%, transparent); }
37
+ .prsp-audit-header { display: flex; align-items: center; justify-content: space-between; gap: 1rem; flex-wrap: wrap; margin: 0 0 1rem; }
33
38
  .prsp-audit-line { display: inline-flex; align-items: center; gap: 0.4rem; color: var(--theme-color-text-muted, #6b7686); font-size: 0.85rem; }
34
39
  .prsp-audit-line .pict-icon { font-size: 0.8rem; }
35
40
  .prsp-audit-line strong { color: var(--theme-color-text-secondary, #45505f); font-weight: 600; }
@@ -256,12 +261,15 @@ const _DEFAULT_CONFIGURATION__Read = (
256
261
  {
257
262
  Hash: 'PRSP-Read-Link-URL-Template',
258
263
  // TODO: Double payload pattern...
259
- Template: `#/PSRS/{~D:Record.Payload.Payload.RecordSet~}/View/{~DVBK:Record.Payload.Data:Record.Payload.Payload.GUIDAddress~}`
264
+ // Soft-deleted rows (visible via the show-deleted filter) route to ViewDeleted, whose
265
+ // lookup explicitly includes deleted records — a plain View would find nothing.
266
+ Template: `#/PSRS/{~D:Record.Payload.Payload.RecordSet~}/View{~NE:Record.Payload.Data.Deleted^Deleted~}/{~DVBK:Record.Payload.Data:Record.Payload.Payload.GUIDAddress~}`
260
267
  },
261
268
  // --- Record audit header (themeable; apps brand via --theme-color-* tokens) ---
262
269
  {
263
270
  Hash: 'PRSP-Read-RecordAuditHeader-Template',
264
271
  Template: /*html*/`
272
+ {~TS:PRSP-Read-DeletedBanner-Template:AppData.PRSP_RecordAudit.DeletedBannerSlot~}
265
273
  <div class="prsp-audit-header">
266
274
  <div class="prsp-audit-activity">{~TS:PRSP-Read-RecordAudit-Line-Template:AppData.PRSP_RecordAudit.ActivitySlot~}</div>
267
275
  <div class="prsp-audit-anchor">
@@ -299,6 +307,16 @@ const _DEFAULT_CONFIGURATION__Read = (
299
307
  Hash: 'PRSP-Read-RecordAudit-Deleted-Template',
300
308
  Template: /*html*/`<dt>Deleted</dt><dd class="is-deleted">{~DateFormat:Record.Date^MMM D, YYYY - h:mm A~} <small>by {~E:User^Record.UserID^PRSP-Read-RecordAudit-UserName-Template~}</small></dd>`
301
309
  },
310
+ {
311
+ // Soft-deleted record banner (the ViewDeleted route, or a record deleted out from
312
+ // under a normal View). The one-or-zero-element DeletedBannerSlot drives it.
313
+ Hash: 'PRSP-Read-DeletedBanner-Template',
314
+ Template: /*html*/`<div class="prsp-read-deleted-banner">{~I:Warning~} This record has been deleted{~TIfAbs:PRSP-Read-DeletedBanner-Date-Template:Record:Record.HasDate^TRUE^~}.</div>`
315
+ },
316
+ {
317
+ Hash: 'PRSP-Read-DeletedBanner-Date-Template',
318
+ Template: /*html*/` on {~DateFormat:Record.Date^MMM D, YYYY - h:mm A~}`
319
+ },
302
320
  ],
303
321
 
304
322
  Renderables:
@@ -404,8 +422,35 @@ class viewRecordSetRead extends libPictRecordSetRecordView
404
422
  }
405
423
 
406
424
  this.action = 'View';
425
+ this.viewingDeletedRecord = false;
426
+ const tmpProviderConfiguration = this.pict.PictSectionRecordSet.recordSetProviderConfigurations[pRoutePayload.data.RecordSet];
427
+ this.layoutType = tmpProviderConfiguration?.ReadLayout || 'Basic';
428
+ const tmpProviderHash = `RSP-Provider-${pRoutePayload.data.RecordSet}`;
429
+
430
+ tmpProviderConfiguration.RoutePayload = pRoutePayload;
431
+ tmpProviderConfiguration.RecordSet = pRoutePayload.data.RecordSet;
432
+ tmpProviderConfiguration.GUIDRecord = pRoutePayload.data.GUIDRecord;
433
+
434
+ return this.renderRead(tmpProviderConfiguration, tmpProviderHash, pRoutePayload.data.GUIDRecord);
435
+ }
436
+
437
+ /**
438
+ * The deleted-record read route (`/PSRS/:RecordSet/ViewDeleted/:GUIDRecord`): identical to the
439
+ * View route except the record lookup explicitly includes soft-deleted rows (a normal View of a
440
+ * deleted record finds nothing — delete tracking filters it out — and renders broken). The flag
441
+ * also rides the read data as ViewingDeletedRecord, which drives the deleted banner.
442
+ */
443
+ handleRecordSetReadDeletedRoute(pRoutePayload)
444
+ {
445
+ if (typeof(pRoutePayload) != 'object')
446
+ {
447
+ throw new Error(`Pict RecordSet Read view route handler called with invalid route payload.`);
448
+ }
449
+
450
+ this.action = 'View';
451
+ this.viewingDeletedRecord = true;
407
452
  const tmpProviderConfiguration = this.pict.PictSectionRecordSet.recordSetProviderConfigurations[pRoutePayload.data.RecordSet];
408
- this.layoutType = tmpProviderConfiguration?.ReadLayout || 'Basic';
453
+ this.layoutType = tmpProviderConfiguration?.ReadLayout || 'Basic';
409
454
  const tmpProviderHash = `RSP-Provider-${pRoutePayload.data.RecordSet}`;
410
455
 
411
456
  tmpProviderConfiguration.RoutePayload = pRoutePayload;
@@ -423,8 +468,9 @@ class viewRecordSetRead extends libPictRecordSetRecordView
423
468
  }
424
469
 
425
470
  this.action = 'Edit';
471
+ this.viewingDeletedRecord = false;
426
472
  const tmpProviderConfiguration = this.pict.PictSectionRecordSet.recordSetProviderConfigurations[pRoutePayload.data.RecordSet];
427
- this.layoutType = tmpProviderConfiguration?.ReadLayout || 'Basic';
473
+ this.layoutType = tmpProviderConfiguration?.ReadLayout || 'Basic';
428
474
  const tmpProviderHash = `RSP-Provider-${pRoutePayload.data.RecordSet}`;
429
475
 
430
476
  tmpProviderConfiguration.RoutePayload = pRoutePayload;
@@ -437,6 +483,7 @@ class viewRecordSetRead extends libPictRecordSetRecordView
437
483
  addRoutes(pPictRouter)
438
484
  {
439
485
  pPictRouter.addRoute('/PSRS/:RecordSet/View/:GUIDRecord', this.handleRecordSetReadRoute.bind(this));
486
+ pPictRouter.addRoute('/PSRS/:RecordSet/ViewDeleted/:GUIDRecord', this.handleRecordSetReadDeletedRoute.bind(this));
440
487
  pPictRouter.addRoute('/PSRS/:RecordSet/Edit/:GUIDRecord', this.handleRecordSetEditRoute.bind(this));
441
488
  return true;
442
489
  }
@@ -507,7 +554,10 @@ class viewRecordSetRead extends libPictRecordSetRecordView
507
554
  ActivitySlot: tmpActivitySlot,
508
555
  CreatedSlot: tmpHasCreate ? [{ Date: pRecord.CreateDate, UserID: pRecord.CreatingIDUser }] : [],
509
556
  UpdatedSlot: tmpHasUpdate ? [{ Date: pRecord.UpdateDate, UserID: pRecord.UpdatingIDUser }] : [],
510
- DeletedSlot: tmpDeleted ? [{ Date: pRecord.DeleteDate, UserID: pRecord.DeletingIDUser }] : []
557
+ DeletedSlot: tmpDeleted ? [{ Date: pRecord.DeleteDate, UserID: pRecord.DeletingIDUser }] : [],
558
+ // The ViewDeleted route's banner: present whenever the record is soft-deleted (whether the
559
+ // user arrived via ViewDeleted or the record was deleted out from under a normal View).
560
+ DeletedBannerSlot: (!!pRecord.Deleted) ? [{ Date: pRecord.DeleteDate, UserID: pRecord.DeletingIDUser, HasDate: this._validAuditDate(pRecord.DeleteDate) }] : []
511
561
  };
512
562
  }
513
563
 
@@ -753,8 +803,9 @@ class viewRecordSetRead extends libPictRecordSetRecordView
753
803
  // TODO: This should be coming from the schema but that can come after we discuss how we deal with default routing
754
804
  tmpRecordReadData.GUIDAddress = this.pict.providers[pProviderHash].getGUIDField();
755
805
 
756
- tmpRecordReadData.Record = await this.pict.providers[pProviderHash].getRecordByGUID(pRecordGUID);
806
+ tmpRecordReadData.Record = await this.pict.providers[pProviderHash].getRecordByGUID(pRecordGUID, this.viewingDeletedRecord === true);
757
807
  tmpRecordReadData.RecordSchema = await this.pict.providers[pProviderHash].getRecordSchema();
808
+ tmpRecordReadData.ViewingDeletedRecord = (this.viewingDeletedRecord === true);
758
809
  this.pict.AppData[`${ tmpRecordReadData.RecordSet }Details`] = tmpRecordReadData.Record;
759
810
 
760
811
  // Build the audit header state (first-class activity line + the Details modal) for this record.
@@ -847,6 +898,14 @@ class viewRecordSetRead extends libPictRecordSetRecordView
847
898
  document.getElementById('PRSP-Read-SaveButton').classList.remove('record-button-bar-hidden');
848
899
  document.getElementById('PRSP-Read-CancelButton').classList.remove('record-button-bar-hidden');
849
900
  }
901
+ else if (this.viewingDeletedRecord === true)
902
+ {
903
+ // A deleted record can't be edited (the Edit route's lookup excludes deleted rows) —
904
+ // the ViewDeleted page is read-only.
905
+ document.getElementById('PRSP-Read-EditButton').classList.add('record-button-bar-hidden');
906
+ document.getElementById('PRSP-Read-SaveButton').classList.add('record-button-bar-hidden');
907
+ document.getElementById('PRSP-Read-CancelButton').classList.add('record-button-bar-hidden');
908
+ }
850
909
  else
851
910
  {
852
911
  document.getElementById('PRSP-Read-EditButton').classList.remove('record-button-bar-hidden');