pict-section-recordset 1.11.1 → 1.18.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.
@@ -5,12 +5,16 @@ const ViewRecordSetList = require('../views/list/RecordSet-List.js');
5
5
  const ViewRecordSetRead = require('../views/read/RecordSet-Read.js');
6
6
  const ViewRecordSetCreate = require('../views/create/RecordSet-Create.js');
7
7
  const ViewRecordSetDashboard = require('../views/dashboard/RecordSet-Dashboard.js');
8
+ const ViewRecordSetAssociate = require('../views/associate/RecordSet-AssociateBulk.js');
9
+ const ViewRecordSetAssociateMatrix = require('../views/associate/RecordSet-AssociateMatrix.js');
10
+ const ViewRecordSetAssociateUnlink = require('../views/associate/RecordSet-AssociateUnlink.js');
8
11
 
9
12
  //_Pict.addProvider('BooksProvider', { Entity: 'Book', URLPrefix: 'http://www.datadebase.com:8086/1.0/' }, require('../source/providers/RecordSet-RecordProvider-MeadowEndpoints.js'));
10
13
  const ProviderBase = require('../providers/RecordSet-RecordProvider-Base.js');
11
14
  const ProviderMeadowEndpoints = require('../providers/RecordSet-RecordProvider-MeadowEndpoints.js');
12
15
 
13
16
  const ProviderLinkManager = require('../providers/RecordSet-Link-Manager.js');
17
+ const ProviderAssociationManager = require('../providers/RecordSet-AssociationManager.js');
14
18
  const libProviderColumnData = require('../providers/Column-Data-Provider.js');
15
19
 
16
20
  const ProviderRouter = require('../providers/RecordSet-Router.js');
@@ -452,6 +456,10 @@ class RecordSetMetacontroller extends libFableServiceProviderBase
452
456
 
453
457
  this.fable.addProvider('RecordSetLinkManager', {}, ProviderLinkManager);
454
458
 
459
+ // Joined-entity association manager — the registry + data layer behind the Association read-tab
460
+ // and the Bulk Associate screen. Associations are parsed from settings.Associations below.
461
+ this.fable.addProvider('RecordSetAssociationManager', {}, ProviderAssociationManager);
462
+
455
463
  // Column visibility persistence — only register the built-in localStorage provider when the
456
464
  // host hasn't supplied its own (the documented seam for server-side per-user persistence).
457
465
  if (!('ColumnDataProvider' in this.fable.providers))
@@ -468,12 +476,18 @@ class RecordSetMetacontroller extends libFableServiceProviderBase
468
476
  this.childViews.read = this.fable.addView('RSP-RecordSet-Read', this.options, ViewRecordSetRead);
469
477
  this.childViews.create = this.fable.addView('RSP-RecordSet-Create', this.options, ViewRecordSetCreate);
470
478
  this.childViews.dashboard = this.fable.addView('RSP-RecordSet-Dashboard', this.options, ViewRecordSetDashboard);
479
+ this.childViews.associate = this.fable.addView('RSP-RecordSet-Associate', this.options, ViewRecordSetAssociate);
480
+ this.childViews.associateMatrix = this.fable.addView('RSP-RecordSet-AssociateMatrix', this.options, ViewRecordSetAssociateMatrix);
481
+ this.childViews.associateUnlink = this.fable.addView('RSP-RecordSet-AssociateUnlink', this.options, ViewRecordSetAssociateUnlink);
471
482
 
472
483
  // Initialize the subviews
473
484
  this.childViews.list.initialize();
474
485
  this.childViews.read.initialize();
475
486
  this.childViews.create.initialize();
476
487
  this.childViews.dashboard.initialize();
488
+ this.childViews.associate.initialize();
489
+ this.childViews.associateMatrix.initialize();
490
+ this.childViews.associateUnlink.initialize();
477
491
 
478
492
  // Now initialize the router
479
493
 
@@ -501,6 +515,14 @@ class RecordSetMetacontroller extends libFableServiceProviderBase
501
515
  }
502
516
  }
503
517
 
518
+ if (this.fable.settings.hasOwnProperty('Associations') && typeof this.fable.settings.Associations === 'object')
519
+ {
520
+ for (const tmpAssociationKey of Object.keys(this.fable.settings.Associations))
521
+ {
522
+ this.pict.providers.RecordSetAssociationManager.addAssociation(tmpAssociationKey, this.fable.settings.Associations[tmpAssociationKey]);
523
+ }
524
+ }
525
+
504
526
  if (this.fable.settings.hasOwnProperty('DefaultRecordSetConfigurations'))
505
527
  {
506
528
  this.loadRecordSetConfigurationArray(this.fable.settings.DefaultRecordSetConfigurations);
@@ -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;