pict-section-recordset 1.22.0 → 1.23.1

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.22.0",
3
+ "version": "1.23.1",
4
4
  "description": "Pict dynamic record set management views",
5
5
  "main": "source/Pict-Section-RecordSet.js",
6
6
  "files": [
@@ -37,10 +37,10 @@
37
37
  "browser-env": "^3.3.0",
38
38
  "eslint": "^9.28.0",
39
39
  "jquery": "^3.7.1",
40
- "pict": "^1.0.384",
40
+ "pict": "^1.0.389",
41
41
  "pict-application": "^1.0.34",
42
42
  "pict-docuserve": "^1.4.19",
43
- "pict-service-commandlineutility": "^1.0.19",
43
+ "pict-service-commandlineutility": "^1.0.20",
44
44
  "quackage": "^1.3.0",
45
45
  "typescript": "^5.9.3"
46
46
  },
@@ -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.1.9",
51
+ "pict-section-form": "^1.3.2",
52
52
  "pict-template": "^1.0.15",
53
53
  "pict-view": "^1.0.68",
54
54
  "sinon": "^21.0.1"
@@ -12,6 +12,7 @@ module.exports.RecordSetProviderBase = require('./providers/RecordSet-RecordProv
12
12
  module.exports.RecordSetProviderMeadowEndpoints = require('./providers/RecordSet-RecordProvider-MeadowEndpoints.js');
13
13
  module.exports.ColumnDataProvider = require('./providers/Column-Data-Provider.js');
14
14
  module.exports.AssociationManager = require('./providers/RecordSet-AssociationManager.js');
15
+ module.exports.CardManager = require('./providers/RecordSet-CardManager.js');
15
16
 
16
17
  // Joined-entity association views (embeddable read-tab editor + bulk associate screen)
17
18
  module.exports.AssociationEditorView = require('./views/associate/RecordSet-AssociationEditor.js');
@@ -0,0 +1,345 @@
1
+ const libPictProvider = require('pict-provider');
2
+
3
+ const _DEFAULT_CONFIGURATION =
4
+ {
5
+ ProviderIdentifier: 'RecordSetCardManager',
6
+
7
+ AutoInitialize: true,
8
+ AutoInitializeOrdinal: 0,
9
+
10
+ CSS: /*css*/`
11
+ .psrs-card-trigger { display: inline-flex; align-items: center; gap: 0.25rem; cursor: pointer; }
12
+ .psrs-card-trigger-label { text-decoration: underline; text-decoration-style: dotted; text-underline-offset: 0.15em; }
13
+ .psrs-card-trigger:hover .psrs-card-trigger-label { text-decoration-style: solid; color: var(--theme-color-brand-primary, #156dd1); }
14
+ .psrs-card-trigger-icon { display: inline-flex; font-size: 0.82em; color: var(--theme-color-text-muted, #6b7686); }
15
+ .psrs-card-trigger:hover .psrs-card-trigger-icon { color: var(--theme-color-brand-primary, #156dd1); }
16
+
17
+ .psrs-card-popover { position: absolute; z-index: 4000; min-width: 16rem; max-width: 22rem;
18
+ background: var(--theme-color-background-panel, #fff); border: 1px solid var(--theme-color-border-default, #d7dce3);
19
+ border-radius: 12px; box-shadow: 0 16px 40px rgba(15, 23, 42, 0.20); padding: 0.95rem 1.05rem;
20
+ opacity: 0; transform: translateY(-4px); transition: opacity 0.12s ease, transform 0.12s ease; pointer-events: none; }
21
+ .psrs-card-popover.is-open { opacity: 1; transform: translateY(0); pointer-events: auto; }
22
+ .psrs-card-head { display: flex; align-items: flex-start; gap: 0.7rem; margin: 0 0 0.6rem; }
23
+ .psrs-card-img { flex: 0 0 auto; width: 2.6rem; height: 2.6rem; border-radius: 8px; overflow: hidden;
24
+ background: var(--theme-color-background-tertiary, #f1f3f6); display: flex; align-items: center; justify-content: center; }
25
+ .psrs-card-img img { width: 100%; height: 100%; object-fit: cover; }
26
+ .psrs-card-title { font-size: 1.02rem; font-weight: 700; line-height: 1.2; color: var(--theme-color-text-primary, #1f2733); }
27
+ .psrs-card-subtitle { font-size: 0.82rem; color: var(--theme-color-text-muted, #6b7686); margin-top: 0.1rem; }
28
+ .psrs-card-fields { display: grid; grid-template-columns: auto 1fr; gap: 0.3rem 0.85rem; align-items: baseline; margin: 0.2rem 0 0; }
29
+ .psrs-card-label { font-size: 0.68rem; font-weight: 600; text-transform: uppercase; letter-spacing: 0.04em; color: var(--theme-color-text-muted, #6b7686); white-space: nowrap; }
30
+ .psrs-card-value { font-size: 0.9rem; color: var(--theme-color-text-primary, #1f2733); word-break: break-word; }
31
+ .psrs-card-custom { font-size: 0.9rem; color: var(--theme-color-text-primary, #1f2733); }
32
+ .psrs-card-actions { display: flex; flex-wrap: wrap; gap: 0.4rem; margin-top: 0.85rem; padding-top: 0.7rem; border-top: 1px solid var(--theme-color-border-light, #eef1f5); }
33
+ .psrs-card-action { display: inline-flex; align-items: center; gap: 0.35rem; cursor: pointer; font: inherit; font-size: 0.82rem; font-weight: 600;
34
+ text-decoration: none; padding: 0.3rem 0.7rem; border-radius: 7px; border: 1px solid var(--theme-color-border-default, #d7dce3);
35
+ background: var(--theme-color-background-secondary, #f5f6f8); color: var(--theme-color-text-secondary, #45505f); }
36
+ .psrs-card-action:hover { border-color: var(--theme-color-brand-primary, #156dd1); color: var(--theme-color-brand-primary, #156dd1); background: var(--theme-color-background-selected, #e3edfb); }
37
+ .psrs-card-action .pict-icon { font-size: 0.9em; }
38
+ `,
39
+ CSSPriority: 600,
40
+ };
41
+
42
+ /**
43
+ * RecordSetCardManager — a global registry of "record preview cards". A card is a small, meaningful
44
+ * view of one record plus links to its most important actions, defined entirely in CONFIGURATION per
45
+ * recordset (no code). Any list, record view, or dashboard can drop a trigger (`{~RecordCard:~}` tag or
46
+ * `triggerHTML()`); clicking it fetches the record (cached EntityProvider) and shows an anchored popover
47
+ * card next to the trigger.
48
+ *
49
+ * Config shape (registered via settings.RecordCards or registerCard()):
50
+ * {
51
+ * Entity: 'Author', // Meadow entity to fetch (default: the recordset name)
52
+ * Title: 'Name', // a field name OR a '{~...~}' template
53
+ * Subtitle: '{~D:Record.City~}, {~D:Record.Country~}',
54
+ * Image: 'PhotoURL', // optional image field/template
55
+ * Fields: [ { Label: 'Books', Value: 'BookCount' }, ... ], // labeled values (field OR template)
56
+ * Template: '<custom markup with {~D:Record.X~}>', // alternative to Fields (full control)
57
+ * Actions: [ { Label:'View', Icon:'Eye', Route:'#/PSRS/Author/View/{~D:Record.GUIDAuthor~}' },
58
+ * { Label:'Download', Icon:'Download', URL:'{~D:Record.FileURL~}' } ] // Route | URL | Handler
59
+ * }
60
+ */
61
+ class RecordSetCardManager extends libPictProvider
62
+ {
63
+ constructor(pFable, pOptions, pServiceHash)
64
+ {
65
+ let tmpOptions = Object.assign({}, _DEFAULT_CONFIGURATION, pOptions);
66
+ super(pFable, tmpOptions, pServiceHash);
67
+
68
+ /** @type {any} */
69
+ this.pict;
70
+
71
+ // recordset name -> normalized card config
72
+ this._cards = {};
73
+ this._popoverElementID = 'PSRS-RecordCard-Popover';
74
+ this._dismissBound = false;
75
+ this._openForKey = null;
76
+
77
+ if (this.pict && this.pict.CSSMap && (typeof this.pict.CSSMap.addCSS === 'function'))
78
+ {
79
+ this.pict.CSSMap.addCSS('PSRS-RecordCard-CSS', this.options.CSS, this.options.CSSPriority);
80
+ }
81
+ }
82
+
83
+ /**
84
+ * Register a card layout for a recordset. Pre-compiles the config into a pict template so render is a
85
+ * single parseTemplateByHash against the fetched record.
86
+ * @param {String} pRecordSetName
87
+ * @param {Object} pCardConfig
88
+ */
89
+ registerCard(pRecordSetName, pCardConfig)
90
+ {
91
+ if (!pRecordSetName || !pCardConfig || (typeof pCardConfig !== 'object'))
92
+ {
93
+ this.log.warn(`RecordSetCardManager: ignoring invalid card config for [${pRecordSetName}].`);
94
+ return;
95
+ }
96
+ const tmpConfig =
97
+ {
98
+ Entity: pCardConfig.Entity || pRecordSetName,
99
+ IDField: pCardConfig.IDField || `ID${pRecordSetName}`,
100
+ TemplateHash: `PSRS-RecordCard-${pRecordSetName}`,
101
+ Config: pCardConfig,
102
+ };
103
+ this._cards[pRecordSetName] = tmpConfig;
104
+ this.pict.TemplateProvider.addTemplate(tmpConfig.TemplateHash, this._buildCardTemplate(pCardConfig));
105
+ }
106
+
107
+ hasCard(pRecordSetName)
108
+ {
109
+ return !!this._cards[pRecordSetName];
110
+ }
111
+
112
+ /** A config value is a field name unless it already contains a template expression. */
113
+ _valueTemplate(pValue)
114
+ {
115
+ if (pValue == null) { return ''; }
116
+ const tmpValue = String(pValue);
117
+ return (tmpValue.indexOf('{~') !== -1) ? tmpValue : `{~D:Record.${tmpValue}~}`;
118
+ }
119
+
120
+ /** Compile a card config into a pict template string (resolved against the record at render time). */
121
+ _buildCardTemplate(pConfig)
122
+ {
123
+ let tmpHead = '<div class="psrs-card-head">';
124
+ if (pConfig.Image)
125
+ {
126
+ tmpHead += `<div class="psrs-card-img"><img src="${this._valueTemplate(pConfig.Image)}" alt="" /></div>`;
127
+ }
128
+ tmpHead += `<div><div class="psrs-card-title">${this._valueTemplate(pConfig.Title || `ID${''}`)}</div>`;
129
+ if (pConfig.Subtitle)
130
+ {
131
+ tmpHead += `<div class="psrs-card-subtitle">${this._valueTemplate(pConfig.Subtitle)}</div>`;
132
+ }
133
+ tmpHead += '</div></div>';
134
+
135
+ let tmpBody = '';
136
+ if (pConfig.Template)
137
+ {
138
+ tmpBody = `<div class="psrs-card-custom">${pConfig.Template}</div>`;
139
+ }
140
+ else if (Array.isArray(pConfig.Fields) && (pConfig.Fields.length > 0))
141
+ {
142
+ tmpBody = '<div class="psrs-card-fields">';
143
+ pConfig.Fields.forEach((pField) =>
144
+ {
145
+ tmpBody += `<span class="psrs-card-label">${pField.Label || ''}</span><span class="psrs-card-value">${this._valueTemplate(pField.Value)}</span>`;
146
+ });
147
+ tmpBody += '</div>';
148
+ }
149
+
150
+ let tmpActions = '';
151
+ if (Array.isArray(pConfig.Actions) && (pConfig.Actions.length > 0))
152
+ {
153
+ tmpActions = '<div class="psrs-card-actions">';
154
+ pConfig.Actions.forEach((pAction) =>
155
+ {
156
+ const tmpIcon = pAction.Icon ? `<span class="psrs-card-action-icon">{~I:${pAction.Icon}~}</span>` : '';
157
+ const tmpLabel = `${tmpIcon}<span>${pAction.Label || 'Open'}</span>`;
158
+ if (pAction.URL)
159
+ {
160
+ tmpActions += `<a class="psrs-card-action" href="${this._valueTemplate(pAction.URL)}" target="_blank" rel="noopener" onclick="_Pict.providers.RecordSetCardManager.closeCard()">${tmpLabel}</a>`;
161
+ }
162
+ else if (pAction.Route)
163
+ {
164
+ tmpActions += `<a class="psrs-card-action" href="${this._valueTemplate(pAction.Route)}" onclick="_Pict.providers.RecordSetCardManager.closeCard()">${tmpLabel}</a>`;
165
+ }
166
+ else if (pAction.Handler)
167
+ {
168
+ tmpActions += `<button type="button" class="psrs-card-action" onclick="_Pict.providers.RecordSetCardManager.closeCard(); ${pAction.Handler}">${tmpLabel}</button>`;
169
+ }
170
+ });
171
+ tmpActions += '</div>';
172
+ }
173
+
174
+ return `<div class="psrs-card">${tmpHead}${tmpBody}${tmpActions}</div>`;
175
+ }
176
+
177
+ // --- Trigger HTML (used by the {~RecordCard:~} template tag and by callers directly) ---
178
+
179
+ /**
180
+ * The clickable inline trigger: a label + a small preview icon. Degrades to plain text when no card is
181
+ * registered for the recordset.
182
+ * @param {String} pRecordSetName
183
+ * @param {String|Number} pID
184
+ * @param {String} pLabel
185
+ * @return {String}
186
+ */
187
+ triggerHTML(pRecordSetName, pID, pLabel)
188
+ {
189
+ const tmpLabel = (pLabel == null) ? '' : String(pLabel);
190
+ if (!this.hasCard(pRecordSetName) || pID == null || pID === '')
191
+ {
192
+ return tmpLabel;
193
+ }
194
+ const tmpIcon = (typeof this.pict.icon === 'function') ? this.pict.icon('Info', { ariaLabel: 'Preview' }) : '';
195
+ const tmpSafeID = String(pID).replace(/'/g, '');
196
+ return `<span class="psrs-card-trigger" onclick="event.stopPropagation();_Pict.providers.RecordSetCardManager.openCard('${pRecordSetName}','${tmpSafeID}',this)">`
197
+ + `<span class="psrs-card-trigger-label">${tmpLabel}</span><span class="psrs-card-trigger-icon">${tmpIcon}</span></span>`;
198
+ }
199
+
200
+ // --- Open / render / position ---
201
+
202
+ /**
203
+ * Open the preview card for a record next to an anchor element. The record is fetched through the
204
+ * cached EntityProvider unless a full record object is passed.
205
+ * @param {String} pRecordSetName
206
+ * @param {String|Number|Object} pIDOrRecord
207
+ * @param {HTMLElement} pAnchorElement
208
+ */
209
+ openCard(pRecordSetName, pIDOrRecord, pAnchorElement)
210
+ {
211
+ const tmpCard = this._cards[pRecordSetName];
212
+ if (!tmpCard) { return; }
213
+ // Toggle off if the same trigger is clicked while open.
214
+ const tmpKey = `${pRecordSetName}:${(typeof pIDOrRecord === 'object') ? '' : pIDOrRecord}`;
215
+ if (this._openForKey === tmpKey && this._isOpen())
216
+ {
217
+ this.closeCard();
218
+ return;
219
+ }
220
+
221
+ if (pIDOrRecord && (typeof pIDOrRecord === 'object'))
222
+ {
223
+ this._renderAndShow(tmpCard, pIDOrRecord, pAnchorElement, tmpKey);
224
+ return;
225
+ }
226
+ this.pict.EntityProvider.getEntity(tmpCard.Entity, pIDOrRecord, (pError, pRecord) =>
227
+ {
228
+ if (pError || !pRecord)
229
+ {
230
+ this.log.warn(`RecordSetCardManager: could not load ${tmpCard.Entity} ${pIDOrRecord} for its preview card.`, pError);
231
+ return;
232
+ }
233
+ this._renderAndShow(tmpCard, pRecord, pAnchorElement, tmpKey);
234
+ });
235
+ }
236
+
237
+ _renderAndShow(pCard, pRecord, pAnchorElement, pKey)
238
+ {
239
+ // Cards use synchronous template tags ({~D:~}, {~I:~}, formatters) so a sync parse is correct and
240
+ // avoids a flash; the record is already in hand here.
241
+ let tmpHTML = '';
242
+ try { tmpHTML = this.pict.parseTemplateByHash(pCard.TemplateHash, pRecord); }
243
+ catch (pError) { this.log.error(`RecordSetCardManager: card render failed for ${pCard.TemplateHash}.`, pError); return; }
244
+ const tmpPopover = this._ensurePopover();
245
+ if (!tmpPopover) { return; }
246
+ tmpPopover.innerHTML = tmpHTML;
247
+ // Drop any field whose value rendered empty so a card never shows a "LABEL: (blank)" row — the
248
+ // configured Fields are static, but which ones have data varies per record.
249
+ const tmpValueCells = tmpPopover.querySelectorAll('.psrs-card-fields .psrs-card-value');
250
+ for (let i = 0; i < tmpValueCells.length; i++)
251
+ {
252
+ if (String(tmpValueCells[i].textContent || '').trim() !== '') { continue; }
253
+ const tmpLabelCell = tmpValueCells[i].previousElementSibling;
254
+ if (tmpLabelCell && tmpLabelCell.classList.contains('psrs-card-label')) { tmpLabelCell.remove(); }
255
+ tmpValueCells[i].remove();
256
+ }
257
+ const tmpFieldsBox = tmpPopover.querySelector('.psrs-card-fields');
258
+ if (tmpFieldsBox && (tmpFieldsBox.children.length === 0)) { tmpFieldsBox.remove(); }
259
+ this.pict.CSSMap.injectCSS();
260
+ this._openForKey = pKey;
261
+ this._position(tmpPopover, pAnchorElement);
262
+ tmpPopover.classList.add('is-open');
263
+ this._bindDismiss();
264
+ }
265
+
266
+ _ensurePopover()
267
+ {
268
+ if (typeof document === 'undefined') { return null; }
269
+ let tmpPopover = document.getElementById(this._popoverElementID);
270
+ if (!tmpPopover)
271
+ {
272
+ tmpPopover = document.createElement('div');
273
+ tmpPopover.id = this._popoverElementID;
274
+ tmpPopover.className = 'psrs-card-popover';
275
+ document.body.appendChild(tmpPopover);
276
+ }
277
+ return tmpPopover;
278
+ }
279
+
280
+ /** Anchor the popover under the trigger, flipping above / clamping horizontally near the viewport edge. */
281
+ _position(pPopover, pAnchorElement)
282
+ {
283
+ if (!pAnchorElement || (typeof pAnchorElement.getBoundingClientRect !== 'function')) { return; }
284
+ const tmpRect = pAnchorElement.getBoundingClientRect();
285
+ // Measure with the card laid out but invisible.
286
+ pPopover.style.top = '-9999px';
287
+ pPopover.style.left = '0px';
288
+ const tmpWidth = pPopover.offsetWidth;
289
+ const tmpHeight = pPopover.offsetHeight;
290
+ const tmpScrollX = window.scrollX || window.pageXOffset || 0;
291
+ const tmpScrollY = window.scrollY || window.pageYOffset || 0;
292
+ const tmpGap = 6;
293
+
294
+ let tmpLeft = tmpRect.left + tmpScrollX;
295
+ const tmpMaxLeft = tmpScrollX + window.innerWidth - tmpWidth - 8;
296
+ if (tmpLeft > tmpMaxLeft) { tmpLeft = Math.max(tmpScrollX + 8, tmpMaxLeft); }
297
+
298
+ let tmpTop = tmpRect.bottom + tmpScrollY + tmpGap;
299
+ // Flip above when there's not enough room below.
300
+ if ((tmpRect.bottom + tmpHeight + tmpGap > window.innerHeight) && (tmpRect.top - tmpHeight - tmpGap > 0))
301
+ {
302
+ tmpTop = tmpRect.top + tmpScrollY - tmpHeight - tmpGap;
303
+ }
304
+ pPopover.style.left = `${Math.round(tmpLeft)}px`;
305
+ pPopover.style.top = `${Math.round(tmpTop)}px`;
306
+ }
307
+
308
+ _isOpen()
309
+ {
310
+ const tmpPopover = (typeof document !== 'undefined') ? document.getElementById(this._popoverElementID) : null;
311
+ return !!(tmpPopover && tmpPopover.classList.contains('is-open'));
312
+ }
313
+
314
+ closeCard()
315
+ {
316
+ const tmpPopover = (typeof document !== 'undefined') ? document.getElementById(this._popoverElementID) : null;
317
+ if (tmpPopover) { tmpPopover.classList.remove('is-open'); }
318
+ this._openForKey = null;
319
+ }
320
+
321
+ /**
322
+ * One-time document handlers that dismiss the card on an outside click or Escape. Browser-level events
323
+ * with no inline-handler equivalent — bound once, guarded so they never stack.
324
+ */
325
+ _bindDismiss()
326
+ {
327
+ if (this._dismissBound || (typeof document === 'undefined')) { return; }
328
+ this._dismissBound = true;
329
+ document.addEventListener('click', (pEvent) =>
330
+ {
331
+ if (!this._isOpen()) { return; }
332
+ const tmpPopover = document.getElementById(this._popoverElementID);
333
+ if (tmpPopover && tmpPopover.contains(pEvent.target)) { return; }
334
+ if (pEvent.target && pEvent.target.closest && pEvent.target.closest('.psrs-card-trigger')) { return; }
335
+ this.closeCard();
336
+ });
337
+ document.addEventListener('keydown', (pEvent) =>
338
+ {
339
+ if ((pEvent.key === 'Escape') && this._isOpen()) { this.closeCard(); }
340
+ });
341
+ }
342
+ }
343
+
344
+ module.exports = RecordSetCardManager;
345
+ module.exports.default_configuration = _DEFAULT_CONFIGURATION;
@@ -101,7 +101,8 @@ class MeadowEndpointsRecordSetProvider extends libRecordSetProviderBase
101
101
  return tmpCallback(new Error('RecordSet provider cannot resolve a distinct request (missing Entity or rest client).'), []);
102
102
  }
103
103
  const tmpURL = `${this.options.URLPrefix || ''}${this.options.Entity}s/Distinct/${pColumn}${tmpOptions.Filter ? `/FilteredTo/${tmpOptions.Filter}` : ''}`;
104
- this.entityProvider.restClient.getJSON(tmpURL, (pError, pResponse, pBody) =>
104
+ const tmpEntityProvider = this.entityProvider;
105
+ const fHandleDistinctResult = (pError, pResponse, pBody) =>
105
106
  {
106
107
  if (pError || (pResponse && pResponse.statusCode > 299) || !Array.isArray(pBody))
107
108
  {
@@ -112,6 +113,25 @@ class MeadowEndpointsRecordSetProvider extends libRecordSetProviderBase
112
113
  const tmpValues = [ ...new Set(pBody.map((pRecord) => pRecord && pRecord[pColumn]).filter((pValue) => pValue != null)) ];
113
114
  this._scopeDistinctCache[tmpCacheKey] = tmpValues;
114
115
  return tmpCallback(null, tmpValues);
116
+ };
117
+ // Route through POST /:Entity/Query (Distinct mode) when the endpoint
118
+ // supports it — a long /FilteredTo/ stanza on the Distinct GET is the same
119
+ // URI-length hazard the Query route exists to sidestep. Falls back to GET.
120
+ const fResolveSupport = (typeof tmpEntityProvider.resolveEntityQuerySupport === 'function')
121
+ ? tmpEntityProvider.resolveEntityQuerySupport.bind(tmpEntityProvider)
122
+ : (pEntity, pPrefix, fCb) => { return fCb(null, false); };
123
+ fResolveSupport(this.options.Entity, this.options.URLPrefix, (pSupportError, pSupportsQuery) =>
124
+ {
125
+ if (pSupportsQuery)
126
+ {
127
+ const tmpBody = { Distinct: true, Columns: pColumn };
128
+ if (tmpOptions.Filter)
129
+ {
130
+ tmpBody.Filter = tmpOptions.Filter;
131
+ }
132
+ return tmpEntityProvider.restClient.postJSON({ url: `${this.options.URLPrefix || ''}${this.options.Entity}s/Query`, body: tmpBody }, fHandleDistinctResult);
133
+ }
134
+ return tmpEntityProvider.restClient.getJSON(tmpURL, fHandleDistinctResult);
115
135
  });
116
136
  }
117
137
 
@@ -1006,6 +1026,13 @@ class MeadowEndpointsRecordSetProvider extends libRecordSetProviderBase
1006
1026
  return fCallback(error);
1007
1027
  }
1008
1028
  this._Schema = result;
1029
+ // The schema response carries the endpoint's version/capability
1030
+ // metadata; seed the entity provider's capability cache from it so
1031
+ // reads avoid a redundant capability probe.
1032
+ if (this.entityProvider && typeof this.entityProvider.primeEntityCapabilityFromSchema === 'function')
1033
+ {
1034
+ this.entityProvider.primeEntityCapabilityFromSchema(this.options.Entity, result, this.options.URLPrefix);
1035
+ }
1009
1036
  return fCallback(null);
1010
1037
  });
