pict-section-recordset 1.9.3 → 1.9.5

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.3",
3
+ "version": "1.9.5",
4
4
  "description": "Pict dynamic record set management views",
5
5
  "main": "source/Pict-Section-RecordSet.js",
6
6
  "files": [
@@ -66,6 +66,40 @@ class MeadowEndpointsRecordSetProvider extends libRecordSetProviderBase
66
66
  return this._EntityProvider;
67
67
  }
68
68
 
69
+ /**
70
+ * Fetch (and cache) the DISTINCT values of a column present in this recordset's data, via
71
+ * Meadow's `<Entity>s/Distinct/<Column>` endpoint. Drives the `ScopeToRecordSet` filter knob:
72
+ * an entity picker can be limited to `FBL~<Column>~INN~<these values>` so it only lists the
73
+ * entities the data actually references, not the whole remote table. Cached per column.
74
+ *
75
+ * @param {string} pColumn @param {(pError: Error|null, pValues: Array<any>) => void} fCallback
76
+ */
77
+ getRecordSetColumnDistinct(pColumn, fCallback)
78
+ {
79
+ this._scopeDistinctCache = this._scopeDistinctCache || {};
80
+ if (Array.isArray(this._scopeDistinctCache[pColumn]))
81
+ {
82
+ return fCallback(null, this._scopeDistinctCache[pColumn]);
83
+ }
84
+ if (!this.options.Entity || !this.entityProvider || !this.entityProvider.restClient)
85
+ {
86
+ return fCallback(new Error('RecordSet provider cannot resolve a distinct request (missing Entity or rest client).'), []);
87
+ }
88
+ const tmpURL = `${this.options.URLPrefix || ''}${this.options.Entity}s/Distinct/${pColumn}`;
89
+ this.entityProvider.restClient.getJSON(tmpURL, (pError, pResponse, pBody) =>
90
+ {
91
+ if (pError || (pResponse && pResponse.statusCode > 299) || !Array.isArray(pBody))
92
+ {
93
+ this.pict.log.warn(`RecordSet [${this.options.RecordSet || this.options.Entity}] distinct fetch for [${pColumn}] failed; the scoped filter falls back to unscoped.`, { Error: pError && pError.message, URL: tmpURL });
94
+ this._scopeDistinctCache[pColumn] = [];
95
+ return fCallback(pError || new Error('distinct fetch returned a non-array'), []);
96
+ }
97
+ const tmpValues = [ ...new Set(pBody.map((pRecord) => pRecord && pRecord[pColumn]).filter((pValue) => pValue != null)) ];
98
+ this._scopeDistinctCache[pColumn] = tmpValues;
99
+ return fCallback(null, tmpValues);
100
+ });
101
+ }
102
+
69
103
  /**
70
104
  * @return {Array<string>} - The fields to ignore for filter availability.
71
105
  */
@@ -674,6 +674,23 @@ class ViewRecordSetSUBSETFilters extends libPictView
674
674
  const tmpDescriptor = tmpProvider.getFilterClauseSchemaForKey(pMount.Field)?.AvailableClauses?.find?.((pClause) => pClause.ClauseKey === pMount.ClauseKey);
675
675
  if (!tmpDescriptor || !tmpDescriptor.RemoteTable) { return; }
676
676
  const tmpSearchFields = Array.isArray(tmpDescriptor.ExternalFilterByColumns) && tmpDescriptor.ExternalFilterByColumns.length > 0 ? tmpDescriptor.ExternalFilterByColumns : [ 'Name' ];
