pict-section-recordset 1.21.1 → 1.23.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 +1 -1
- package/source/Pict-Section-RecordSet.js +1 -0
- package/source/providers/RecordSet-CardManager.js +345 -0
- package/source/services/RecordsSet-MetaController.js +67 -2
- package/source/templates/Pict-Template-RecordCard.js +82 -0
- package/source/views/associate/RecordSet-AssociationEditor.js +6 -1
- package/source/views/create/RecordSet-Create.js +13 -2
- package/source/views/dashboard/RecordSet-Dashboard.js +2 -0
- package/source/views/list/RecordSet-List-Title.js +8 -0
- package/source/views/list/RecordSet-List.js +22 -0
- package/source/views/read/RecordSet-Read.js +48 -7
package/package.json
CHANGED
|
@@ -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;
|
|
@@ -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
|
-
|
|
139
|
-
|
|
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)
|
|
@@ -186,6 +201,43 @@ class RecordSetMetacontroller extends libFableServiceProviderBase
|
|
|
186
201
|
return this.recordSetProviderConfigurations[pRecordSet];
|
|
187
202
|
}
|
|
188
203
|
|
|
204
|
+
/**
|
|
205
|
+
* Resolve a column name to the record-set it references, so a form can mount an entity Picker and the
|
|
206
|
+
* read view can resolve the name. Two sources, in order:
|
|
207
|
+
*
|
|
208
|
+
* 1. The record-set config's explicit `ForeignKeyEntities` map ({ ColumnName: EntityName }). This is
|
|
209
|
+
* how non-conventional references are declared — prefixed/suffixed columns (ParentIDSample,
|
|
210
|
+
* ItemIDTestSpecificationSet, LinkedIDUser, IDMaterialParent, …) and any override. Mapping a
|
|
211
|
+
* column to a falsy value explicitly opts it OUT (keeps it a plain input).
|
|
212
|
+
* 2. The plain `ID<Entity>` convention — a column named exactly `ID` + an entity that is a
|
|
213
|
+
* registered record-set (IDOrganization → Organization). No prefix heuristics; anything that
|
|
214
|
+
* isn't a clean `ID<Entity>` must be declared in the map above.
|
|
215
|
+
*
|
|
216
|
+
* Returns false for the row's own id, IDCustomer, non-references, and references this app doesn't
|
|
217
|
+
* manage as a record-set (which then stay plain inputs).
|
|
218
|
+
*
|
|
219
|
+
* @param {string} pFieldName - the schema column name.
|
|
220
|
+
* @param {Record<string, any>} [pRecordSetConfiguration] - the owning record-set's configuration.
|
|
221
|
+
* @param {string} [pOwnIDField] - the row's own id field, excluded (e.g. 'IDTestSpecification').
|
|
222
|
+
* @return {string|false} the referenced record-set name, or false.
|
|
223
|
+
*/
|
|
224
|
+
resolveForeignEntity(pFieldName, pRecordSetConfiguration, pOwnIDField)
|
|
225
|
+
{
|
|
226
|
+
if (!pFieldName || (typeof pFieldName !== 'string')) { return false; }
|
|
227
|
+
// 1. Explicit per-record-set mapping wins (declares exceptions; a falsy value opts a column out).
|
|
228
|
+
const tmpMap = pRecordSetConfiguration && pRecordSetConfiguration.ForeignKeyEntities;
|
|
229
|
+
if (tmpMap && Object.prototype.hasOwnProperty.call(tmpMap, pFieldName))
|
|
230
|
+
{
|
|
231
|
+
return tmpMap[pFieldName] || false;
|
|
232
|
+
}
|
|
233
|
+
// 2. Plain ID<Entity> convention, validated against the registered record-sets.
|
|
234
|
+
if ((pFieldName === pOwnIDField) || (pFieldName === 'IDCustomer')) { return false; }
|
|
235
|
+
if (!pFieldName.startsWith('ID')) { return false; }
|
|
236
|
+
const tmpRemote = pFieldName.slice(2);
|
|
237
|
+
if (!tmpRemote) { return false; }
|
|
238
|
+
return this.recordSetProviderConfigurations[tmpRemote] ? tmpRemote : false;
|
|
239
|
+
}
|
|
240
|
+
|
|
189
241
|
/**
|
|
190
242
|
* @param {Record<string, any>} pRecordSetConfiguration - The RecordSet configuration to load.
|
|
191
243
|
*/
|
|
@@ -460,6 +512,10 @@ class RecordSetMetacontroller extends libFableServiceProviderBase
|
|
|
460
512
|
// and the Bulk Associate screen. Associations are parsed from settings.Associations below.
|
|
461
513
|
this.fable.addProvider('RecordSetAssociationManager', {}, ProviderAssociationManager);
|
|
462
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
|
+
|
|
463
519
|
// Column visibility persistence — only register the built-in localStorage provider when the
|
|
464
520
|
// host hasn't supplied its own (the documented seam for server-side per-user persistence).
|
|
465
521
|
if (!('ColumnDataProvider' in this.fable.providers))
|
|
@@ -470,6 +526,7 @@ class RecordSetMetacontroller extends libFableServiceProviderBase
|
|
|
470
526
|
// Add the subviews internally and externally
|
|
471
527
|
this.pict.addTemplate(require('../templates/Pict-Template-FilterView.js'));
|
|
472
528
|
this.pict.addTemplate(require('../templates/Pict-Template-FilterInstanceViews.js'));
|
|
529
|
+
this.pict.addTemplate(require('../templates/Pict-Template-RecordCard.js'));
|
|
473
530
|
this.pict.addTemplate(require('../views/filters').Base);
|
|
474
531
|
this.childViews.errorNotFound = this.fable.addView('RSP-RecordSet-Error-NotFound', ViewDefinitionRecordSetErrorNotFound);
|
|
475
532
|
this.childViews.list = this.fable.addView('RSP-RecordSet-List', this.options, ViewRecordSetList);
|
|
@@ -523,6 +580,14 @@ class RecordSetMetacontroller extends libFableServiceProviderBase
|
|
|
523
580
|
}
|
|
524
581
|
}
|
|
525
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
|
+
|
|
526
591
|
if (this.fable.settings.hasOwnProperty('DefaultRecordSetConfigurations'))
|
|
527
592
|
{
|
|
528
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">{~
|
|
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);
|
|
@@ -210,14 +210,14 @@ class viewRecordSetCreate extends libPictRecordSetRecordView
|
|
|
210
210
|
let rowCounter = 1;
|
|
211
211
|
for (const p of Object.keys(tmpRecordCreateData.RecordSchema.properties))
|
|
212
212
|
{
|
|
213
|
-
const exclusionSet = [this.pict.providers[this.providerHash].getIDField(), this.pict.providers[this.providerHash].getGUIDField(), 'CreatingIDUser', 'UpdatingIDUser', 'DeletingIDUser', 'Deleted', 'CreateDate', 'UpdateDate', 'DeleteDate', 'Deleted'];
|
|
213
|
+
const exclusionSet = [this.pict.providers[this.providerHash].getIDField(), this.pict.providers[this.providerHash].getGUIDField(), 'CreatingIDUser', 'UpdatingIDUser', 'DeletingIDUser', 'Deleted', 'CreateDate', 'UpdateDate', 'DeleteDate', 'Deleted', 'IDCustomer', 'ExternalSyncDate', 'ExternalSyncGUID']; // IDCustomer: meadow-endpoints/retold tenancy discriminator — server-managed, never settable via the API
|
|
214
214
|
if (exclusionSet.includes(p))
|
|
215
215
|
{
|
|
216
216
|
continue;
|
|
217
217
|
}
|
|
218
218
|
const tmpDescriptor =
|
|
219
219
|
{
|
|
220
|
-
"Name": `${ this.pict.providers[pProviderHash].getHumanReadableFieldName?.() || p }`,
|
|
220
|
+
"Name": `${ this.pict.providers[pProviderHash].getHumanReadableFieldName?.(p) || p }`,
|
|
221
221
|
"Hash": `${ tmpRecordCreateData.RecordSet }-${ p }`,
|
|
222
222
|
"DataType": "String",
|
|
223
223
|
"PictForm":
|
|
@@ -256,6 +256,17 @@ class viewRecordSetCreate extends libPictRecordSetRecordView
|
|
|
256
256
|
tmpDescriptor.DataType = 'String';
|
|
257
257
|
}
|
|
258
258
|
|
|
259
|
+
const tmpForeignEntity = this.pict.PictSectionRecordSet.resolveForeignEntity(p, pRecordConfiguration, this.pict.providers[this.providerHash].getIDField());
|
|
260
|
+
if (tmpForeignEntity && this.pict.providers['Pict-Section-Picker'])
|
|
261
|
+
{
|
|
262
|
+
// Foreign-key column → searchable entity Picker (edit) + resolved name (read).
|
|
263
|
+
tmpDescriptor.PictForm.InputType = 'Picker';
|
|
264
|
+
tmpDescriptor.PictForm.Entity = tmpForeignEntity;
|
|
265
|
+
const tmpForeignConfig = this.pict.PictSectionRecordSet.recordSetProviderConfigurations[tmpForeignEntity];
|
|
266
|
+
if (tmpForeignConfig && tmpForeignConfig.SearchFields) { tmpDescriptor.PictForm.SearchFields = tmpForeignConfig.SearchFields; }
|
|
267
|
+
}
|
|
268
|
+
// Per-record-set label override (e.g. Contract/Project IDOrganization → "Prime Contractor").
|
|
269
|
+
if (pRecordConfiguration.FieldLabels && pRecordConfiguration.FieldLabels[p]) { tmpDescriptor.Name = pRecordConfiguration.FieldLabels[p]; }
|
|
259
270
|
this.defaultManifest.Descriptors[`${ tmpRecordCreateData.RecordSet }Details.${ p }`] = tmpDescriptor;
|
|
260
271
|
}
|
|
261
272
|
this.pict.TemplateProvider.addTemplate(`PRSP-Create-RecordCreate-Template`, /*html*/`
|
|
@@ -218,6 +218,8 @@ class viewRecordSetDashboard extends libPictRecordSetRecordView
|
|
|
218
218
|
'DeletingIDUser',
|
|
219
219
|
'UpdateDate',
|
|
220
220
|
'UpdatingIDUser',
|
|
221
|
+
'IDCustomer', // meadow-endpoints/retold tenancy discriminator — server-managed, hidden from every record view
|
|
222
|
+
'ExternalSyncDate', 'ExternalSyncGUID', // external-sync auditing stamps (Headlight integration sync)
|
|
221
223
|
];
|
|
222
224
|
|
|
223
225
|
const tmpSchema = pRecordListData.RecordSchema;
|
|
@@ -34,8 +34,16 @@ const _DEFAULT_CONFIGURATION_List_Title =
|
|
|
34
34
|
<header id="PRSP_Title_Container">
|
|
35
35
|
<h1 id="PRSP_Title">{~D:Record.Title~}</h1>
|
|
36
36
|
<h2 id="PRSP_Subtitle">{~D:Record.Subtitle~}</h2>
|
|
37
|
+
{~TIfAbs:PRSP-List-Title-CreateButton-Template:Record:Record.RecordSetConfiguration.RecordSetListShowCreateButton^TRUE^~}
|
|
37
38
|
</header>
|
|
38
39
|
<!-- DefaultPackage end view template: [PRSP-List-Title-Template] -->
|
|
40
|
+
`
|
|
41
|
+
},
|
|
42
|
+
{
|
|
43
|
+
Hash: 'PRSP-List-Title-CreateButton-Template',
|
|
44
|
+
Template: /*html*/`
|
|
45
|
+
<!-- Optional list "New" action; opt in per record set via RecordSetConfiguration.RecordSetListShowCreateButton. -->
|
|
46
|
+
<button type="button" class="prsp-list-title-create" title="Create a new record" onclick="_Pict.views['RSP-RecordSet-List'].createNew('{~D:Record.RecordSet~}')">{~I:Plus~} New</button>
|
|
39
47
|
`
|
|
40
48
|
},
|
|
41
49
|
],
|
|
@@ -189,6 +189,26 @@ class viewRecordSetList extends libPictRecordSetRecordView
|
|
|
189
189
|
return true;
|
|
190
190
|
}
|
|
191
191
|
|
|
192
|
+
/**
|
|
193
|
+
* Navigate to the Create form for a record set. Backs the optional list title-bar "New" button
|
|
194
|
+
* (opt in per record set via RecordSetConfiguration.RecordSetListShowCreateButton). Routes through
|
|
195
|
+
* the section router so it works in both hash and memory router modes; falls back to the active
|
|
196
|
+
* route's record set when called without an argument.
|
|
197
|
+
* @param {string} [pRecordSet] - The record set to create a new record in.
|
|
198
|
+
* @return {boolean} True when navigation was issued.
|
|
199
|
+
*/
|
|
200
|
+
createNew(pRecordSet)
|
|
201
|
+
{
|
|
202
|
+
const tmpRecordSet = pRecordSet || this.fable?.providers?.RecordSetRouter?.pictRouter?.router?.current?.[0]?.data?.RecordSet;
|
|
203
|
+
if (!tmpRecordSet)
|
|
204
|
+
{
|
|
205
|
+
this.log.warn(`RecordSetList: createNew called but no record set could be resolved.`);
|
|
206
|
+
return false;
|
|
207
|
+
}
|
|
208
|
+
this.fable.providers.RecordSetRouter.pictRouter.navigate(`/PSRS/${ tmpRecordSet }/Create`);
|
|
209
|
+
return true;
|
|
210
|
+
}
|
|
211
|
+
|
|
192
212
|
onBeforeRenderList(pRecordListData)
|
|
193
213
|
{
|
|
194
214
|
return pRecordListData;
|
|
@@ -269,6 +289,8 @@ class viewRecordSetList extends libPictRecordSetRecordView
|
|
|
269
289
|
'DeletingIDUser',
|
|
270
290
|
'UpdateDate',
|
|
271
291
|
'UpdatingIDUser',
|
|
292
|
+
'IDCustomer', // meadow-endpoints/retold tenancy discriminator — server-managed, hidden from every record view
|
|
293
|
+
'ExternalSyncDate', 'ExternalSyncGUID', // external-sync auditing stamps (Headlight integration sync)
|
|
272
294
|
];
|
|
273
295
|
}
|
|
274
296
|
|
|
@@ -5,7 +5,9 @@ const libViewAssociationEditor = require('../associate/RecordSet-AssociationEdit
|
|
|
5
5
|
// surfaced through the record audit header (the first-class activity line + the Details
|
|
6
6
|
// modal), not inline in the record body. The entity's own ID/GUID field names are added
|
|
7
7
|
// at suppression time via the provider's getIDField()/getGUIDField().
|
|
8
|
-
|
|
8
|
+
// Audit / sync-bookkeeping fields suppressed from the record body and surfaced in the Details modal.
|
|
9
|
+
// ExternalSyncDate / ExternalSyncGUID are the external-sync auditing stamps (Headlight integration sync).
|
|
10
|
+
const _AUDIT_FIELD_NAMES = ['CreatingIDUser', 'UpdatingIDUser', 'DeletingIDUser', 'Deleted', 'CreateDate', 'UpdateDate', 'DeleteDate', 'ExternalSyncDate', 'ExternalSyncGUID'];
|
|
9
11
|
|
|
10
12
|
/** @type {Record<string, any>} */
|
|
11
13
|
const _DEFAULT_CONFIGURATION__Read = (
|
|
@@ -43,9 +45,10 @@ const _DEFAULT_CONFIGURATION__Read = (
|
|
|
43
45
|
.prsp-audit-button:hover { border-color: var(--theme-color-brand-primary, #156dd1); color: var(--theme-color-brand-primary, #156dd1); }
|
|
44
46
|
.prsp-record-related:empty { display: none; }
|
|
45
47
|
.prsp-audit-anchor { position: relative; }
|
|
46
|
-
.prsp-audit-popover { position: absolute; top: calc(100% + 8px); right: 0; min-width:
|
|
48
|
+
.prsp-audit-popover { position: absolute; top: calc(100% + 8px); right: 0; min-width: 380px; width: max-content; max-width: 90vw; background: var(--theme-color-background-panel, #fff); border: 1px solid var(--theme-color-border-default, #d7dce3); border-radius: 10px; box-shadow: 0 14px 36px rgba(15, 23, 42, 0.18); padding: 1rem 1.1rem; z-index: 40; display: none; }
|
|
47
49
|
.prsp-audit-popover.is-open { display: block; }
|
|
48
|
-
.prsp-audit-dl { display: grid; grid-template-columns: auto
|
|
50
|
+
.prsp-audit-dl { display: grid; grid-template-columns: auto auto; gap: 0.6rem 1rem; align-items: baseline; margin: 0; }
|
|
51
|
+
.prsp-audit-dl dd { white-space: nowrap; }
|
|
49
52
|
.prsp-audit-dl dt { color: var(--theme-color-text-muted, #6b7686); font-size: 0.74rem; text-transform: uppercase; letter-spacing: 0.03em; white-space: nowrap; }
|
|
50
53
|
.prsp-audit-dl dd { margin: 0; color: var(--theme-color-text-primary, #1f2733); font-size: 0.9rem; }
|
|
51
54
|
.prsp-audit-dl dd small { color: var(--theme-color-text-muted, #6b7686); }
|
|
@@ -242,6 +245,7 @@ const _DEFAULT_CONFIGURATION__Read = (
|
|
|
242
245
|
{~TS:PRSP-Read-RecordAudit-Created-Template:AppData.PRSP_RecordAudit.CreatedSlot~}
|
|
243
246
|
{~TS:PRSP-Read-RecordAudit-Updated-Template:AppData.PRSP_RecordAudit.UpdatedSlot~}
|
|
244
247
|
{~TS:PRSP-Read-RecordAudit-Deleted-Template:AppData.PRSP_RecordAudit.DeletedSlot~}
|
|
248
|
+
{~TS:PRSP-Read-RecordAudit-ExternalSync-Template:AppData.PRSP_RecordAudit.ExternalSyncSlot~}
|
|
245
249
|
</dl>
|
|
246
250
|
</div>
|
|
247
251
|
</div>
|
|
@@ -268,6 +272,14 @@ const _DEFAULT_CONFIGURATION__Read = (
|
|
|
268
272
|
Hash: 'PRSP-Read-RecordAudit-Deleted-Template',
|
|
269
273
|
Template: /*html*/`<dt>Deleted</dt><dd class="is-deleted">{~DateFormat:Record.Date^MMM D, YYYY - h:mm A~} <small>by {~E:User^Record.UserID^PRSP-Read-RecordAudit-UserName-Template~}</small></dd>`
|
|
270
274
|
},
|
|
275
|
+
{
|
|
276
|
+
Hash: 'PRSP-Read-RecordAudit-ExternalSync-Template',
|
|
277
|
+
Template: /*html*/`<dt>External sync</dt><dd>{~TIfAbs:PRSP-Read-RecordAudit-ExternalSyncDate-Template:Record:Record.HasDate^TRUE^~}{~NE:Record.NeverSynced^Never synced~}</dd>`
|
|
278
|
+
},
|
|
279
|
+
{
|
|
280
|
+
Hash: 'PRSP-Read-RecordAudit-ExternalSyncDate-Template',
|
|
281
|
+
Template: /*html*/`{~DateFormat:Record.Date^MMM D, YYYY - h:mm A~}`
|
|
282
|
+
},
|
|
271
283
|
{
|
|
272
284
|
// Soft-deleted record banner (the ViewDeleted route, or a record deleted out from
|
|
273
285
|
// under a normal View). The one-or-zero-element DeletedBannerSlot drives it.
|
|
@@ -518,7 +530,9 @@ class viewRecordSetRead extends libPictRecordSetRecordView
|
|
|
518
530
|
DeletedSlot: tmpDeleted ? [{ Date: pRecord.DeleteDate, UserID: pRecord.DeletingIDUser }] : [],
|
|
519
531
|
// The ViewDeleted route's banner: present whenever the record is soft-deleted (whether the
|
|
520
532
|
// user arrived via ViewDeleted or the record was deleted out from under a normal View).
|
|
521
|
-
DeletedBannerSlot: (!!pRecord.Deleted) ? [{ Date: pRecord.DeleteDate, UserID: pRecord.DeletingIDUser, HasDate: this._validAuditDate(pRecord.DeleteDate) }] : []
|
|
533
|
+
DeletedBannerSlot: (!!pRecord.Deleted) ? [{ Date: pRecord.DeleteDate, UserID: pRecord.DeletingIDUser, HasDate: this._validAuditDate(pRecord.DeleteDate) }] : [],
|
|
534
|
+
// External-sync auditing stamp — surfaced in the Details modal for entities that carry the field.
|
|
535
|
+
ExternalSyncSlot: ('ExternalSyncDate' in pRecord) ? [{ Date: pRecord.ExternalSyncDate, HasDate: this._validAuditDate(pRecord.ExternalSyncDate), NeverSynced: !this._validAuditDate(pRecord.ExternalSyncDate) }] : []
|
|
522
536
|
};
|
|
523
537
|
}
|
|
524
538
|
|
|
@@ -657,7 +671,7 @@ class viewRecordSetRead extends libPictRecordSetRecordView
|
|
|
657
671
|
return;
|
|
658
672
|
}
|
|
659
673
|
const tmpProvider = this.pict.providers[this.providerHash];
|
|
660
|
-
const tmpSuppress = _AUDIT_FIELD_NAMES.
|
|
674
|
+
const tmpSuppress = _AUDIT_FIELD_NAMES.concat('IDCustomer'); // IDCustomer: meadow-endpoints/retold tenancy discriminator — server-managed
|
|
661
675
|
if (tmpProvider)
|
|
662
676
|
{
|
|
663
677
|
tmpSuppress.push(tmpProvider.getIDField());
|
|
@@ -963,14 +977,14 @@ class viewRecordSetRead extends libPictRecordSetRecordView
|
|
|
963
977
|
const schema = await this.pict.providers[providerHash].getRecordSchema();
|
|
964
978
|
for (const p of Object.keys(schema.properties))
|
|
965
979
|
{
|
|
966
|
-
const exclusionSet = [this.pict.providers[this.providerHash].getIDField(), this.pict.providers[this.providerHash].getGUIDField()].concat(_AUDIT_FIELD_NAMES);
|
|
980
|
+
const exclusionSet = [this.pict.providers[this.providerHash].getIDField(), this.pict.providers[this.providerHash].getGUIDField()].concat(_AUDIT_FIELD_NAMES, 'IDCustomer');
|
|
967
981
|
if (exclusionSet.includes(p))
|
|
968
982
|
{
|
|
969
983
|
continue;
|
|
970
984
|
}
|
|
971
985
|
const tmpDescriptor =
|
|
972
986
|
{
|
|
973
|
-
"Name": `${ this.pict.providers[providerHash].getHumanReadableFieldName?.() || p }`,
|
|
987
|
+
"Name": `${ this.pict.providers[providerHash].getHumanReadableFieldName?.(p) || p }`,
|
|
974
988
|
"Hash": `${ this.pict.providers[this.providerHash].options.Entity }-${ p }`,
|
|
975
989
|
"DataType": "String",
|
|
976
990
|
"PictForm":
|
|
@@ -1009,6 +1023,28 @@ class viewRecordSetRead extends libPictRecordSetRecordView
|
|
|
1009
1023
|
tmpDescriptor.DataType = 'String';
|
|
1010
1024
|
}
|
|
1011
1025
|
|
|
1026
|
+
const tmpForeignEntity = this.pict.PictSectionRecordSet.resolveForeignEntity(p, this.pict.PictSectionRecordSet.recordSetProviderConfigurations[recordSet], this.pict.providers[this.providerHash].getIDField());
|
|
1027
|
+
if (tmpForeignEntity && this.pict.providers['Pict-Section-Picker'])
|
|
1028
|
+
{
|
|
1029
|
+
// Foreign-key column → searchable entity Picker (edit) + resolved name (read, see the view-mode override).
|
|
1030
|
+
tmpDescriptor.PictForm.InputType = 'Picker';
|
|
1031
|
+
tmpDescriptor.PictForm.Entity = tmpForeignEntity;
|
|
1032
|
+
const tmpForeignConfig = this.pict.PictSectionRecordSet.recordSetProviderConfigurations[tmpForeignEntity];
|
|
1033
|
+
if (tmpForeignConfig && tmpForeignConfig.SearchFields) { tmpDescriptor.PictForm.SearchFields = tmpForeignConfig.SearchFields; }
|
|
1034
|
+
}
|
|
1035
|
+
// Per-record-set label override (e.g. Contract/Project IDOrganization → "Prime Contractor").
|
|
1036
|
+
const tmpReadRSConfig = this.pict.PictSectionRecordSet.recordSetProviderConfigurations[recordSet];
|
|
1037
|
+
if (tmpReadRSConfig && tmpReadRSConfig.FieldLabels && tmpReadRSConfig.FieldLabels[p]) { tmpDescriptor.Name = tmpReadRSConfig.FieldLabels[p]; }
|
|
1038
|
+
// JSON/object columns → embedded ObjectEditor (read-only tree in View, editable in Edit). Driven by the
|
|
1039
|
+
// schema column type, with a per-record-set ObjectEditorFields list to opt in string-typed JSON blobs.
|
|
1040
|
+
// Gated on the pict-section-form ObjectEditor input being registered (graceful on older form versions).
|
|
1041
|
+
const tmpObjectEditorFields = (tmpReadRSConfig && Array.isArray(tmpReadRSConfig.ObjectEditorFields)) ? tmpReadRSConfig.ObjectEditorFields : [];
|
|
1042
|
+
if (tmpDescriptor.PictForm.InputType !== 'Picker' && this.pict.providers['Pict-Input-ObjectEditor'] &&
|
|
1043
|
+
(schema.properties[p].type === 'object' || schema.properties[p].type === 'json' || tmpObjectEditorFields.includes(p)))
|
|
1044
|
+
{
|
|
1045
|
+
tmpDescriptor.DataType = 'Object';
|
|
1046
|
+
tmpDescriptor.PictForm.InputType = 'ObjectEditor';
|
|
1047
|
+
}
|
|
1012
1048
|
defaultManifest.Descriptors[`${ recordSet }Details.${ p }`] = tmpDescriptor;
|
|
1013
1049
|
}
|
|
1014
1050
|
return defaultManifest;
|
|
@@ -1039,6 +1075,11 @@ class viewRecordSetRead extends libPictRecordSetRecordView
|
|
|
1039
1075
|
{
|
|
1040
1076
|
tmpManifest.Descriptors[x].PictForm = {};
|
|
1041
1077
|
}
|
|
1078
|
+
// Entity-reference (Picker) fields stay a Picker but render read-only (the resolved entity
|
|
1079
|
+
// name as plain text, no dropdown); everything else becomes plain read-only text.
|
|
1080
|
+
if (tmpManifest.Descriptors[x].PictForm.InputType === 'Picker') { tmpManifest.Descriptors[x].PictForm.ReadOnly = true; continue; }
|
|
1081
|
+
// ObjectEditor stays an ObjectEditor but renders its read-only tree (not a plain text field).
|
|
1082
|
+
if (tmpManifest.Descriptors[x].PictForm.InputType === 'ObjectEditor') { tmpManifest.Descriptors[x].PictForm.ReadOnly = true; continue; }
|
|
1042
1083
|
tmpManifest.Descriptors[x].PictForm.InputType = 'ReadOnly';
|
|
1043
1084
|
}
|
|
1044
1085
|
}
|