pict-section-recordset 1.23.1 → 1.24.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.
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pict-section-recordset",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.24.0",
|
|
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.
|
|
40
|
+
"pict": "^1.0.384",
|
|
41
41
|
"pict-application": "^1.0.34",
|
|
42
42
|
"pict-docuserve": "^1.4.19",
|
|
43
|
-
"pict-service-commandlineutility": "^1.0.
|
|
43
|
+
"pict-service-commandlineutility": "^1.0.19",
|
|
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.
|
|
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"
|
|
@@ -14,7 +14,7 @@ const _DEFAULT_CONFIGURATION =
|
|
|
14
14
|
.psrs-card-trigger-icon { display: inline-flex; font-size: 0.82em; color: var(--theme-color-text-muted, #6b7686); }
|
|
15
15
|
.psrs-card-trigger:hover .psrs-card-trigger-icon { color: var(--theme-color-brand-primary, #156dd1); }
|
|
16
16
|
|
|
17
|
-
.psrs-card-popover { position: absolute; z-index: 4000; min-width: 16rem; max-width: 22rem;
|
|
17
|
+
.psrs-card-popover { position: absolute; z-index: 4000; min-width: 16rem; max-width: 22rem; max-height: 70vh; overflow-y: auto;
|
|
18
18
|
background: var(--theme-color-background-panel, #fff); border: 1px solid var(--theme-color-border-default, #d7dce3);
|
|
19
19
|
border-radius: 12px; box-shadow: 0 16px 40px rgba(15, 23, 42, 0.20); padding: 0.95rem 1.05rem;
|
|
20
20
|
opacity: 0; transform: translateY(-4px); transition: opacity 0.12s ease, transform 0.12s ease; pointer-events: none; }
|
|
@@ -28,6 +28,10 @@ const _DEFAULT_CONFIGURATION =
|
|
|
28
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
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
30
|
.psrs-card-value { font-size: 0.9rem; color: var(--theme-color-text-primary, #1f2733); word-break: break-word; }
|
|
31
|
+
.psrs-card-bool { display: inline-flex; align-items: center; justify-content: center; width: 1.05rem; height: 1.05rem; border-radius: 4px;
|
|
32
|
+
border: 1.5px solid var(--theme-color-border-default, #c7cdd6); color: #fff; vertical-align: middle; }
|
|
33
|
+
.psrs-card-bool-on { background: var(--theme-color-brand-primary, #156dd1); border-color: var(--theme-color-brand-primary, #156dd1); }
|
|
34
|
+
.psrs-card-bool .pict-icon { font-size: 0.8em; }
|
|
31
35
|
.psrs-card-custom { font-size: 0.9rem; color: var(--theme-color-text-primary, #1f2733); }
|
|
32
36
|
.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
37
|
.psrs-card-action { display: inline-flex; align-items: center; gap: 0.35rem; cursor: pointer; font: inherit; font-size: 0.82rem; font-weight: 600;
|
|
@@ -35,6 +39,22 @@ const _DEFAULT_CONFIGURATION =
|
|
|
35
39
|
background: var(--theme-color-background-secondary, #f5f6f8); color: var(--theme-color-text-secondary, #45505f); }
|
|
36
40
|
.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
41
|
.psrs-card-action .pict-icon { font-size: 0.9em; }
|
|
42
|
+
/* Audit stripe — a thin Created/Updated line + ID/GUID copy buttons, shown above the actions when on. */
|
|
43
|
+
.psrs-card-audit { display: flex; flex-wrap: wrap; align-items: center; gap: 0.3rem 0.8rem; margin-top: 0.7rem; padding-top: 0.55rem;
|
|
44
|
+
border-top: 1px dashed var(--theme-color-border-light, #eef1f5); font-size: 0.7rem; line-height: 1.35; color: var(--theme-color-text-muted, #6b7686); }
|
|
45
|
+
.psrs-card-audit-info { display: flex; flex-wrap: wrap; align-items: center; gap: 0.15rem 0.5rem; }
|
|
46
|
+
.psrs-card-audit-info b { font-weight: 600; color: var(--theme-color-text-secondary, #45505f); }
|
|
47
|
+
.psrs-card-audit-sep { opacity: 0.45; }
|
|
48
|
+
.psrs-card-audit-copy { display: inline-flex; gap: 0.3rem; margin-left: auto; }
|
|
49
|
+
.psrs-card-copy { display: inline-flex; align-items: center; gap: 0.2rem; cursor: pointer; font: inherit; font-size: 0.68rem; font-weight: 600;
|
|
50
|
+
border: 1px solid var(--theme-color-border-light, #e1e6ec); border-radius: 6px; padding: 0.08rem 0.4rem;
|
|
51
|
+
background: var(--theme-color-background-secondary, #f5f6f8); color: var(--theme-color-text-muted, #6b7686); }
|
|
52
|
+
.psrs-card-copy:hover { border-color: var(--theme-color-brand-primary, #156dd1); color: var(--theme-color-brand-primary, #156dd1); }
|
|
53
|
+
.psrs-card-copy.psrs-card-copied { border-color: #2e9e5b; color: #2e9e5b; }
|
|
54
|
+
.psrs-card-copy .pict-icon { font-size: 0.85em; }
|
|
55
|
+
.psrs-card-audit-toggle { display: inline-flex; align-items: center; gap: 0.3rem; margin-left: auto; font-size: 0.74rem; font-weight: 600;
|
|
56
|
+
color: var(--theme-color-text-muted, #6b7686); cursor: pointer; white-space: nowrap; }
|
|
57
|
+
.psrs-card-audit-toggle input { cursor: pointer; margin: 0; }
|
|
38
58
|
`,
|
|
39
59
|
CSSPriority: 600,
|
|
40
60
|
};
|
|
@@ -97,6 +117,7 @@ class RecordSetCardManager extends libPictProvider
|
|
|
97
117
|
{
|
|
98
118
|
Entity: pCardConfig.Entity || pRecordSetName,
|
|
99
119
|
IDField: pCardConfig.IDField || `ID${pRecordSetName}`,
|
|
120
|
+
GUIDField: pCardConfig.GUIDField || `GUID${pCardConfig.Entity || pRecordSetName}`,
|
|
100
121
|
TemplateHash: `PSRS-RecordCard-${pRecordSetName}`,
|
|
101
122
|
Config: pCardConfig,
|
|
102
123
|
};
|
|
@@ -206,63 +227,402 @@ class RecordSetCardManager extends libPictProvider
|
|
|
206
227
|
* @param {String|Number|Object} pIDOrRecord
|
|
207
228
|
* @param {HTMLElement} pAnchorElement
|
|
208
229
|
*/
|
|
209
|
-
openCard(pRecordSetName, pIDOrRecord, pAnchorElement)
|
|
230
|
+
openCard(pRecordSetName, pIDOrRecord, pAnchorElement, pOptions)
|
|
210
231
|
{
|
|
211
|
-
const
|
|
232
|
+
const tmpOptions = pOptions || {};
|
|
233
|
+
// A registered card, or a synthesized default card so any entity gets a preview (rich callers).
|
|
234
|
+
let tmpCard = this._cards[pRecordSetName];
|
|
235
|
+
if (!tmpCard) { tmpCard = this._defaultCard(pRecordSetName, tmpOptions); }
|
|
212
236
|
if (!tmpCard) { return; }
|
|
213
|
-
|
|
214
|
-
const
|
|
237
|
+
|
|
238
|
+
const tmpKeyID = (pIDOrRecord && (typeof pIDOrRecord === 'object'))
|
|
239
|
+
? ((pIDOrRecord[tmpCard.IDField] != null) ? pIDOrRecord[tmpCard.IDField] : '')
|
|
240
|
+
: pIDOrRecord;
|
|
241
|
+
const tmpKey = `${pRecordSetName}:${tmpKeyID}`;
|
|
215
242
|
if (this._openForKey === tmpKey && this._isOpen())
|
|
216
243
|
{
|
|
217
244
|
this.closeCard();
|
|
218
245
|
return;
|
|
219
246
|
}
|
|
220
247
|
|
|
248
|
+
// The audit stripe + the "more data" body both need the FULL record (the explorer passes only the few
|
|
249
|
+
// Lite columns). Only RICH cards render those, so only rich cards fetch — a normal recordset card keeps
|
|
250
|
+
// its exact existing behavior (no extra fetch) even when the global audit toggle is on.
|
|
251
|
+
const tmpWantFull = !!tmpOptions.Rich;
|
|
221
252
|
if (pIDOrRecord && (typeof pIDOrRecord === 'object'))
|
|
222
253
|
{
|
|
223
|
-
|
|
224
|
-
|
|
254
|
+
const tmpID = pIDOrRecord[tmpCard.IDField];
|
|
255
|
+
if (tmpWantFull && (tmpID != null) && this.pict.EntityProvider)
|
|
256
|
+
{
|
|
257
|
+
return this._fetchAndShow(tmpCard, tmpID, pAnchorElement, tmpKey, tmpOptions, pIDOrRecord);
|
|
258
|
+
}
|
|
259
|
+
return this._renderAndShow(tmpCard, pIDOrRecord, pAnchorElement, tmpKey, tmpOptions);
|
|
225
260
|
}
|
|
226
|
-
this.pict.EntityProvider
|
|
261
|
+
if (!this.pict.EntityProvider) { return; }
|
|
262
|
+
return this._fetchAndShow(tmpCard, pIDOrRecord, pAnchorElement, tmpKey, tmpOptions, null);
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
/** Fetch the full record (and, when the audit stripe is on, its creating/updating user names) then show. */
|
|
266
|
+
_fetchAndShow(pCard, pID, pAnchorElement, pKey, pOptions, pFallbackRecord)
|
|
267
|
+
{
|
|
268
|
+
this.pict.EntityProvider.getEntity(pCard.Entity, pID, (pError, pRecord) =>
|
|
227
269
|
{
|
|
228
|
-
|
|
270
|
+
const tmpRecord = (pError || !pRecord) ? pFallbackRecord : pRecord;
|
|
271
|
+
if (!tmpRecord)
|
|
229
272
|
{
|
|
230
|
-
this.log.warn(`RecordSetCardManager: could not load ${
|
|
273
|
+
this.log.warn(`RecordSetCardManager: could not load ${pCard.Entity} ${pID} for its preview card.`, pError);
|
|
231
274
|
return;
|
|
232
275
|
}
|
|
233
|
-
|
|
276
|
+
const fRender = () =>
|
|
277
|
+
{
|
|
278
|
+
if (this.getAuditEnabled())
|
|
279
|
+
{
|
|
280
|
+
return this._resolveAuditUsers(tmpRecord, () => this._renderAndShow(pCard, tmpRecord, pAnchorElement, pKey, pOptions));
|
|
281
|
+
}
|
|
282
|
+
return this._renderAndShow(pCard, tmpRecord, pAnchorElement, pKey, pOptions);
|
|
283
|
+
};
|
|
284
|
+
// Rich cards checkbox-render schema booleans → ensure the entity's (cached) schema first.
|
|
285
|
+
if (pOptions && pOptions.Rich) { return this._ensureSchema(pCard.Entity, fRender); }
|
|
286
|
+
return fRender();
|
|
287
|
+
});
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
/**
|
|
291
|
+
* Resolve + cache the set of BOOLEAN columns for an entity (so rich cards show 0/1 bits as checkboxes).
|
|
292
|
+
* Uses the host-supplied `SchemaSource(entity, cb) -> (err, schema)` — meadow JSON-schema (`properties[].type`)
|
|
293
|
+
* or Stricture (`Columns[].DataType`). No source / fetch failure → no booleans (values render verbatim).
|
|
294
|
+
*/
|
|
295
|
+
_ensureSchema(pEntity, fComplete)
|
|
296
|
+
{
|
|
297
|
+
if (!this._schemaBooleans) { this._schemaBooleans = {}; }
|
|
298
|
+
if (this._schemaBooleans[pEntity]) { return fComplete(); }
|
|
299
|
+
if (typeof this.SchemaSource !== 'function') { this._schemaBooleans[pEntity] = {}; return fComplete(); }
|
|
300
|
+
let tmpSettled = false;
|
|
301
|
+
const fDone = (pSchema) =>
|
|
302
|
+
{
|
|
303
|
+
if (tmpSettled) { return; }
|
|
304
|
+
tmpSettled = true;
|
|
305
|
+
const tmpBooleans = {};
|
|
306
|
+
if (pSchema && pSchema.properties)
|
|
307
|
+
{
|
|
308
|
+
Object.keys(pSchema.properties).forEach((pKey) => { if (pSchema.properties[pKey] && (pSchema.properties[pKey].type === 'boolean')) { tmpBooleans[pKey] = true; } });
|
|
309
|
+
}
|
|
310
|
+
else if (pSchema && Array.isArray(pSchema.Columns))
|
|
311
|
+
{
|
|
312
|
+
pSchema.Columns.forEach((pColumn) => { if (pColumn && (pColumn.DataType === 'Boolean')) { tmpBooleans[pColumn.Column] = true; } });
|
|
313
|
+
}
|
|
314
|
+
this._schemaBooleans[pEntity] = tmpBooleans;
|
|
315
|
+
fComplete();
|
|
316
|
+
};
|
|
317
|
+
try { this.SchemaSource(pEntity, (pError, pSchema) => fDone(pError ? null : pSchema)); }
|
|
318
|
+
catch (pError) { fDone(null); }
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
/** Synthesize a card for an entity that has none registered, so every record still gets a preview. */
|
|
322
|
+
_defaultCard(pRecordSetName, pOptions)
|
|
323
|
+
{
|
|
324
|
+
const tmpEntityConfig = (pOptions && pOptions.EntityConfig) || {};
|
|
325
|
+
const tmpEntity = tmpEntityConfig.Entity || pRecordSetName;
|
|
326
|
+
return {
|
|
327
|
+
Entity: tmpEntity,
|
|
328
|
+
IDField: tmpEntityConfig.IDField || `ID${pRecordSetName}`,
|
|
329
|
+
GUIDField: tmpEntityConfig.GUIDField || `GUID${tmpEntity}`,
|
|
330
|
+
TemplateHash: null, // no pre-compiled template — default cards always render the rich (dynamic) body
|
|
331
|
+
Config: { _Default: true, Title: (tmpEntityConfig.Display && tmpEntityConfig.Display.Title) || null, Actions: [] },
|
|
332
|
+
};
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
/** Resolve creating/updating user display names onto the record (cached lookups) before a sync render. */
|
|
336
|
+
_resolveAuditUsers(pRecord, fComplete)
|
|
337
|
+
{
|
|
338
|
+
const tmpEntityProvider = this.pict.EntityProvider;
|
|
339
|
+
if (!tmpEntityProvider || (typeof tmpEntityProvider.getEntity !== 'function')) { return fComplete(); }
|
|
340
|
+
const tmpJobs = [];
|
|
341
|
+
if (pRecord.CreatingIDUser) { tmpJobs.push({ Field: 'CreatingUserName', ID: pRecord.CreatingIDUser }); }
|
|
342
|
+
if (pRecord.UpdatingIDUser) { tmpJobs.push({ Field: 'UpdatingUserName', ID: pRecord.UpdatingIDUser }); }
|
|
343
|
+
if (tmpJobs.length < 1) { return fComplete(); }
|
|
344
|
+
let tmpPending = tmpJobs.length;
|
|
345
|
+
tmpJobs.forEach((pJob) =>
|
|
346
|
+
{
|
|
347
|
+
tmpEntityProvider.getEntity('User', pJob.ID, (pError, pUser) =>
|
|
348
|
+
{
|
|
349
|
+
if (!pError && pUser)
|
|
350
|
+
{
|
|
351
|
+
pRecord[pJob.Field] = [ pUser.NameFirst, pUser.NameLast ].filter((pPart) => (typeof pPart === 'string') && pPart.trim().length > 0).join(' ')
|
|
352
|
+
|| pUser.FullName || pUser.Name || pUser.LoginID || `User ${pJob.ID}`;
|
|
353
|
+
}
|
|
354
|
+
tmpPending--;
|
|
355
|
+
if (tmpPending <= 0) { fComplete(); }
|
|
356
|
+
});
|
|
234
357
|
});
|
|
235
358
|
}
|
|
236
359
|
|
|
237
|
-
_renderAndShow(pCard, pRecord, pAnchorElement, pKey)
|
|
360
|
+
_renderAndShow(pCard, pRecord, pAnchorElement, pKey, pOptions)
|
|
238
361
|
{
|
|
239
|
-
|
|
240
|
-
//
|
|
362
|
+
const tmpOptions = pOptions || {};
|
|
363
|
+
// A default card (no pre-compiled template) or a rich caller renders the dynamic body (all columns +
|
|
364
|
+
// audit stripe). Otherwise the existing pre-compiled card template renders verbatim (no behavior change).
|
|
365
|
+
const tmpRich = !!tmpOptions.Rich || !pCard.TemplateHash;
|
|
241
366
|
let tmpHTML = '';
|
|
242
|
-
|
|
243
|
-
|
|
367
|
+
if (!tmpRich)
|
|
368
|
+
{
|
|
369
|
+
// Cards use synchronous template tags ({~D:~}, {~I:~}, formatters) so a sync parse is correct and
|
|
370
|
+
// avoids a flash; the record is already in hand here.
|
|
371
|
+
try { tmpHTML = this.pict.parseTemplateByHash(pCard.TemplateHash, pRecord); }
|
|
372
|
+
catch (pError) { this.log.error(`RecordSetCardManager: card render failed for ${pCard.TemplateHash}.`, pError); return; }
|
|
373
|
+
}
|
|
374
|
+
else
|
|
375
|
+
{
|
|
376
|
+
tmpHTML = this._buildRichCardHTML(pCard, pRecord, tmpOptions);
|
|
377
|
+
}
|
|
244
378
|
const tmpPopover = this._ensurePopover();
|
|
245
379
|
if (!tmpPopover) { return; }
|
|
246
380
|
tmpPopover.innerHTML = tmpHTML;
|
|
247
|
-
|
|
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++)
|
|
381
|
+
if (!tmpRich)
|
|
251
382
|
{
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
tmpValueCells
|
|
383
|
+
// Drop any field whose value rendered empty so a card never shows a "LABEL: (blank)" row — the
|
|
384
|
+
// configured Fields are static, but which ones have data varies per record. (The rich body skips
|
|
385
|
+
// empties as it builds, so this only applies to the pre-compiled path.)
|
|
386
|
+
const tmpValueCells = tmpPopover.querySelectorAll('.psrs-card-fields .psrs-card-value');
|
|
387
|
+
for (let i = 0; i < tmpValueCells.length; i++)
|
|
388
|
+
{
|
|
389
|
+
if (String(tmpValueCells[i].textContent || '').trim() !== '') { continue; }
|
|
390
|
+
const tmpLabelCell = tmpValueCells[i].previousElementSibling;
|
|
391
|
+
if (tmpLabelCell && tmpLabelCell.classList.contains('psrs-card-label')) { tmpLabelCell.remove(); }
|
|
392
|
+
tmpValueCells[i].remove();
|
|
393
|
+
}
|
|
394
|
+
const tmpFieldsBox = tmpPopover.querySelector('.psrs-card-fields');
|
|
395
|
+
if (tmpFieldsBox && (tmpFieldsBox.children.length === 0)) { tmpFieldsBox.remove(); }
|
|
256
396
|
}
|
|
257
|
-
const tmpFieldsBox = tmpPopover.querySelector('.psrs-card-fields');
|
|
258
|
-
if (tmpFieldsBox && (tmpFieldsBox.children.length === 0)) { tmpFieldsBox.remove(); }
|
|
259
397
|
this.pict.CSSMap.injectCSS();
|
|
260
398
|
this._openForKey = pKey;
|
|
399
|
+
this._lastShow = { Card: pCard, Record: pRecord, Anchor: pAnchorElement, Key: pKey, Options: tmpOptions }; // for the audit re-render
|
|
261
400
|
this._position(tmpPopover, pAnchorElement);
|
|
262
401
|
tmpPopover.classList.add('is-open');
|
|
263
402
|
this._bindDismiss();
|
|
264
403
|
}
|
|
265
404
|
|
|
405
|
+
// --- Rich card body (all columns + audit stripe) + the audit/copy actions ---
|
|
406
|
+
|
|
407
|
+
/** Build the dynamic card HTML: head + all meaningful columns + (when on) the audit stripe + actions. */
|
|
408
|
+
_buildRichCardHTML(pCard, pRecord, pOptions)
|
|
409
|
+
{
|
|
410
|
+
const tmpConfig = pCard.Config || {};
|
|
411
|
+
const tmpTitle = (tmpConfig.Title ? this._resolveText(tmpConfig.Title, pRecord) : '') || this._deriveTitle(pRecord, pCard);
|
|
412
|
+
let tmpHead = `<div class="psrs-card-head"><div><div class="psrs-card-title">${this._escapeHTML(tmpTitle)}</div>`;
|
|
413
|
+
const tmpSubtitle = tmpConfig.Subtitle ? this._resolveText(tmpConfig.Subtitle, pRecord) : '';
|
|
414
|
+
if (tmpSubtitle) { tmpHead += `<div class="psrs-card-subtitle">${this._escapeHTML(tmpSubtitle)}</div>`; }
|
|
415
|
+
tmpHead += '</div></div>';
|
|
416
|
+
|
|
417
|
+
// Skip the field the title already shows, so the body doesn't repeat it as a "Name: …" row.
|
|
418
|
+
let tmpSkipField = null;
|
|
419
|
+
if (tmpConfig.Title && (String(tmpConfig.Title).indexOf('{~') < 0)) { tmpSkipField = tmpConfig.Title; }
|
|
420
|
+
else if (!tmpConfig.Title && pRecord.Name) { tmpSkipField = 'Name'; }
|
|
421
|
+
let tmpBody = '';
|
|
422
|
+
const tmpBooleanFields = (this._schemaBooleans && this._schemaBooleans[pCard.Entity]) || {};
|
|
423
|
+
const tmpFields = this._richFields(pRecord, tmpSkipField, tmpBooleanFields);
|
|
424
|
+
if (tmpFields.length > 0)
|
|
425
|
+
{
|
|
426
|
+
tmpBody = '<div class="psrs-card-fields">';
|
|
427
|
+
tmpFields.forEach((pField) =>
|
|
428
|
+
{
|
|
429
|
+
const tmpValueCell = pField.IsBoolean ? this._boolHTML(pField.BoolOn) : this._escapeHTML(pField.Value);
|
|
430
|
+
tmpBody += `<span class="psrs-card-label">${this._escapeHTML(pField.Label)}</span><span class="psrs-card-value">${tmpValueCell}</span>`;
|
|
431
|
+
});
|
|
432
|
+
tmpBody += '</div>';
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
const tmpAudit = this.getAuditEnabled() ? this._auditStripeHTML(pCard, pRecord) : '';
|
|
436
|
+
const tmpActions = this._richActionsHTML(tmpConfig, pRecord, pOptions);
|
|
437
|
+
return `<div class="psrs-card">${tmpHead}${tmpBody}${tmpAudit}${tmpActions}</div>`;
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
/** Every real data column (skipping id/guid — surfaced via copy buttons — audit, nulls + objects). */
|
|
441
|
+
_richFields(pRecord, pSkipField, pBooleanFields)
|
|
442
|
+
{
|
|
443
|
+
const tmpBooleans = pBooleanFields || {};
|
|
444
|
+
const tmpFields = [];
|
|
445
|
+
Object.keys(pRecord || {}).forEach((pKey) =>
|
|
446
|
+
{
|
|
447
|
+
if (pKey === pSkipField) { return; }
|
|
448
|
+
if (/^(ID|GUID)/.test(pKey)) { return; }
|
|
449
|
+
if (/^(CreateDate|UpdateDate|CreatingIDUser|UpdatingIDUser|DeletingIDUser|DeleteDate|Deleted|CreatingUserName|UpdatingUserName)$/.test(pKey)) { return; }
|
|
450
|
+
const tmpValue = pRecord[pKey];
|
|
451
|
+
if ((tmpValue == null) || (tmpValue === '') || (typeof tmpValue === 'object')) { return; }
|
|
452
|
+
// Schema booleans render as a checkbox glyph, not their raw 0/1.
|
|
453
|
+
if (tmpBooleans[pKey])
|
|
454
|
+
{
|
|
455
|
+
tmpFields.push({ Label: this._humanizeLabel(pKey), IsBoolean: true, BoolOn: ((tmpValue === 1) || (tmpValue === '1') || (tmpValue === true) || (String(tmpValue).toLowerCase() === 'true')) });
|
|
456
|
+
return;
|
|
457
|
+
}
|
|
458
|
+
let tmpDisplay = String(tmpValue);
|
|
459
|
+
// Hide values that are only null/undefined tokens (e.g. a "null null" address from concatenated
|
|
460
|
+
// empty parts) — they shouldn't reach the user looking like broken software.
|
|
461
|
+
if (/^(null|undefined|nan)(\s+(null|undefined|nan))*$/i.test(tmpDisplay.trim())) { return; }
|
|
462
|
+
// Humanize ISO timestamps instead of dumping the raw `2026-06-21T05:47:01.000Z`.
|
|
463
|
+
if (/^\d{4}-\d{2}-\d{2}[T ]\d{2}:\d{2}/.test(tmpDisplay)) { tmpDisplay = this._fmtDate(tmpDisplay); }
|
|
464
|
+
tmpFields.push({ Label: this._humanizeLabel(pKey), Value: tmpDisplay });
|
|
465
|
+
});
|
|
466
|
+
return tmpFields;
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
/** A checkbox glyph for a schema-boolean value — filled when on, an empty box when off. */
|
|
470
|
+
_boolHTML(pOn)
|
|
471
|
+
{
|
|
472
|
+
const tmpCheck = (pOn && (typeof this.pict.icon === 'function')) ? this.pict.icon('Check') : '';
|
|
473
|
+
return `<span class="psrs-card-bool${pOn ? ' psrs-card-bool-on' : ''}">${pOn ? tmpCheck : ''}</span>`;
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
/** The thin audit stripe: Created/Updated date + user, plus ID/GUID copy buttons (value shown on hover). */
|
|
477
|
+
_auditStripeHTML(pCard, pRecord)
|
|
478
|
+
{
|
|
479
|
+
const tmpInfo = [];
|
|
480
|
+
if (pRecord.CreateDate) { tmpInfo.push(`<span>Created <b>${this._escapeHTML(this._fmtDate(pRecord.CreateDate))}</b>${pRecord.CreatingUserName ? ` by <b>${this._escapeHTML(pRecord.CreatingUserName)}</b>` : ''}</span>`); }
|
|
481
|
+
if (pRecord.UpdateDate) { tmpInfo.push(`<span>Updated <b>${this._escapeHTML(this._fmtDate(pRecord.UpdateDate))}</b>${pRecord.UpdatingUserName ? ` by <b>${this._escapeHTML(pRecord.UpdatingUserName)}</b>` : ''}</span>`); }
|
|
482
|
+
const tmpIcon = (typeof this.pict.icon === 'function') ? this.pict.icon('Copy') : '';
|
|
483
|
+
let tmpCopies = '';
|
|
484
|
+
const tmpID = pRecord[pCard.IDField];
|
|
485
|
+
const tmpGUID = pRecord[pCard.GUIDField];
|
|
486
|
+
if ((tmpID != null) && (tmpID !== '')) { tmpCopies += this._copyButtonHTML('ID', tmpID, tmpIcon); }
|
|
487
|
+
if (tmpGUID) { tmpCopies += this._copyButtonHTML('GUID', tmpGUID, tmpIcon); }
|
|
488
|
+
if ((tmpInfo.length < 1) && !tmpCopies) { return ''; }
|
|
489
|
+
return `<div class="psrs-card-audit"><div class="psrs-card-audit-info">${tmpInfo.join('<span class="psrs-card-audit-sep">·</span>')}</div>`
|
|
490
|
+
+ `<div class="psrs-card-audit-copy">${tmpCopies}</div></div>`;
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
_copyButtonHTML(pLabel, pValue, pIcon)
|
|
494
|
+
{
|
|
495
|
+
const tmpValue = this._escapeAttribute(String(pValue));
|
|
496
|
+
return `<button type="button" class="psrs-card-copy" title="${tmpValue}" data-copy="${tmpValue}" onclick="event.stopPropagation();_Pict.providers.RecordSetCardManager.copyValue(this.getAttribute('data-copy'),this)">${pIcon}<span>${this._escapeHTML(pLabel)}</span></button>`;
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
/** The card's configured actions (View, …) + the global Audit toggle pushed to the right. */
|
|
500
|
+
_richActionsHTML(pConfig, pRecord, pOptions)
|
|
501
|
+
{
|
|
502
|
+
let tmpActions = '';
|
|
503
|
+
(Array.isArray(pConfig.Actions) ? pConfig.Actions : []).forEach((pAction) => { tmpActions += this._actionHTML(pAction, pRecord); });
|
|
504
|
+
const tmpAuditToggle = (pOptions && (pOptions.Audit === false)) ? ''
|
|
505
|
+
: `<label class="psrs-card-audit-toggle"><input type="checkbox" ${this.getAuditEnabled() ? 'checked ' : ''}onclick="event.stopPropagation();_Pict.providers.RecordSetCardManager.toggleAudit()" /><span>Audit</span></label>`;
|
|
506
|
+
if (!tmpActions && !tmpAuditToggle) { return ''; }
|
|
507
|
+
return `<div class="psrs-card-actions">${tmpActions}${tmpAuditToggle}</div>`;
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
/** Build one action anchor/button, resolving its Route/URL template + icon against the record. */
|
|
511
|
+
_actionHTML(pAction, pRecord)
|
|
512
|
+
{
|
|
513
|
+
const tmpIcon = (pAction.Icon && (typeof this.pict.icon === 'function')) ? this.pict.icon(pAction.Icon) : '';
|
|
514
|
+
const tmpLabel = `${tmpIcon}<span>${this._escapeHTML(pAction.Label || 'Open')}</span>`;
|
|
515
|
+
if (pAction.URL)
|
|
516
|
+
{
|
|
517
|
+
return `<a class="psrs-card-action" href="${this._escapeAttribute(this._resolveText(pAction.URL, pRecord))}" target="_blank" rel="noopener" onclick="_Pict.providers.RecordSetCardManager.closeCard()">${tmpLabel}</a>`;
|
|
518
|
+
}
|
|
519
|
+
if (pAction.Route)
|
|
520
|
+
{
|
|
521
|
+
return `<a class="psrs-card-action" href="${this._escapeAttribute(this._resolveText(pAction.Route, pRecord))}" onclick="_Pict.providers.RecordSetCardManager.closeCard()">${tmpLabel}</a>`;
|
|
522
|
+
}
|
|
523
|
+
if (pAction.Handler)
|
|
524
|
+
{
|
|
525
|
+
return `<button type="button" class="psrs-card-action" onclick="_Pict.providers.RecordSetCardManager.closeCard(); ${pAction.Handler}">${tmpLabel}</button>`;
|
|
526
|
+
}
|
|
527
|
+
return '';
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
/** Resolve a field-name-or-template config value against a record into a plain string. */
|
|
531
|
+
_resolveText(pValue, pRecord)
|
|
532
|
+
{
|
|
533
|
+
try { return this.pict.parseTemplate(this._valueTemplate(pValue), pRecord); }
|
|
534
|
+
catch (pError) { return ''; }
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
_deriveTitle(pRecord, pCard)
|
|
538
|
+
{
|
|
539
|
+
return pRecord.Name || pRecord.Title || pRecord.DisplayName
|
|
540
|
+
|| `${pCard.Entity} #${(pRecord[pCard.IDField] != null) ? pRecord[pCard.IDField] : ''}`;
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
/** `MaterialCode` → `Material Code`, `mix_id` → `mix id`. */
|
|
544
|
+
_humanizeLabel(pKey)
|
|
545
|
+
{
|
|
546
|
+
return String(pKey).replace(/([a-z0-9])([A-Z])/g, '$1 $2').replace(/_/g, ' ');
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
/** Human-readable date — `Jun 21, 2026 05:47` (or `Jun 21, 2026` with no time) from an ISO string. */
|
|
550
|
+
_fmtDate(pDate)
|
|
551
|
+
{
|
|
552
|
+
const tmpString = String((pDate == null) ? '' : pDate);
|
|
553
|
+
const tmpMatch = /^(\d{4})-(\d{2})-(\d{2})(?:[T ](\d{2}):(\d{2}))?/.exec(tmpString);
|
|
554
|
+
if (!tmpMatch) { return tmpString; }
|
|
555
|
+
const tmpMonths = [ 'Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec' ];
|
|
556
|
+
const tmpMonth = tmpMonths[parseInt(tmpMatch[2], 10) - 1] || tmpMatch[2];
|
|
557
|
+
let tmpResult = `${tmpMonth} ${parseInt(tmpMatch[3], 10)}, ${tmpMatch[1]}`;
|
|
558
|
+
if (tmpMatch[4]) { tmpResult += ` ${tmpMatch[4]}:${tmpMatch[5]}`; }
|
|
559
|
+
return tmpResult;
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
_escapeHTML(pValue)
|
|
563
|
+
{
|
|
564
|
+
return String((pValue == null) ? '' : pValue).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
_escapeAttribute(pValue)
|
|
568
|
+
{
|
|
569
|
+
return String((pValue == null) ? '' : pValue).replace(/&/g, '&').replace(/"/g, '"').replace(/</g, '<').replace(/>/g, '>');
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
// --- Audit toggle (global, persisted) + clipboard copy ---
|
|
573
|
+
|
|
574
|
+
/** Whether the audit stripe is globally enabled (localStorage, with an in-memory fallback). */
|
|
575
|
+
getAuditEnabled()
|
|
576
|
+
{
|
|
577
|
+
try
|
|
578
|
+
{
|
|
579
|
+
if (typeof window !== 'undefined' && window.localStorage) { return window.localStorage.getItem('RecordSetCardManager.AuditStripe') === 'true'; }
|
|
580
|
+
}
|
|
581
|
+
catch (pError) { /* fall through to the in-memory copy */ }
|
|
582
|
+
return !!this._auditEnabled;
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
setAuditEnabled(pEnabled)
|
|
586
|
+
{
|
|
587
|
+
this._auditEnabled = !!pEnabled;
|
|
588
|
+
try
|
|
589
|
+
{
|
|
590
|
+
if (typeof window !== 'undefined' && window.localStorage) { window.localStorage.setItem('RecordSetCardManager.AuditStripe', String(!!pEnabled)); }
|
|
591
|
+
}
|
|
592
|
+
catch (pError) { /* in-memory only */ }
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
/** Flip the global audit setting and repaint the open card so its stripe shows/hides immediately. */
|
|
596
|
+
toggleAudit()
|
|
597
|
+
{
|
|
598
|
+
this.setAuditEnabled(!this.getAuditEnabled());
|
|
599
|
+
if (!this._lastShow) { return; }
|
|
600
|
+
const tmpLast = this._lastShow;
|
|
601
|
+
if (this.getAuditEnabled())
|
|
602
|
+
{
|
|
603
|
+
return this._resolveAuditUsers(tmpLast.Record, () => this._renderAndShow(tmpLast.Card, tmpLast.Record, tmpLast.Anchor, tmpLast.Key, tmpLast.Options));
|
|
604
|
+
}
|
|
605
|
+
return this._renderAndShow(tmpLast.Card, tmpLast.Record, tmpLast.Anchor, tmpLast.Key, tmpLast.Options);
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
/** Copy a value (an ID / GUID) to the clipboard with a brief confirmation on the button. */
|
|
609
|
+
copyValue(pValue, pButton)
|
|
610
|
+
{
|
|
611
|
+
try
|
|
612
|
+
{
|
|
613
|
+
if ((typeof navigator !== 'undefined') && navigator.clipboard && (typeof navigator.clipboard.writeText === 'function'))
|
|
614
|
+
{
|
|
615
|
+
navigator.clipboard.writeText(String((pValue == null) ? '' : pValue));
|
|
616
|
+
}
|
|
617
|
+
}
|
|
618
|
+
catch (pError) { /* clipboard unavailable */ }
|
|
619
|
+
if (pButton && pButton.classList)
|
|
620
|
+
{
|
|
621
|
+
pButton.classList.add('psrs-card-copied');
|
|
622
|
+
setTimeout(() => { if (pButton.classList) { pButton.classList.remove('psrs-card-copied'); } }, 1100);
|
|
623
|
+
}
|
|
624
|
+
}
|
|
625
|
+
|
|
266
626
|
_ensurePopover()
|
|
267
627
|
{
|
|
268
628
|
if (typeof document === 'undefined') { return null; }
|
|
@@ -301,6 +661,9 @@ class RecordSetCardManager extends libPictProvider
|
|
|
301
661
|
{
|
|
302
662
|
tmpTop = tmpRect.top + tmpScrollY - tmpHeight - tmpGap;
|
|
303
663
|
}
|
|
664
|
+
// Clamp vertically so a tall (rich) card never runs off-screen — it scrolls internally past max-height.
|
|
665
|
+
const tmpBottomLimit = tmpScrollY + window.innerHeight - 8;
|
|
666
|
+
if (tmpTop + tmpHeight > tmpBottomLimit) { tmpTop = Math.max(tmpScrollY + 8, tmpBottomLimit - tmpHeight); }
|
|
304
667
|
pPopover.style.left = `${Math.round(tmpLeft)}px`;
|
|
305
668
|
pPopover.style.top = `${Math.round(tmpTop)}px`;
|
|
306
669
|
}
|
|
@@ -101,8 +101,7 @@ 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
|
-
|
|
105
|
-
const fHandleDistinctResult = (pError, pResponse, pBody) =>
|
|
104
|
+
this.entityProvider.restClient.getJSON(tmpURL, (pError, pResponse, pBody) =>
|
|
106
105
|
{
|
|
107
106
|
if (pError || (pResponse && pResponse.statusCode > 299) || !Array.isArray(pBody))
|
|
108
107
|
{
|
|
@@ -113,25 +112,6 @@ class MeadowEndpointsRecordSetProvider extends libRecordSetProviderBase
|
|
|
113
112
|
const tmpValues = [ ...new Set(pBody.map((pRecord) => pRecord && pRecord[pColumn]).filter((pValue) => pValue != null)) ];
|
|
114
113
|
this._scopeDistinctCache[tmpCacheKey] = tmpValues;
|
|
115
114
|
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);
|
|
135
115
|
});
|
|
136
116
|
}
|
|
137
117
|
|
|
@@ -1026,13 +1006,6 @@ class MeadowEndpointsRecordSetProvider extends libRecordSetProviderBase
|
|
|
1026
1006
|
return fCallback(error);
|
|
1027
1007
|
}
|
|
1028
1008
|
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
|
-
}
|
|
1036
1009
|
return fCallback(null);
|
|
1037
1010
|
});
|
|
1038
1011
|
}).catch((error) =>
|