677
+ // ScopeToRecordSet knob: limit the picker to the values present in this recordset's data
678
+ // (FBL~<col>~INN~<distinct>) so it doesn't list the whole remote table. Pre-fetch the
679
+ // distinct set, then re-mount once it resolves so the first open is already scoped.
680
+ let tmpScopeBaseFilter = undefined;
681
+ if (tmpDescriptor.ScopeToRecordSet && tmpDescriptor.FilterByColumn && typeof tmpProvider.getRecordSetColumnDistinct === 'function')
682
+ {
683
+ const tmpScopeColumn = tmpDescriptor.FilterByColumn;
684
+ if (!tmpProvider._scopeDistinctCache || !Array.isArray(tmpProvider._scopeDistinctCache[tmpScopeColumn]))
685
+ {
686
+ tmpProvider.getRecordSetColumnDistinct(tmpScopeColumn, () => this._mountQuickFilterEntity(pRecordSet, pViewContext, pMount));
687
+ }
688
+ tmpScopeBaseFilter = () =>
689
+ {
690
+ const tmpVals = (tmpProvider._scopeDistinctCache || {})[tmpScopeColumn];
691
+ return (Array.isArray(tmpVals) && tmpVals.length > 0) ? `FBL~${tmpScopeColumn}~INN~${tmpVals.join(',')}` : '';
692
+ };
693
+ }
677
694
  const tmpView = tmpPickerProvider.createEntityPicker(`Quick-Picker-${pRecordSet}-${pMount.Field}`,
678
695
  {
679
696
  DestinationAddress: `#${pMount.HostID}`,
@@ -688,6 +705,7 @@ class ViewRecordSetSUBSETFilters extends libPictView
688
705
  TextTemplate: tmpDescriptor.EntityListEntryTemplate || undefined,
689
706
  Placeholder: `Select ${pMount.Label}…`,
690
707
  OnChange: (pValue) => this.applyQuickFilterEntity(pRecordSet, pViewContext, pMount.Field, pMount.ClauseKey, pValue),
708
+ BaseFilter: tmpScopeBaseFilter,
691
709
  });
692
710
  if (!tmpView) { return; }
693
711
  tmpView.render();
@@ -709,12 +727,18 @@ class ViewRecordSetSUBSETFilters extends libPictView
709
727
  {
710
728
  tmpProvider.upsertQuickFilterClauseValue(pField, pClauseKey, (pValue === undefined || pValue === null) ? '' : String(pValue).trim());
711
729
  }
712
- this.handleSearch(null, pRecordSet, pViewContext);
730
+ // Stage the clause into the active filter state but DON'T fetch — the
731
+ // user explicitly clicks Apply / Search to commit. Avoids two URL fetches
732
+ // (one stale, one not) when adjacent inputs change in quick succession
733
+ // (e.g. the From / To dates of a DateRange both fire `change` events
734
+ // within ~50ms — the GE-only fetch finishes after the GE+LE fetch and
735
+ // overwrites the recordset state with stale data).
713
736
  }
714
737
 
715
738
  /**
716
- * Apply a date-range quick filter: set one bound (`start`/`end`) of the field's DateRange clause
717
- * (removed when both bounds clear), then run the search.
739
+ * Stage one bound (`start`/`end`) of a field's DateRange quick-filter clause.
740
+ * Doesn't fire the search that waits for the user to click Apply / Search,
741
+ * so the From and To inputs change once each without racing the fetch.
718
742
  *
719
743
  * @param {string} pRecordSet @param {string} pViewContext @param {string} pField @param {string} pClauseKey @param {'start'|'end'} pWhich @param {string} pValue
720
744
  */
@@ -726,12 +750,11 @@ class ViewRecordSetSUBSETFilters extends libPictView
726
750
  {
727
751
  tmpProvider.upsertQuickFilterDateRange(pField, pClauseKey, pWhich, (pValue === undefined || pValue === null) ? '' : pValue);
728
752
  }
729
- this.handleSearch(null, pRecordSet, pViewContext);
730
753
  }
731
754
 
732
755
  /**
733
- * Apply an entity quick filter: set the field's entity clause to the picked value (removed when
734
- * cleared), then run the search. Called from the quick-bar picker's OnChange.
756
+ * Stage a field's entity quick-filter selection. Doesn't fire the search
757
+ * commit happens on Apply / Search.
735
758
  *
736
759
  * @param {string} pRecordSet @param {string} pViewContext @param {string} pField @param {string} pClauseKey @param {any} pValue
737
760
  */
@@ -743,7 +766,6 @@ class ViewRecordSetSUBSETFilters extends libPictView
743
766
  {
744
767
  tmpProvider.upsertQuickFilterEntity(pField, pClauseKey, pValue);
745
768
  }
746
- this.handleSearch(null, pRecordSet, pViewContext);
747
769
  }
748
770
 
749
771
  /**
@@ -127,6 +127,25 @@ class ViewRecordSetSUBSETFilterEntityReferenceBase extends ViewRecordSetSUBSETFi
127
127
  */
