pict-section-recordset 1.9.0 → 1.9.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pict-section-recordset",
3
- "version": "1.9.0",
3
+ "version": "1.9.2",
4
4
  "description": "Pict dynamic record set management views",
5
5
  "main": "source/Pict-Section-RecordSet.js",
6
6
  "files": [
@@ -37,9 +37,9 @@
37
37
  "browser-env": "^3.3.0",
38
38
  "eslint": "^9.28.0",
39
39
  "jquery": "^3.7.1",
40
- "pict": "^1.0.372",
40
+ "pict": "^1.0.374",
41
41
  "pict-application": "^1.0.34",
42
- "pict-docuserve": "^1.4.4",
42
+ "pict-docuserve": "^1.4.19",
43
43
  "pict-service-commandlineutility": "^1.0.19",
44
44
  "quackage": "^1.3.0",
45
45
  "typescript": "^5.9.3"
@@ -48,7 +48,7 @@
48
48
  "fable-serviceproviderbase": "^3.0.19",
49
49
  "pict-provider": "^1.0.13",
50
50
  "pict-router": "^1.0.10",
51
- "pict-section-form": "^1.0.196",
51
+ "pict-section-form": "^1.1.9",
52
52
  "pict-template": "^1.0.15",
53
53
  "pict-view": "^1.0.68",
54
54
  "sinon": "^21.0.1"
@@ -192,6 +192,63 @@ class MeadowEndpointsRecordSetProvider extends libRecordSetProviderBase
192
192
  return [ tmpClauses, tmpExperience ];
193
193
  }
194
194
 