1011
1038
  }).catch((error) =>
@@ -15,6 +15,7 @@ const ProviderMeadowEndpoints = require('../providers/RecordSet-RecordProvider-M
15
15
 
16
16
  const ProviderLinkManager = require('../providers/RecordSet-Link-Manager.js');
17
17
  const ProviderAssociationManager = require('../providers/RecordSet-AssociationManager.js');
18
+ const ProviderCardManager = require('../providers/RecordSet-CardManager.js');
18
19
  const libProviderColumnData = require('../providers/Column-Data-Provider.js');
19
20
 
20
21
  const ProviderRouter = require('../providers/RecordSet-Router.js');
@@ -93,6 +94,9 @@ class RecordSetMetacontroller extends libFableServiceProviderBase
93
94
  return fCallback(null, '');
94
95
  }
95
96
  const remote = field.split('ID')[1];
97
+ // Capture the raw foreign-key id before `value` is overwritten with the resolved name —
98
+ // a registered preview card opens on the id, not the display string.
99
+ const tmpForeignID = value;
96
100
  try
97
101
  {
98
102
  const entity = await new Promise((resolve, reject) =>
@@ -135,8 +139,19 @@ class RecordSetMetacontroller extends libFableServiceProviderBase
135
139
  }
136
140
  if (this.pict.PictSectionRecordSet.recordSetProviderConfigurations[remote])
137
141
  {
138
- const url = this.pict.parseTemplateByHash('PRSP-Read-Link-URL-Template', { Payload: { Payload: { RecordSet: remote, GUIDAddress: `GUID${ remote }` }, Data: entity }});
139
- value = `<a href="${ url }">${ value }</a>`;
142
+ // A registered preview card supersedes the plain link: the trigger opens the anchored
143
+ // mini-card (which carries its own "view" action/route). Degrades to the link when no
144
+ // card is registered for the referenced record-set.
145
+ const tmpCardManager = this.pict.providers.RecordSetCardManager;
146
+ if (tmpCardManager && tmpCardManager.hasCard(remote))
147
+ {
148
+ value = tmpCardManager.triggerHTML(remote, tmpForeignID, value);
149
+ }
150
+ else
151
+ {
152
+ const url = this.pict.parseTemplateByHash('PRSP-Read-Link-URL-Template', { Payload: { Payload: { RecordSet: remote, GUIDAddress: `GUID${ remote }` }, Data: entity }});
153
+ value = `<a href="${ url }">${ value }</a>`;
154
+ }
140
155
  }
141
156
  }
142
157
  catch (e)
@@ -497,6 +512,10 @@ class RecordSetMetacontroller extends libFableServiceProviderBase
497
512
  // and the Bulk Associate screen. Associations are parsed from settings.Associations below.
498
513
  this.fable.addProvider('RecordSetAssociationManager', {}, ProviderAssociationManager);
499
514
 
515
+ // Record preview cards — a global registry of small, config-driven record cards (popover on a
516
+ // trigger). Layouts are parsed from settings.RecordCards below.
517
+ this.fable.addProvider('RecordSetCardManager', {}, ProviderCardManager);
518
+
500
519
  // Column visibility persistence — only register the built-in localStorage provider when the
501
520
  // host hasn't supplied its own (the documented seam for server-side per-user persistence).
502
521
  if (!('ColumnDataProvider' in this.fable.providers))
@@ -507,6 +526,7 @@ class RecordSetMetacontroller extends libFableServiceProviderBase
507
526
  // Add the subviews internally and externally
508
527
  this.pict.addTemplate(require('../templates/Pict-Template-FilterView.js'));
509
528
  this.pict.addTemplate(require('../templates/Pict-Template-FilterInstanceViews.js'));
529
+ this.pict.addTemplate(require('../templates/Pict-Template-RecordCard.js'));
510
530
  this.pict.addTemplate(require('../views/filters').Base);
511
531
  this.childViews.errorNotFound = this.fable.addView('RSP-RecordSet-Error-NotFound', ViewDefinitionRecordSetErrorNotFound);
512
532
  this.childViews.list = this.fable.addView('RSP-RecordSet-List', this.options, ViewRecordSetList);
@@ -560,6 +580,14 @@ class RecordSetMetacontroller extends libFableServiceProviderBase
560
580
  }