128
128
  getContextScopeFilter(pClause)
129
129
  {
130
+ // ScopeToRecordSet knob: scope the entity search to the values present in the recordset's
131
+ // data (FBL~<col>~INN~<distinct>), fetched + cached on the recordset provider. The first
132
+ // search may be unscoped until the fetch resolves; subsequent searches are scoped.
133
+ if (pClause && pClause.ScopeToRecordSet && pClause.FilterByColumn)
134
+ {
135
+ const tmpProvider = this.pict.providers['RSP-Provider-' + pClause.RecordSet];
136
+ if (tmpProvider && typeof tmpProvider.getRecordSetColumnDistinct === 'function')
137
+ {
138
+ if (!tmpProvider._scopeDistinctCache || !Array.isArray(tmpProvider._scopeDistinctCache[pClause.FilterByColumn]))
139
+ {
140
+ tmpProvider.getRecordSetColumnDistinct(pClause.FilterByColumn, () => {});
141
+ }
142
+ const tmpVals = (tmpProvider._scopeDistinctCache || {})[pClause.FilterByColumn];
143
+ if (Array.isArray(tmpVals) && tmpVals.length > 0)
144
+ {
145
+ return `FBL~${pClause.FilterByColumn}~INN~${tmpVals.join(',')}`;
146
+ }
147
+ }
148
+ }
130
149
  return pClause.ContextScopeFilter || '';
131
150
  }
132
151
 