195
+ /**
196
+ * Derive the Lite `ExtraColumns` for a list fetch from the manifest's displayed
197
+ * columns. Lite already returns the ID-prefixed, GUID-prefixed, CreatingIDUser and
198
+ * UpdateDate fields plus a computed Value, so we only request the remaining scalar
199
+ * display columns — and only ones that are real, non-blob schema columns. Returns
200
+ * [] (caller then does a safe full fetch) if the manifest columns or schema are
201
+ * unavailable.
202
+ * @param {string} pEntity - The entity being listed.
203
+ * @param {Record<string, any>} pOptions - The list options (carries RecordSetConfiguration).
204
+ * @return {Array<string>} The ExtraColumns to request.
205
+ */
206
+ _deriveLiteExtraColumns(pEntity, pOptions)
207
+ {
208
+ const tmpConfig = pOptions && pOptions.RecordSetConfiguration;
209
+ let tmpDescriptors = null;
210
+ if (tmpConfig && this.pict.PictSectionRecordSet && this.pict.PictSectionRecordSet.manifestDefinitions)
211
+ {
212
+ const tmpManifestHash = tmpConfig.RecordSetListDefaultManifest || (Array.isArray(tmpConfig.RecordSetListManifests) ? tmpConfig.RecordSetListManifests[0] : null);
213
+ const tmpManifest = tmpManifestHash ? this.pict.PictSectionRecordSet.manifestDefinitions[tmpManifestHash] : null;
214
+ tmpDescriptors = (tmpManifest && tmpManifest.Descriptors) || null;
215
+ }
216
+ const tmpSchemaColumns = (this._Schema && this._Schema.MeadowSchema && Array.isArray(this._Schema.MeadowSchema.Schema)) ? this._Schema.MeadowSchema.Schema : [];
217
+ if (!tmpDescriptors || tmpSchemaColumns.length < 1)
218
+ {
219
+ return [];
220
+ }
221
+ const tmpColumnType = {};
222
+ for (const tmpColumn of tmpSchemaColumns)
223
+ {
224
+ if (tmpColumn && tmpColumn.Column)
225
+ {
226
+ tmpColumnType[tmpColumn.Column] = tmpColumn.Type;
227
+ }
228
+ }
229
+ const tmpBlobTypes = { 'Text': true, 'JSON': true };
230
+ const tmpColumns = [];
231
+ for (const tmpKey of Object.keys(tmpDescriptors))
232
+ {
233
+ // ID*/GUID*/owner/update come back in every Lite record for free.
234
+ if (tmpKey.startsWith('ID') || tmpKey.startsWith('GUID') || tmpKey === 'CreatingIDUser' || tmpKey === 'UpdateDate')
235
+ {
236
+ continue;
237
+ }
238
+ // Only request real, non-blob columns (a computed/templated manifest column
239
+ // that is not a DB column would otherwise error the query).
240
+ if (!(tmpKey in tmpColumnType) || tmpBlobTypes[tmpColumnType[tmpKey]])
241
+ {
242
+ continue;
243
+ }
244
+ if (!tmpColumns.includes(tmpKey))
245
+ {
246
+ tmpColumns.push(tmpKey);
247
+ }
248
+ }
249
+ return tmpColumns;
250
+ }
251
+
195
252
  /**
196
253
  * Read records from the provider.
197
254
  *
@@ -205,6 +262,30 @@ class MeadowEndpointsRecordSetProvider extends libRecordSetProviderBase
205
262
  throw new Error('Entity is not defined in the provider options.');
206
263
  }
207
264
  const tmpEntity = pOptions.Entity || this.options.Entity;
265
+
266
+ // Lite projection (opt-in via RecordSetListLiteFetch): request only the columns
267
+ // the manifest displays so the list stops pulling blob columns (FormData, etc.).
268
+ // Lite (partial) records are NOT written to the entity cache (see getEntitySetPage) —
269
+ // the list renders them straight from state — so they can never poison the global
270
+ // cache that full-record consumers (row-click View, {~E:~}) rely on. The list's
271
+ // reference entities (Project/User) are full records and stay in the global cache,
272
+ // batched reliably by the connected-entity prefetch + the stale-read prune fix.
273
+ const tmpLiteFetch = (this.options.RecordSetListLiteFetch === true) || !!(pOptions && pOptions.RecordSetConfiguration && pOptions.RecordSetConfiguration.RecordSetListLiteFetch);
274
+ let tmpProjection = null;
275
+ if (tmpLiteFetch)
276
+ {
277
+ // Ensure the entity schema is loaded so we only request real, non-blob columns.
278
+ if (!this._Schema)
279
+ {
280
+ await this.getRecordSchema();
281
+ }
282
+ const tmpExtraColumns = this._deriveLiteExtraColumns(tmpEntity, pOptions);
283
+ if (tmpExtraColumns.length > 0)
284
+ {
285
+ tmpProjection = { Mode: 'LiteExtended', ExtraColumns: tmpExtraColumns };
286
+ }
287
+ }
288
+
208
289
  if (this.pict.LogNoisiness > 1)
209
290
  {
210
291
  this.pict.log.info(`Reading ${tmpEntity} records`, { Options: pOptions });
@@ -212,6 +293,10 @@ class MeadowEndpointsRecordSetProvider extends libRecordSetProviderBase
212
293
  return new Promise((resolve, reject) =>
213
294
  {
214
295
  const [ tmpClauses, tmpExperience ] = this._prepareFilterState(tmpEntity, pOptions);
296
+ if (tmpProjection)
297
+ {
298
+ tmpExperience.Projection = tmpProjection;
299
+ }
215
300
  if (this.options.FilterEndpointOverride)
216
301
  {
217
302
  // Call the filtering endpoint with the clauses and experience.
@@ -237,7 +322,7 @@ class MeadowEndpointsRecordSetProvider extends libRecordSetProviderBase
237
322
  }
238
323
  }
239
324
  }
240
- this.pict.EntityProvider.cacheConnectedEntityRecordsWithoutCount(recordsReturn, IDFields, ['User', 'User'], false, () =>
325
+ this.pict.EntityProvider.cacheConnectedEntityRecordsWithoutCount(recordsReturn, IDFields, ['User', 'User'], false, () =>
241
326
  {
242
327
  resolve({ Records: recordsReturn, Facets: { } });
243
328
  });
@@ -262,7 +347,12 @@ class MeadowEndpointsRecordSetProvider extends libRecordSetProviderBase
262
347
  }
263
348
  }
264
349
  }
265
- this.pict.EntityProvider.cacheConnectedEntityRecords(recordsReturn, IDFields, ['User', 'User'], false, () =>
350
+ // Use the NoCount (lazy-page) batch: counts are very costly in this MySQL,
351
+ // and the count-based variant serializes one slow COUNT per reference entity,
352
+ // stalling the queue so later entities (e.g. Project) never batch and fall
353
+ // back to per-row fetches. NoCount fetches each batch in one paged request
354
+ // with real concurrency (maxOperations).
355
+ this.pict.EntityProvider.cacheConnectedEntityRecordsWithoutCount(recordsReturn, IDFields, ['User', 'User'], false, () =>
266
356
  {
267
357
  resolve({ Records: recordsReturn, Facets: { } });
268
358
  });
@@ -371,6 +461,11 @@ class MeadowEndpointsRecordSetProvider extends libRecordSetProviderBase
371
461
  }
372
462
  // A new record changes the total; drop the cached count so the next render re-counts.
373
463
  this._RecordSetCountCache = null;
464
+ // Drop this list's scoped cache too, so the next render re-fetches fresh.
465
+ if (typeof this.pict.EntityProvider.clearScope === 'function')
466
+ {
467
+ this.pict.EntityProvider.clearScope(`RSList::${ this.options.RecordSet || this.options.Entity }`);
468
+ }
374
469
  resolve(result);
375
470
  });
376
471
  });
@@ -400,6 +495,11 @@ class MeadowEndpointsRecordSetProvider extends libRecordSetProviderBase
400
495
  }
401
496
  // An edit can move a record in or out of the active filter; drop the cached count to be safe.
402
497
  this._RecordSetCountCache = null;
498
+ // Drop this list's scoped cache too, so the next render re-fetches fresh.
499
+ if (typeof this.pict.EntityProvider.clearScope === 'function')
500
+ {
501
+ this.pict.EntityProvider.clearScope(`RSList::${ this.options.RecordSet || this.options.Entity }`);
502
+ }
403
503
  resolve(result);
404
504
  });
405
505
  });
@@ -429,6 +529,11 @@ class MeadowEndpointsRecordSetProvider extends libRecordSetProviderBase
429
529
  }
430
530
  // A delete changes the total; drop the cached count so the next render re-counts.
431
531
  this._RecordSetCountCache = null;
532
+ // Drop this list's scoped cache too, so the next render re-fetches fresh.
533
+ if (typeof this.pict.EntityProvider.clearScope === 'function')
534
+ {
535
+ this.pict.EntityProvider.clearScope(`RSList::${ this.options.RecordSet || this.options.Entity }`);
536
+ }
432
537
  resolve(result);
433
538
  });
434
539
  });
@@ -535,11 +640,19 @@ class MeadowEndpointsRecordSetProvider extends libRecordSetProviderBase
535
640
  const tmpFieldHumanName = this.getHumanReadableFieldName(pSchemaField);
536
641
  const isUserAuditField = ['CreatingIDUser', 'DeletingIDUser', 'UpdatingIDUser'].includes(pSchemaField);
537
642
  const customFilterClauses = this.options.Filters?.[pSchemaField];
538
- if (pSchemaField.startsWith('ID') || pSchemaField.startsWith('ParentID') || isUserAuditField || customFilterClauses)
643
+ // The entity's own identity column (AutoIdentity / AutoGUID) i.e. the primary key.
644
+ const isOwnIdentityField = pMeadowSchemaField && (pMeadowSchemaField.Type === 'AutoIdentity' || pMeadowSchemaField.Type === 'AutoGUID');
645
+ const isForeignKeyLike = pSchemaField.startsWith('ID') || pSchemaField.startsWith('ParentID') || isUserAuditField || customFilterClauses;
646
+ if (isForeignKeyLike)
539
647
  {
540
648
  for (const customField of Array.isArray(customFilterClauses) ? customFilterClauses : [customFilterClauses])
541
649
  {
542
- const remoteTableName = customField?.RemoteTable || pSchemaField.split('ID')[1];
650
+ // The table the picker pulls from: an explicit RemoteTable, else this
651
+ // recordset's declared Entity when the column is our own primary key (the PK
652
+ // references our own records), else the name peeled from the column for a
653
+ // plain foreign key. Peeling alone is the defect — a lake PK like
654
+ // `IDC182_HMA_MixDesign` peels to a table name that is not the entity name.
655
+ const remoteTableName = customField?.RemoteTable || (isOwnIdentityField ? this.options.Entity : pSchemaField.split('ID')[1]);
543
656
  const fieldName = this.getHumanReadableFieldName(pSchemaField);
544
657
  tmpFieldFilterClauses.push(Object.assign(
545
658
  {
@@ -552,6 +665,7 @@ class MeadowEndpointsRecordSetProvider extends libRecordSetProviderBase
552
665
  "EntityListEntryTemplate": this.getEntityListEntryTemplate(remoteTableName),
553
666
  "CoreConnectionColumn": pSchemaField,
554
667
  "RemoteTable": `${ remoteTableName }`,
668
+ "URLPrefix": this.options.URLPrefix,
555
669
  "JoinExternalConnectionColumn": `ID${ remoteTableName }`,
556
670
  "JoinInternalConnectionColumn": pSchemaField,
557
671
  'DisplayName': `Selected Records`,
@@ -561,7 +675,10 @@ class MeadowEndpointsRecordSetProvider extends libRecordSetProviderBase
561
675
  }, customField));
562
676
  }
563
677
  }
564
- else
678
+ // The primary key is also a plain integer key, so it ALSO falls through to the
679
+ // Exact / In Range clauses below. A real foreign key gets only the picker; ordinary
680
+ // columns are matched by type.
681
+ if (!isForeignKeyLike || isOwnIdentityField)
565
682
  {
566
683
  switch (tmpFieldType)
567
684
  {
@@ -233,6 +233,7 @@ class ViewRecordSetSUBSETFilterEntityReferenceBase extends ViewRecordSetSUBSETFi
233
233
  Destination: pOffset > 0 ? `${this.getInformaryAddressPrefix()}${pClauseInformaryAddress}.SearchResultsAppend` : `${this.getInformaryAddressPrefix()}${pClauseInformaryAddress}.SearchResults`,
234
234
  RecordStartCursor: pOffset,
235
235
  PageSize: this.options.PageSize,
236
+ URLPrefix: tmpClause.URLPrefix,
236
237
  }
237
238
  ],
238
239
  () =>
@@ -31,7 +31,12 @@ const _DEFAULT_CONFIGURATION__List = (
31
31
  AutoSolveWithApp: false,
32
32
  AutoSolveOrdinal: 0,
33
33
 
34
- CSS: false,
34
+ 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; }
37
+ .prsp-list-spinner { display: inline-flex; animation: prsp-list-spin 0.9s linear infinite; }
38
+ @keyframes prsp-list-spin { from { transform: rotate(0deg); } to { transform: rotate(360deg); } }
39
+ `,
35
40
  CSSPriority: 500,
36
41
 
37
42
  Templates:
@@ -57,6 +62,17 @@ const _DEFAULT_CONFIGURATION__List = (
57
62
  Hash: 'PRSP-List-Template-Record',
58
63
  Template: /*html*/`
59
64
  <!-- DefaultPackage end view template: [PRSP-List-Template] -->
65
+ `
66
+ },
67
+ {
68
+ Hash: 'PRSP-List-LoadingShell',
69
+ Template: /*html*/`
70
+ <section id="PRSP_List_Loading" class="prsp-list-loading">
71
+ <div class="prsp-list-loading-inner">
72
+ <span class="prsp-list-spinner" aria-hidden="true">{~I:Refresh~}</span>
73
+ <span class="prsp-list-loading-label">Loading…</span>
74
+ </div>
75
+ </section>
60
76
  `
61
77
  }
