pict-section-recordset 1.9.5 → 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 +412 -14
  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
@@ -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)
@@ -192,10 +217,30 @@ class viewRecordSetList extends libPictRecordSetRecordView
192
217
  // When the list is already on screen, scope the spinner to just the rows area so the title,
193
218
  // filters, and pagination stay put (and the expensive filter view isn't disturbed). On the first
194
219
  // render the rows container doesn't exist yet, so fall back to the whole list destination.
195
- const tmpRowsContainerPresent = this.pict.ContentAssignment.getElement('#PRSP_RecordList_Container').length > 0;
220
+ const tmpRowsElements = this.pict.ContentAssignment.getElement('#PRSP_RecordList_Container');
221
+ const tmpRowsContainerPresent = tmpRowsElements.length > 0;
196
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;
227
+ if (tmpRowsContainerPresent)
228
+ {
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).
232
+ const tmpCurrentHeight = tmpRowsElements[0].offsetHeight;
233
+ if (tmpCurrentHeight > 0)
234
+ {
235
+ tmpRowsElements[0].style.minHeight = `${ tmpCurrentHeight }px`;
236
+ tmpFillHeight = Math.min(tmpCurrentHeight, tmpFillHeight);
237
+ }
238
+ }
239
+ const tmpSkeletonRowCount = Math.max(6, Math.min(60, Math.ceil(tmpFillHeight / 53)));
197
240
  this.pict.CSSMap.injectCSS();
198
- 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));
199
244
  }
200
245
  catch (pError)
201
246
  {
@@ -204,13 +249,19 @@ class viewRecordSetList extends libPictRecordSetRecordView
204
249
  }
205
250
  }
206
251
 
207
- 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)
208
261
  {
209
- pRecordListData.TableCells = [];
210
- const tmpEntity = pRecordListData.RecordSetConfiguration.Entity;
211
- this.excludedByDefaultCells = [
212
- 'ID' + tmpEntity,
213
- 'GUID' + tmpEntity,
262
+ return [
263
+ 'ID' + pEntity,
264
+ 'GUID' + pEntity,
214
265
  'CreateDate',
215
266
  'CreatingIDUser',
216
267
  'DeleteDate',
@@ -219,6 +270,13 @@ class viewRecordSetList extends libPictRecordSetRecordView
219
270
  'UpdateDate',
220
271
  'UpdatingIDUser',
221
272
  ];
273
+ }
274
+
275
+ dynamicallyGenerateColumns(pRecordListData)
276
+ {
277
+ pRecordListData.TableCells = [];
278
+ const tmpEntity = pRecordListData.RecordSetConfiguration.Entity;
279
+ this.excludedByDefaultCells = this._getExcludedSchemaColumns(tmpEntity);
222
280
 
223
281
  const tmpSchema = pRecordListData.RecordSchema;
224
282
  const tmpProperties = tmpSchema?.properties;
@@ -243,6 +301,190 @@ class viewRecordSetList extends libPictRecordSetRecordView
243
301
  return pRecordListData;
244
302
  }
245
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
+
246
488
  /**
247
489
  * @param {Record<string, any>} pRecordSetConfiguration
248
490
  * @param {string} pProviderHash
@@ -263,6 +505,10 @@ class viewRecordSetList extends libPictRecordSetRecordView
263
505
  return;
264
506
  }
265
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
+
266
512
  if (pRecordSetConfiguration.RecordSetListManifestOnly)
267
513
  {
268
514
  const tmpManifestHash = pRecordSetConfiguration.RecordSetListDefaultManifest || pRecordSetConfiguration.RecordSetListManifests?.[0];
@@ -490,6 +736,8 @@ class viewRecordSetList extends libPictRecordSetRecordView
490
736
  {
491
737
  this.dynamicallyGenerateColumns(tmpRecordListData);
492
738
  }
739
+ this._composeColumnCandidates(tmpRecordListData);
740
+ this._lastRecordListData = tmpRecordListData;
493
741
  tmpRecordListData = this.onBeforeRenderList(tmpRecordListData);
494
742
 
495
743
  this._paintRecordList(tmpRecordListData, pBodyOnly);
@@ -521,6 +769,11 @@ class viewRecordSetList extends libPictRecordSetRecordView
521
769
  return;
522
770
  }
523
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
+
524
777
  let tmpTitle = pRecordSetConfiguration.Title || pRecordSetConfiguration.RecordSet;
525
778
  if (pManifest && pManifest.TitleTemplate)
526
779
  {
@@ -738,6 +991,8 @@ class viewRecordSetList extends libPictRecordSetRecordView
738
991
  }
739
992
  }
740
993
 
994
+ this._composeColumnCandidates(tmpRecordListData);
995
+ this._lastRecordListData = tmpRecordListData;
741
996
  tmpRecordListData = this.onBeforeRenderList(tmpRecordListData);
742
997
 
743
998
  this.pict.providers.DynamicRecordsetSolver.solveDashboard(pManifest, tmpRecordListData.Records.Records);
@@ -789,7 +1044,148 @@ class viewRecordSetList extends libPictRecordSetRecordView
789
1044
  // rows, each into its own stable container. The filter view, title, and header list are left as-is.
790
1045
  this.childViews.paginationTop.renderAsync('PRSP_Renderable_PaginationTop', '#PRSP_PaginationTop_Container', pRecordListData, null, () => { });
791
1046
  this.childViews.paginationBottom.renderAsync('PRSP_Renderable_PaginationBottom', '#PRSP_PaginationBottom_Container', pRecordListData, null, () => { });
792
- this.childViews.recordList.renderAsync('PRSP_Renderable_RecordList', '#PRSP_RecordList_Container', pRecordListData, null, fLogRendered);
1047
+ this.childViews.recordList.renderAsync('PRSP_Renderable_RecordList', '#PRSP_RecordList_Container', pRecordListData, null,
1048
+ function (pError)
1049
+ {
1050
+ // Release the height floor that was pinned while the spinner showed (see _projectLoadingShell),
1051
+ // now that the real rows are back in and the container can size to its content again.
1052
+ const tmpRows = this.pict.ContentAssignment.getElement('#PRSP_RecordList_Container');
1053
+ if (tmpRows.length)
1054
+ {
1055
+ tmpRows[0].style.minHeight = '';
1056
+ }
1057
+ fLogRendered(pError);
1058
+ }.bind(this));
1059
+ }
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);
793
1189
  }
794
1190
 
795
1191
  onInitialize()
@@ -802,6 +1198,7 @@ class viewRecordSetList extends libPictRecordSetRecordView
802
1198
  this.childViews.recordListHeader = this.pict.addView('PRSP-List-RecordListHeader', viewRecordListHeader.default_configuration, viewRecordListHeader);
803
1199
  this.childViews.recordListEntry = this.pict.addView('PRSP-List-RecordListEntry', viewRecordListEntry.default_configuration, viewRecordListEntry);
804
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);
805
1202
 
806
1203
  // Initialize the subviews
807
1204
  this.childViews.headerList.initialize();
@@ -811,6 +1208,7 @@ class viewRecordSetList extends libPictRecordSetRecordView
811
1208
  this.childViews.recordListHeader.initialize();
812
1209
  this.childViews.recordListEntry.initialize();
813
1210
  this.childViews.paginationBottom.initialize();
1211
+ this.childViews.columnChooser.initialize();
814
1212
 
815
1213
  return super.onInitialize();
816
1214
  }