@@ -51,9 +51,9 @@ const _DEFAULT_CONFIGURATION__List = (
51
51
  <section id="PRSP_Filters_Container">
52
52
  {~FV:PRSP-Filters:List~}
53
53
  </section>
54
- {~V:PRSP-List-PaginationTop~}
55
- {~V:PRSP-List-RecordList~}
56
- {~V:PRSP-List-PaginationBottom~}
54
+ <div id="PRSP_PaginationTop_Container">{~V:PRSP-List-PaginationTop~}</div>
55
+ <div id="PRSP_RecordList_Container">{~V:PRSP-List-RecordList~}</div>
56
+ <div id="PRSP_PaginationBottom_Container">{~V:PRSP-List-PaginationBottom~}</div>
57
57
  </section>
58
58
  <!-- DefaultPackage end view template: [PRSP-List-Template] -->
59
59
  `
@@ -106,6 +106,11 @@ class viewRecordSetList extends libPictRecordSetRecordView
106
106
  recordListEntry: null,
107
107
  paginationBottom: null
108
108
  };
109
+
110
+ // Identity (`RecordSet::FilterString::FilterExperience`) of the list currently painted into the DOM.
111
+ // When a route only changes the page (Offset/PageSize) and this still matches, we re-render just the
112
+ // rows + pagination instead of the whole view — see handleRecordSetListRoute / _paintRecordList.
113
+ this._renderedListIdentity = null;
109
114
  }
110
115
 
111
116
  handleRecordSetListRoute(pRoutePayload)
@@ -131,7 +136,17 @@ class viewRecordSetList extends libPictRecordSetRecordView
131
136
  const tmpOffset = pRoutePayload.data.Offset ? pRoutePayload.data.Offset : 0;
132
137
  const tmpPageSize = pRoutePayload.data.PageSize ? pRoutePayload.data.PageSize : 100;
133
138
 
134
- return this.renderList(tmpProviderConfiguration, tmpProviderHash, tmpFilterString, tmpFilterExperience, tmpOffset, tmpPageSize);
139
+ // Surgical page render: when only the page changed (same RecordSet, FilterString, and
140
+ // FilterExperience), re-render just the rows + pagination rather than the whole list view. A full
141
+ // re-render rebuilds the filter view — including all of its picker/control state — which is by far
142
+ // the most expensive part of a list paint and entirely wasted when only paging. Requires the list to
143
+ // already be in the DOM (its rows container exists); otherwise we fall through to a full render.
144
+ const tmpListIdentity = `${pRoutePayload.data.RecordSet}::${tmpFilterString}::${tmpFilterExperience}`;
145
+ const tmpRenderBodyOnly = (this._renderedListIdentity === tmpListIdentity)
146
+ && (this.pict.ContentAssignment.getElement('#PRSP_RecordList_Container').length > 0);
147
+ this._renderedListIdentity = tmpListIdentity;
148
+
149
+ return this.renderList(tmpProviderConfiguration, tmpProviderHash, tmpFilterString, tmpFilterExperience, tmpOffset, tmpPageSize, tmpRenderBodyOnly);
135
150
  }
136
151
 
137
152
  addRoutes(pPictRouter)
@@ -174,8 +189,13 @@ class viewRecordSetList extends libPictRecordSetRecordView
174
189
  {
175
190
  return;
176
191
  }
192
+ // When the list is already on screen, scope the spinner to just the rows area so the title,
193
+ // filters, and pagination stay put (and the expensive filter view isn't disturbed). On the first
194
+ // 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;
196
+ const tmpDestination = tmpRowsContainerPresent ? '#PRSP_RecordList_Container' : pRecordListData.RenderDestination;
177
197
  this.pict.CSSMap.injectCSS();
178
- this.pict.ContentAssignment.assignContent(pRecordListData.RenderDestination, this.pict.parseTemplateByHash('PRSP-List-LoadingShell', pRecordListData));
198
+ this.pict.ContentAssignment.assignContent(tmpDestination, this.pict.parseTemplateByHash('PRSP-List-LoadingShell', pRecordListData));
179
199
  }
180
200
  catch (pError)
181
201
  {
@@ -230,10 +250,11 @@ class viewRecordSetList extends libPictRecordSetRecordView
230
250
  * @param {string} pSerializedFilterExperience
231
251
  * @param {number} pOffset
232
252
  * @param {number} pPageSize
253
+ * @param {boolean} [pBodyOnly] - When true, re-render only the rows + pagination (page change), leaving the filter view intact.
233
254
  *
234
255
  * @return {Promise<void>}
235
256
  */
236
- async renderList(pRecordSetConfiguration, pProviderHash, pFilterString, pSerializedFilterExperience, pOffset, pPageSize)
257
+ async renderList(pRecordSetConfiguration, pProviderHash, pFilterString, pSerializedFilterExperience, pOffset, pPageSize, pBodyOnly)
237
258
  {
238
259
  // Get the records
239
260
  if (!(pProviderHash in this.pict.providers))
@@ -252,7 +273,7 @@ class viewRecordSetList extends libPictRecordSetRecordView
252
273
  }
253
274
  else
254
275
  {
255
- return this.renderListFromManifest(tmpManifest, pRecordSetConfiguration, pProviderHash, pFilterString, pSerializedFilterExperience, pOffset, pPageSize);
276
+ return this.renderListFromManifest(tmpManifest, pRecordSetConfiguration, pProviderHash, pFilterString, pSerializedFilterExperience, pOffset, pPageSize, pBodyOnly);
256
277
  }
257
278
  }
258
279
 
@@ -471,25 +492,7 @@ class viewRecordSetList extends libPictRecordSetRecordView
471
492
  }
472
493
  tmpRecordListData = this.onBeforeRenderList(tmpRecordListData);
473
494
 
474
- this.renderAsync('PRSP_Renderable_List', tmpRecordListData.RenderDestination, tmpRecordListData,
475
- function (pError)
476
- {
477
- if (pError)
478
- {
479
- this.pict.log.error(`RecordSetList: Error rendering list ${pError}`, tmpRecordListData);
480
- return false;
481
- }
482
-
483
- if (this.pict.LogNoisiness > 0)
484
- {
485
- this.pict.log.info(`RecordSetList: Rendered list ${tmpRecordListData.RecordSet} with ${tmpRecordListData.Records.Records.length} records.`, tmpRecordListData);
486
- }
487
- else
488
- {
489
- this.pict.log.info(`RecordSetList: Rendered list ${tmpRecordListData.RecordSet} with ${tmpRecordListData.Records.Records.length} records.`);
490
- }
491
- return true;
492
- }.bind(this));
495
+ this._paintRecordList(tmpRecordListData, pBodyOnly);
493
496
  }
494
497
 
495
498
  /**
@@ -500,10 +503,11 @@ class viewRecordSetList extends libPictRecordSetRecordView
500
503
  * @param {string} pSerializedFilterExperience
501
504
  * @param {number} pOffset
502
505
  * @param {number} pPageSize
506
+ * @param {boolean} [pBodyOnly] - When true, re-render only the rows + pagination (page change), leaving the filter view intact.
503
507
  *
504
508
  * @return {Promise<void>}
505
509
  */
506
- async renderListFromManifest(pManifest, pRecordSetConfiguration, pProviderHash, pFilterString, pSerializedFilterExperience, pOffset, pPageSize)
510
+ async renderListFromManifest(pManifest, pRecordSetConfiguration, pProviderHash, pFilterString, pSerializedFilterExperience, pOffset, pPageSize, pBodyOnly)
507
511
  {
508
512
  if (!pRecordSetConfiguration)
509
513
  {
@@ -738,24 +742,54 @@ class viewRecordSetList extends libPictRecordSetRecordView
738
742
 
739
743
  this.pict.providers.DynamicRecordsetSolver.solveDashboard(pManifest, tmpRecordListData.Records.Records);
740
744
 
741
- this.renderAsync('PRSP_Renderable_List', tmpRecordListData.RenderDestination, tmpRecordListData,
742
- function (pError)
745
+ this._paintRecordList(tmpRecordListData, pBodyOnly);
746
+ }
747
+
748
+ /**
749
+ * Paint the computed record-list data into the DOM.
750
+ *
751
+ * Full render (pBodyOnly falsy): render the whole `PRSP_Renderable_List` (title, header, filters,
752
+ * pagination, rows) into the list destination — the original behavior.
753
+ *
754
+ * Body-only render (pBodyOnly true): only the page changed, so re-render just the rows and the two
755
+ * pagination strips into their stable containers, leaving the filter view (and its picker/control state)
756
+ * completely untouched. Each child is rendered with the freshly-computed record passed as an object, so
757
+ * it produces exactly what the inline `{~V:~}` render would have.
758
+ *
759
+ * @param {Record<string, any>} pRecordListData - The fully-computed list data (records, pagination, cells).
760
+ * @param {boolean} [pBodyOnly] - When true, surgically re-render only rows + pagination.
761
+ * @return {void}
762
+ */
763
+ _paintRecordList(pRecordListData, pBodyOnly)
764
+ {
765
+ const fLogRendered = function (pError)
766
+ {
767
+ if (pError)
743
768
  {
744
- if (pError)
745
- {
746
- this.pict.log.error(`RecordSetList: Error rendering list ${pError}`, tmpRecordListData);
747
- return;
748
- }
769
+ this.pict.log.error(`RecordSetList: Error rendering list ${pError}`, pRecordListData);
770
+ return;
771
+ }
772
+ if (this.pict.LogNoisiness > 0)
773
+ {
774
+ this.pict.log.info(`RecordSetList: Rendered list ${pRecordListData.RecordSet} with ${pRecordListData.Records.Records.length} records.`, pRecordListData);
775
+ }
776
+ else
777
+ {
778
+ this.pict.log.info(`RecordSetList: Rendered list ${pRecordListData.RecordSet} with ${pRecordListData.Records.Records.length} records.`);
779
+ }
780
+ }.bind(this);
749
781
 
750
- if (this.pict.LogNoisiness > 0)
751
- {
752
- this.pict.log.info(`RecordSetList: Rendered list ${tmpRecordListData.RecordSet} with ${tmpRecordListData.Records.Records.length} records.`, tmpRecordListData);
753
- }
754
- else
755
- {
756
- this.pict.log.info(`RecordSetList: Rendered list ${tmpRecordListData.RecordSet} with ${tmpRecordListData.Records.Records.length} records.`);
757
- }
758
- }.bind(this));
782
+ if (!pBodyOnly)
783
+ {
784
+ this.renderAsync('PRSP_Renderable_List', pRecordListData.RenderDestination, pRecordListData, fLogRendered);
785
+ return;
786
+ }
787
+
788
+ // Page-only change: re-render the two pagination strips (current page + "showing X of Y") and the
789
+ // rows, each into its own stable container. The filter view, title, and header list are left as-is.
790
+ this.childViews.paginationTop.renderAsync('PRSP_Renderable_PaginationTop', '#PRSP_PaginationTop_Container', pRecordListData, null, () => { });
791
+ 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);
759
793
  }
760
794
 
761
795
  onInitialize()