62
78
  ],
@@ -138,6 +154,36 @@ class viewRecordSetList extends libPictRecordSetRecordView
138
154
  return pRecordListData;
139
155
  }
140
156
 
157
+ /**
158
+ * Paint a loading shell into the list destination synchronously, before the data
159
+ * fetch, so the previous page doesn't sit silently while a slow query runs. The
160
+ * real list render (RenderMethod 'replace' into the same destination) overwrites
161
+ * it when data arrives. Opt out with RecordSetListShowLoadingShell:false.
162
+ * @param {Record<string, any>} pRecordListData
163
+ */
164
+ _projectLoadingShell(pRecordListData)
165
+ {
166
+ try
167
+ {
168
+ const tmpConfig = pRecordListData && pRecordListData.RecordSetConfiguration;
169
+ if (tmpConfig && tmpConfig.RecordSetListShowLoadingShell === false)
170
+ {
171
+ return;
172
+ }
173
+ if (!pRecordListData || !pRecordListData.RenderDestination)
174
+ {
175
+ return;
176
+ }
177
+ this.pict.CSSMap.injectCSS();
178
+ this.pict.ContentAssignment.assignContent(pRecordListData.RenderDestination, this.pict.parseTemplateByHash('PRSP-List-LoadingShell', pRecordListData));
179
+ }
180
+ catch (pError)
181
+ {
182
+ // The loading shell is purely cosmetic; never let it break the list render.
183
+ this.log.warn(`RecordSetList: loading shell render failed: ${ pError && pError.message }`);
184
+ }
185
+ }
186
+
141
187
  dynamicallyGenerateColumns(pRecordListData)