561
581
  }
562
582
 
583
+ if (this.fable.settings.hasOwnProperty('RecordCards') && typeof this.fable.settings.RecordCards === 'object')
584
+ {
585
+ for (const tmpRecordCardKey of Object.keys(this.fable.settings.RecordCards))
586
+ {
587
+ this.pict.providers.RecordSetCardManager.registerCard(tmpRecordCardKey, this.fable.settings.RecordCards[tmpRecordCardKey]);
588
+ }
589
+ }
590
+
563
591
  if (this.fable.settings.hasOwnProperty('DefaultRecordSetConfigurations'))
564
592
  {
565
593
  this.loadRecordSetConfigurationArray(this.fable.settings.DefaultRecordSetConfigurations);
@@ -0,0 +1,82 @@
1
+ const libPictTemplate = require('pict-template');
2
+
3
+ /**
4
+ * The `{~RecordCard:RecordSet^IDAddress^Label~}` template tag — drops an inline preview-card trigger
5
+ * (a label + a small icon) anywhere a record is referenced (lists, record views, dashboards). Clicking
6
+ * it opens the recordset's configured preview card in an anchored popover (see RecordSet-CardManager).
7
+ *
8
+ * Three `^`-separated parts (the third is optional):
9
+ * 1. RecordSet name (literal, e.g. `Author`).
10
+ * 2. The record id — a literal number OR an address resolved against the current data (`Record.IDAuthor`).
11
+ * 3. The visible label — an address (`Record.Name`) OR a literal string. Defaults to the id.
12
+ *
13
+ * Unlike `{~E:~}` this renders synchronously (no fetch): it only emits the trigger; the record is fetched
14
+ * lazily on click. When no card is registered for the recordset it degrades to the plain label.
15
+ */
16
+ class PictTemplateProviderRecordCard extends libPictTemplate
17
+ {
18
+ constructor(pFable, pOptions, pServiceHash)
19
+ {
20
+ super(pFable, pOptions, pServiceHash);
21
+
22
+ /** @type {any} */
23
+ this.log;
24
+
25
+ this.addPattern('{~RecordCard:', '~}');
26
+ this.addPattern('{~RC:', '~}');
27
+ }
28
+
29
+ render(pTemplateHash, pRecord, pContextArray, pScope, pState)
30
+ {
31
+ const tmpParts = String(pTemplateHash).trim().split('^');
32
+ if (tmpParts.length < 2)
33
+ {
34
+ this.log.warn(`Pict: RecordCard tag needs at least RecordSet^id — got [${pTemplateHash}].`);
35
+ return '';
36
+ }
37
+
38
+ let tmpRecordSet = tmpParts[0].trim();
39
+ let tmpID = tmpParts[1].trim();
40
+ let tmpLabel = (tmpParts.length > 2) ? tmpParts[2].trim() : '';
41
+
42
+ // Resolve the recordset: a literal name stays, an address (Record./AppData.) is looked up. This
43
+ // lets a shared template (e.g. an association row) drive the card's recordset from data — an empty
44
+ // result degrades to the plain label.
45
+ if (/^(Record|AppData)[.[]/.test(tmpRecordSet))
46
+ {
47
+ tmpRecordSet = this.resolveStateFromAddress(tmpRecordSet, pRecord, pContextArray, null, pScope, pState);
48
+ }
49
+
50
+ // Resolve the id: a literal number stays, otherwise it is an address into the data.
51
+ if (isNaN(Number(tmpID)))
52
+ {
53
+ tmpID = this.resolveStateFromAddress(tmpID, pRecord, pContextArray, null, pScope, pState);
54
+ }
55
+
56
+ // Resolve the label: an address (Record./AppData.) is looked up, otherwise it is a literal.
57
+ if (tmpLabel && /^(Record|AppData)[.[]/.test(tmpLabel))
58
+ {
59
+ tmpLabel = this.resolveStateFromAddress(tmpLabel, pRecord, pContextArray, null, pScope, pState);
60
+ }
61
+ if (tmpLabel == null || tmpLabel === '')
62
+ {
63
+ tmpLabel = (tmpID == null) ? '' : String(tmpID);
64
+ }
65
+
66
+ const tmpManager = this.pict.providers.RecordSetCardManager;
67
+ if (!tmpManager)
68
+ {
69
+ return String(tmpLabel);
70
+ }
71
+ return tmpManager.triggerHTML(tmpRecordSet, tmpID, tmpLabel);
72
+ }
73
+
74
+ /** Async entry just defers to the synchronous render (no fetch happens here — it is click-driven). */
75
+ renderAsync(pTemplateHash, pRecord, fCallback, pContextArray, pScope, pState)
76
+ {
77
+ const tmpCallback = (typeof fCallback === 'function') ? fCallback : () => '';
78
+ return tmpCallback(null, this.render(pTemplateHash, pRecord, pContextArray, pScope, pState));
79
+ }
80
+ }
81
+
82
+ module.exports = PictTemplateProviderRecordCard;
@@ -122,7 +122,7 @@ const _DEFAULT_CONFIGURATION_AssociationEditor = (
122
122
  Hash: 'PRSP-AssociationEditor-Row',
123
123
  Template: /*html*/`
124
124
  <div class="prsp-assoc-row">
125
- <span class="prsp-assoc-row-name">{~D:Record.Display~}</span>
125
+ <span class="prsp-assoc-row-name">{~RecordCard:Record.CardRecordSet^Record.OtherID^Record.Display~}</span>
126
126
  <span class="prsp-assoc-row-chips">{~TS:PRSP-AssociationEditor-Chip:Record.Chips~}</span>
127
127
  <span class="prsp-assoc-row-cfg">{~TS:PRSP-AssociationEditor-EditField:Record.EditFields~}</span>
128
128
  <span class="prsp-assoc-row-id">#{~D:Record.OtherID~}</span>
@@ -247,11 +247,16 @@ class viewRecordSetAssociationEditor extends libPictView
247
247
 
248
248
  // Per-join editable config controls (for "rich" joins) — empty array for a plain link.
249
249
  const tmpEditableFields = this.manager.getJoinEditableFields(this.options.AssociationHash);
250
+ // Auto record-preview-card: when a card is registered for the other side, each row's name becomes a
251
+ // preview-card trigger (the {~RecordCard:~} tag degrades to the plain name when CardRecordSet is '').
252
+ const tmpCardManager = this.pict.providers.RecordSetCardManager;
253
+ const tmpCardRecordSet = (tmpCardManager && tmpCardManager.hasCard(tmpSides.otherSide.RecordSet)) ? tmpSides.otherSide.RecordSet : '';
250
254
  // Stamp the view hash + the editable controls on each row.
251
255
  for (let i = 0; i < tmpItems.length; i++)
252
256
  {
253
257
  tmpItems[i].ViewHash = this.Hash;
254
258
  tmpItems[i].EditFields = this._buildEditFields(tmpEditableFields, tmpItems[i]);
259
+ tmpItems[i].CardRecordSet = tmpCardRecordSet;
255
260
  }
256
261
  this._lastItems = tmpItems;
257
262
  this._otherIDs = tmpItems.map((pItem) => pItem.OtherID);