pict-section-recordset 1.11.1 → 1.17.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.
@@ -0,0 +1,449 @@
1
+ const libPictView = require('pict-view');
2
+
3
+ /**
4
+ * The Bulk Associate screen — a purpose-built page for bulk join operations ("assign these books to
5
+ * THIS store"). The anchor recordset (the route's `:RecordSet`) is one side of the association; the
6
+ * user picks an anchor record, multi-selects many other-side records (currently-associated rows culled
7
+ * out), and creates all the joins at once. Current associations are listed below, each removable.
8
+ *
9
+ * Registered ONCE by the metacontroller as `RSP-RecordSet-Associate` and parameterized by route:
10
+ * /PSRS/:RecordSet/Associate/:Association
11
+ * /PSRS/:RecordSet/Associate/:Association/:AnchorID
12
+ *
13
+ * Light opt-in: a recordset advertises the screen in its nav by listing `RecordSetBulkAssociations`,
14
+ * but the route works for any association whose side matches `:RecordSet`. All data flows through the
15
+ * shared `RecordSetAssociationManager`; pickers come from `pict-section-picker` (soft dependency).
16
+ */
17
+
18
+ /** @type {Record<string, any>} */
19
+ const _DEFAULT_CONFIGURATION_AssociateBulk = (
20
+ {
21
+ ViewIdentifier: 'PRSP-AssociateBulk',
22
+
23
+ DefaultRenderable: 'PRSP_Renderable_AssociateBulk',
24
+ DefaultDestinationAddress: '#PRSP_Container',
25
+ DefaultTemplateRecordAddress: false,
26
+
27
+ AutoInitialize: false,
28
+ AutoInitializeOrdinal: 0,
29
+ AutoRender: false,
30
+ AutoRenderOrdinal: 0,
31
+ AutoSolveWithApp: false,
32
+ AutoSolveOrdinal: 0,
33
+
34
+ CSS: /*css*/`
35
+ .prsp-bulk { display: flex; flex-direction: column; gap: 1.1rem; padding: 0.25rem 0 1rem; }
36
+ .prsp-bulk-header h2 { margin: 0 0 0.2rem; font-size: 1.25rem; color: var(--theme-color-text-primary, #1f2733); }
37
+ .prsp-bulk-sub { margin: 0; color: var(--theme-color-text-muted, #6b7686); font-size: 0.92rem; }
38
+ .prsp-bulk-card { border: 1px solid var(--theme-color-border-light, #e8ebf0); border-radius: 12px; padding: 0.9rem 1rem; background: var(--theme-color-background-primary, #fff); display: flex; flex-direction: column; gap: 0.5rem; }
39
+ .prsp-bulk-card-label { font-size: 0.72rem; font-weight: 650; text-transform: uppercase; letter-spacing: 0.05em; color: var(--theme-color-text-muted, #6b7686); }
40
+ .prsp-bulk-picker-host { max-width: 520px; }
41
+ .prsp-bulk-actions { display: flex; align-items: center; gap: 0.6rem; }
42
+ .prsp-bulk-associate { display: inline-flex; align-items: center; gap: 0.4rem; cursor: pointer; font: inherit; font-size: 0.92rem; font-weight: 600;
43
+ padding: 0.5rem 0.95rem; border-radius: 8px; border: 1px solid var(--theme-color-brand-primary, #156dd1);
44
+ background: var(--theme-color-brand-primary, #156dd1); color: #fff; }
45
+ .prsp-bulk-associate:hover { background: var(--theme-color-brand-primary-hover, #1259ad); }
46
+ .prsp-bulk-hint { color: var(--theme-color-text-muted, #6b7686); font-size: 0.92rem; font-style: italic; padding: 0.5rem 0.2rem; }
47
+ .prsp-bulk-note { color: var(--theme-color-status-error, #b62828); font-size: 0.86rem; }
48
+ .prsp-bulk-list-head { display: flex; align-items: baseline; justify-content: space-between; gap: 1rem; }
49
+ .prsp-bulk-list-title { font-size: 0.72rem; font-weight: 650; text-transform: uppercase; letter-spacing: 0.05em; color: var(--theme-color-text-muted, #6b7686); }
50
+ .prsp-bulk-count { font-size: 0.78rem; color: var(--theme-color-text-muted, #6b7686); }
51
+ .prsp-bulk-list { display: flex; flex-direction: column; gap: 0.3rem; margin-top: 0.3rem; }
52
+ .prsp-bulk-row { display: flex; align-items: center; gap: 0.6rem; padding: 0.45rem 0.6rem; border: 1px solid var(--theme-color-border-light, #e8ebf0); border-radius: 8px; background: var(--theme-color-background-primary, #fff); }
53
+ .prsp-bulk-row-name { flex: 1 1 auto; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--theme-color-text-primary, #1f2733); font-size: 0.92rem; }
54
+ .prsp-bulk-row-id { flex: 0 0 auto; font-size: 0.74rem; color: var(--theme-color-text-muted, #6b7686); font-family: ui-monospace, SFMono-Regular, Menlo, monospace; }
55
+ .prsp-bulk-remove { flex: 0 0 auto; display: inline-flex; align-items: center; cursor: pointer; border: none; background: transparent; color: var(--theme-color-text-muted, #6b7686); padding: 0.2rem; border-radius: 5px; font: inherit; }
56
+ .prsp-bulk-remove:hover { color: var(--theme-color-status-error, #b62828); background: color-mix(in srgb, var(--theme-color-status-error, #b62828) 10%, transparent); }
57
+ .prsp-bulk-empty { padding: 0.6rem 0.2rem; color: var(--theme-color-text-muted, #6b7686); font-size: 0.88rem; font-style: italic; }
58
+ `,
59
+ CSSPriority: 500,
60
+
61
+ Templates:
62
+ [
63
+ {
64
+ Hash: 'PRSP-AssociateBulk-Template',
65
+ Template: /*html*/`
66
+ <!-- DefaultPackage pict view template: [PRSP-AssociateBulk-Template] -->
67
+ <div class="prsp-bulk">
68
+ <div class="prsp-bulk-header">
69
+ <h2>{~D:Record.Title~}</h2>
70
+ <p class="prsp-bulk-sub">{~D:Record.Subtitle~}</p>
71
+ </div>
72
+ <div class="prsp-bulk-card">
73
+ <span class="prsp-bulk-card-label">{~D:Record.AnchorLabel~}</span>
74
+ <div class="prsp-bulk-picker-host" id="{~D:Record.AnchorPickerHostID~}"></div>
75
+ {~NE:Record.PickerMissing^<div class="prsp-bulk-note">The entity picker (pict-section-picker) is not registered, so this screen cannot run.</div>~}
76
+ </div>
77
+ {~TS:PRSP-AssociateBulk-Hint:Record.HintSlot~}
78
+ {~TS:PRSP-AssociateBulk-Body:Record.BodySlot~}
79
+ </div>
80
+ <!-- DefaultPackage end view template: [PRSP-AssociateBulk-Template] -->
81
+ `
82
+ },
83
+ {
84
+ Hash: 'PRSP-AssociateBulk-Hint',
85
+ Template: /*html*/`
86
+ <div class="prsp-bulk-hint">{~D:Record.Hint~}</div>
87
+ `
88
+ },
89
+ {
90
+ Hash: 'PRSP-AssociateBulk-Body',
91
+ Template: /*html*/`
92
+ <div class="prsp-bulk-card">
93
+ <span class="prsp-bulk-card-label">{~D:Record.AddLabel~}</span>
94
+ <div class="prsp-bulk-picker-host" id="{~D:Record.CandidatePickerHostID~}"></div>
95
+ <div class="prsp-bulk-actions">
96
+ <button type="button" class="prsp-bulk-associate" onclick="_Pict.views['RSP-RecordSet-Associate'].associateStaged()">{~I:Plus~} {~D:Record.AssociateLabel~}</button>
97
+ </div>
98
+ </div>
99
+ <div class="prsp-bulk-card">
100
+ <div class="prsp-bulk-list-head">
101
+ <span class="prsp-bulk-list-title">{~D:Record.ListLabel~}</span>
102
+ <span class="prsp-bulk-count">{~D:Record.Count~}</span>
103
+ </div>
104
+ <div class="prsp-bulk-list">
105
+ {~TS:PRSP-AssociateBulk-Row:Record.Items~}
106
+ {~TS:PRSP-AssociateBulk-Empty:Record.EmptySlot~}
107
+ </div>
108
+ </div>
109
+ `
110
+ },
111
+ {
112
+ Hash: 'PRSP-AssociateBulk-Empty',
113
+ Template: /*html*/`<div class="prsp-bulk-empty">{~D:Record.EmptyText~}</div>`
114
+ },
115
+ {
116
+ Hash: 'PRSP-AssociateBulk-Row',
117
+ Template: /*html*/`
118
+ <div class="prsp-bulk-row">
119
+ <span class="prsp-bulk-row-name">{~D:Record.Display~}</span>
120
+ <span class="prsp-bulk-row-id">#{~D:Record.OtherID~}</span>
121
+ <button type="button" class="prsp-bulk-remove" title="Remove association" onclick="_Pict.views['RSP-RecordSet-Associate'].removeItem({~D:Record.JoinID~})">{~I:Trash~}</button>
122
+ </div>
123
+ `
124
+ }
125
+ ],
126
+
127
+ Renderables:
128
+ [
129
+ {
130
+ RenderableHash: 'PRSP_Renderable_AssociateBulk',
131
+ TemplateHash: 'PRSP-AssociateBulk-Template',
132
+ ContentDestinationAddress: '#PRSP_Container',
133
+ RenderMethod: 'replace'
134
+ }
135
+ ],
136
+
137
+ Manifests: {}
138
+ });
139
+
140
+ class viewRecordSetAssociateBulk extends libPictView
141
+ {
142
+ constructor(pFable, pOptions, pServiceHash)
143
+ {
144
+ let tmpOptions = Object.assign({}, _DEFAULT_CONFIGURATION_AssociateBulk, pOptions);
145
+ super(pFable, tmpOptions, pServiceHash);
146
+
147
+ /** @type {import('pict') & { PictSectionRecordSet: any }} */
148
+ this.pict;
149
+
150
+ this._anchorRecordSet = null;
151
+ this._associationHash = null;
152
+ this._anchorID = null;
153
+ this._currentItems = [];
154
+ this._currentJoinedIDs = [];
155
+ }
156
+
157
+ /** @return {any} The association manager provider. */
158
+ get manager()
159
+ {
160
+ return this.pict.providers.RecordSetAssociationManager;
161
+ }
162
+
163
+ addRoutes(pPictRouter)
164
+ {
165
+ pPictRouter.router.on('/PSRS/:RecordSet/Associate/:Association/:AnchorID', this.handleAssociateRoute.bind(this));
166
+ pPictRouter.router.on('/PSRS/:RecordSet/Associate/:Association', this.handleAssociateRoute.bind(this));
167
+ return true;
168
+ }
169
+
170
+ /**
171
+ * Route handler — parse the anchor recordset, association, and optional preset anchor id, then paint.
172
+ * @param {Record<string, any>} pRoutePayload
173
+ */
174
+ handleAssociateRoute(pRoutePayload)
175
+ {
176
+ if (typeof(pRoutePayload) != 'object')
177
+ {
178
+ throw new Error(`Pict RecordSet Associate route handler called with invalid route payload.`);
179
+ }
180
+ this._anchorRecordSet = pRoutePayload.data.RecordSet;
181
+ this._associationHash = pRoutePayload.data.Association;
182
+ this._anchorID = (pRoutePayload.data.AnchorID !== undefined && pRoutePayload.data.AnchorID !== '') ? pRoutePayload.data.AnchorID : null;
183
+ this._currentItems = [];
184
+ this._currentJoinedIDs = [];
185
+
186
+ // Use the recordset's RecordSetBulkAssociations Title (the opt-in label) as the screen title.
187
+ this.options.ScreenTitle = false;
188
+ const tmpRecordSetConfiguration = this.pict.PictSectionRecordSet
189
+ ? this.pict.PictSectionRecordSet.recordSetProviderConfigurations[this._anchorRecordSet] : null;
190
+ const tmpBulkConfiguration = (tmpRecordSetConfiguration && Array.isArray(tmpRecordSetConfiguration.RecordSetBulkAssociations))
191
+ ? tmpRecordSetConfiguration.RecordSetBulkAssociations.find((pEntry) => pEntry.Association === this._associationHash) : null;
192
+ if (tmpBulkConfiguration && tmpBulkConfiguration.Title)
193
+ {
194
+ this.options.ScreenTitle = tmpBulkConfiguration.Title;
195
+ }
196
+
197
+ return this.renderScreen();
198
+ }
199
+
200
+ /** A DOM/address-safe key for this screen's pickers. */
201
+ get safeKey()
202
+ {
203
+ return `${String(this._anchorRecordSet)}_${String(this._associationHash)}`.replace(/[^A-Za-z0-9]/g, '_');
204
+ }
205
+
206
+ /**
207
+ * Load the current associations (when an anchor is selected) and paint the screen, then mount the
208
+ * anchor + candidate pickers.
209
+ * @return {Promise<boolean>}
210
+ */
211
+ async renderScreen()
212
+ {
213
+ const tmpSides = this.manager ? this.manager.resolveSides(this._associationHash, this._anchorRecordSet) : false;
214
+ if (!tmpSides)
215
+ {
216
+ this.pict.log.warn(`AssociateBulk: association [${this._associationHash}] could not be resolved for [${this._anchorRecordSet}].`);
217
+ return false;
218
+ }
219
+
220
+ const tmpAnchorLabel = tmpSides.thisSide.Title || tmpSides.thisSide.RecordSet || tmpSides.thisSide.Entity;
221
+ const tmpOtherLabel = tmpSides.otherSide.Title || tmpSides.otherSide.RecordSet || tmpSides.otherSide.Entity;
222
+ const tmpHasAnchor = (this._anchorID !== undefined && this._anchorID !== null && this._anchorID !== '');
223
+
224
+ let tmpItems = [];
225
+ if (tmpHasAnchor)
226
+ {
227
+ tmpItems = await this.manager.listAssociatedRecords(this._associationHash, this._anchorRecordSet, this._anchorID);
228
+ }
229
+ this._currentItems = tmpItems;
230
+ this._currentJoinedIDs = tmpItems.map((pItem) => pItem.OtherID);
231
+
232
+ const tmpBodyData = {
233
+ AddLabel: `Add ${tmpOtherLabel}`,
234
+ CandidatePickerHostID: `${this.safeKey}_Candidate`,
235
+ AssociateLabel: `Associate selected`,
236
+ ListLabel: `Current ${tmpOtherLabel}`,
237
+ Count: `${tmpItems.length} ${tmpItems.length === 1 ? 'record' : 'records'}`,
238
+ Items: tmpItems,
239
+ // One-or-zero-element slot drives the empty-state line (TS parses inner tags; NE would not).
240
+ EmptySlot: (tmpItems.length === 0) ? [ { EmptyText: `No ${tmpOtherLabel} associated with this ${tmpAnchorLabel} yet.` } ] : [],
241
+ };
242
+
243
+ const tmpRecord =
244
+ {
245
+ Title: this.options.ScreenTitle || `Assign ${tmpOtherLabel} to ${tmpAnchorLabel}`,
246
+ Subtitle: `Choose one of the ${tmpAnchorLabel}, then search and add ${tmpOtherLabel}. Already-added ${tmpOtherLabel} are hidden from the search.`,
247
+ AnchorLabel: `${tmpAnchorLabel}`,
248
+ AnchorPickerHostID: `${this.safeKey}_Anchor`,
249
+ PickerMissing: !this.pict.providers['Pict-Section-Picker'],
250
+ HintSlot: tmpHasAnchor ? [] : [ { Hint: `Choose one of the ${tmpAnchorLabel} above to begin.` } ],
251
+ BodySlot: tmpHasAnchor ? [ tmpBodyData ] : [],
252
+ };
253
+
254
+ return new Promise((resolve) =>
255
+ {
256
+ this.renderAsync(this.options.DefaultRenderable, this.options.DefaultDestinationAddress, tmpRecord,
257
+ (pError) =>
258
+ {
259
+ if (pError)
260
+ {
261
+ this.pict.log.error(`AssociateBulk: render error.`, pError);
262
+ return resolve(false);
263
+ }
264
+ this._mountAnchorPicker(tmpSides, tmpRecord.AnchorPickerHostID);
265
+ if (tmpHasAnchor)
266
+ {
267
+ this._mountCandidatePicker(tmpSides, tmpBodyData.CandidatePickerHostID);
268
+ }
269
+ this.pict.CSSMap.injectCSS();
270
+ return resolve(true);
271
+ });
272
+ });
273
+ }
274
+
275
+ /**
276
+ * Mount the anchor (this side) picker — single select, preselected to the current anchor.
277
+ * @param {Record<string, any>} pSides @param {string} pHostID
278
+ */
279
+ _mountAnchorPicker(pSides, pHostID)
280
+ {
281
+ const tmpPickerProvider = this.pict.providers['Pict-Section-Picker'];
282
+ if (!tmpPickerProvider)
283
+ {
284
+ return;
285
+ }
286
+ const tmpPickerHash = `${this.safeKey}_AnchorPicker`;
287
+ const tmpValueAddress = `AppData.PRSPBulkAnchor.${this.safeKey}`;
288
+
289
+ const tmpConfig = this.manager.buildAnchorPickerConfig(this._associationHash, this._anchorRecordSet,
290
+ {
291
+ DestinationAddress: `#${pHostID}`,
292
+ ValueAddress: tmpValueAddress,
293
+ Placeholder: `Search ${pSides.thisSide.Title || pSides.thisSide.RecordSet || pSides.thisSide.Entity}…`,
294
+ OnChange: (pValue) => { this.selectAnchor(pValue); },
295
+ });
296
+ if (!tmpConfig)
297
+ {
298
+ return;
299
+ }
300
+ tmpPickerProvider.createEntityPicker(tmpPickerHash, tmpConfig);
301
+ // setValue (not render) so the picker (re)seeds + resolves the preset anchor's display via
302
+ // ResolveValue — its init-time resolution does not re-run when the instance is reused across
303
+ // re-renders (e.g. the deep-link /Associate/:RecordSet/:Association/:AnchorID route). setValue
304
+ // does not fire OnChange, so there's no selectAnchor loop.
305
+ this.pict.views[tmpPickerHash].setValue((this._anchorID !== undefined && this._anchorID !== null) ? this._anchorID : null);
306
+ }
307
+
308
+ /**
309
+ * Mount the candidate (other side) MULTI picker — culls the currently-associated ids.
310
+ * @param {Record<string, any>} pSides @param {string} pHostID
311
+ */
312
+ _mountCandidatePicker(pSides, pHostID)
313
+ {
314
+ const tmpPickerProvider = this.pict.providers['Pict-Section-Picker'];
315
+ if (!tmpPickerProvider)
316
+ {
317
+ return;
318
+ }
319
+ const tmpPickerHash = `${this.safeKey}_CandidatePicker`;
320
+ const tmpValueAddress = `AppData.PRSPBulkStaged.${this.safeKey}`;
321
+ if (!this.pict.AppData.PRSPBulkStaged) { this.pict.AppData.PRSPBulkStaged = {}; }
322
+ this.pict.AppData.PRSPBulkStaged[this.safeKey] = [];
323
+
324
+ const tmpConfig = this.manager.buildOtherPickerConfig(this._associationHash, this._anchorRecordSet, () => this._currentJoinedIDs,
325
+ {
326
+ Mode: 'multi',
327
+ DestinationAddress: `#${pHostID}`,
328
+ ValueAddress: tmpValueAddress,
329
+ Placeholder: `Search ${pSides.otherSide.Title || pSides.otherSide.RecordSet || pSides.otherSide.Entity} to add…`,
330
+ });
331
+ if (!tmpConfig)
332
+ {
333
+ return;
334
+ }
335
+ tmpPickerProvider.createEntityPicker(tmpPickerHash, tmpConfig);
336
+ this.pict.views[tmpPickerHash].render();
337
+ }
338
+
339
+ /**
340
+ * The anchor picker's OnChange — switch the anchor and repaint (loads its current associations and
341
+ * mounts the candidate picker).
342
+ * @param {string|number} pAnchorID
343
+ * @return {Promise<void>}
344
+ */
345
+ async selectAnchor(pAnchorID)
346
+ {
347
+ this._anchorID = (pAnchorID !== undefined && pAnchorID !== '') ? pAnchorID : null;
348
+ await this.renderScreen();
349
+ }
350
+
351
+ /**
352
+ * Create joins for every staged candidate (the "Associate selected" button), then clear + repaint.
353
+ * @return {Promise<void>}
354
+ */
355
+ async associateStaged()
356
+ {
357
+ if (this._anchorID === undefined || this._anchorID === null || this._anchorID === '')
358
+ {
359
+ return;
360
+ }
361
+ const tmpStaged = (this.pict.AppData.PRSPBulkStaged && Array.isArray(this.pict.AppData.PRSPBulkStaged[this.safeKey]))
362
+ ? this.pict.AppData.PRSPBulkStaged[this.safeKey].slice() : [];
363
+ if (tmpStaged.length < 1)
364
+ {
365
+ this._toast('Select one or more records to associate first.', 'info');
366
+ return;
367
+ }
368
+ let tmpFailures = 0;
369
+ for (let i = 0; i < tmpStaged.length; i++)
370
+ {
371
+ try
372
+ {
373
+ await this.manager.createJoin(this._associationHash, this._anchorRecordSet, this._anchorID, tmpStaged[i]);
374
+ }
375
+ catch (pError)
376
+ {
377
+ tmpFailures++;
378
+ this.pict.log.error(`AssociateBulk: failed to create join for ${tmpStaged[i]}.`, pError);
379
+ }
380
+ }
381
+ const tmpCreated = tmpStaged.length - tmpFailures;
382
+ if (tmpCreated > 0)
383
+ {
384
+ this._toast(`Associated ${tmpCreated} ${tmpCreated === 1 ? 'record' : 'records'}.`, 'success');
385
+ }
386
+ if (tmpFailures > 0)
387
+ {
388
+ this._toast(`${tmpFailures} association(s) could not be created.`, 'error');
389
+ }
390
+ await this.renderScreen();
391
+ }
392
+
393
+ /**
394
+ * Remove one current association (a row's remove button) — confirm, delete, repaint.
395
+ * @param {string|number} pJoinID
396
+ * @return {Promise<void>}
397
+ */
398
+ async removeItem(pJoinID)
399
+ {
400
+ const tmpItem = this._currentItems.find((pItem) => String(pItem.JoinID) === String(pJoinID));
401
+ if (!tmpItem)
402
+ {
403
+ return;
404
+ }
405
+ const fRemove = async () =>
406
+ {
407
+ try
408
+ {
409
+ await this.manager.removeJoin(this._associationHash, tmpItem.JoinRecord);
410
+ }
411
+ catch (pError)
412
+ {
413
+ this.pict.log.error(`AssociateBulk: failed to remove association.`, pError);
414
+ this._toast('Could not remove the association.', 'error');
415
+ return;
416
+ }
417
+ await this.renderScreen();
418
+ };
419
+
420
+ const tmpModal = this.pict.views['Pict-Section-Modal'];
421
+ if (tmpModal && typeof tmpModal.confirm === 'function')
422
+ {
423
+ const tmpOk = await tmpModal.confirm(`Remove the association with "${tmpItem.Display}"?`,
424
+ { title: 'Remove association', confirmLabel: 'Remove', cancelLabel: 'Cancel', dangerous: true });
425
+ if (!tmpOk)
426
+ {
427
+ return;
428
+ }
429
+ }
430
+ return fRemove();
431
+ }
432
+
433
+ /**
434
+ * Non-blocking notification via the host modal's toast, when available.
435
+ * @param {string} pMessage @param {string} pType
436
+ */
437
+ _toast(pMessage, pType)
438
+ {
439
+ const tmpModal = this.pict.views['Pict-Section-Modal'];
440
+ if (tmpModal && typeof tmpModal.toast === 'function')
441
+ {
442
+ tmpModal.toast(pMessage, { type: pType || 'info' });
443
+ }
444
+ }
445
+ }
446
+
447
+ module.exports = viewRecordSetAssociateBulk;
448
+
449
+ module.exports.default_configuration = _DEFAULT_CONFIGURATION_AssociateBulk;