142
188
  {
143
189
  pRecordListData.TableCells = [];
@@ -240,6 +286,9 @@ class viewRecordSetList extends libPictRecordSetRecordView
240
286
  };
241
287
 
242
288
  // TODO: There are still problems with the way these have nested data. Discuss how we might move that around
289
+ // Paint a loading shell before the (potentially slow) fetch so the prior page
290
+ // doesn't sit silently; the real list render replaces it when data arrives.
291
+ this._projectLoadingShell(tmpRecordListData);
243
292
  // Fetch the records
244
293
  const [ tmpRecords, tmpTotalRecordCount, tmpRecordSchema ] = await Promise.all([
245
294
  this.pict.providers[pProviderHash].getRecords(tmpRecordListData),
@@ -504,6 +553,9 @@ class viewRecordSetList extends libPictRecordSetRecordView
504
553
  };
505
554
 
506
555
  // TODO: There are still problems with the way these have nested data. Discuss how we might move that around
556
+ // Paint a loading shell before the (potentially slow) fetch so the prior page
557
+ // doesn't sit silently; the real list render replaces it when data arrives.
558
+ this._projectLoadingShell(tmpRecordListData);
507
559
  // Fetch the records
508
560
  tmpRecordListData.Records = await this.pict.providers[pProviderHash].getDecoratedRecords(tmpRecordListData);
509
561
  // Get the total record count