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.
- package/README.md +13 -0
- package/package.json +1 -1
- package/source/Pict-Section-RecordSet.js +7 -0
- package/source/providers/RecordSet-AssociationManager.js +588 -0
- package/source/providers/RecordSet-Router.js +3 -0
- package/source/services/RecordsSet-MetaController.js +22 -0
- package/source/views/associate/RecordSet-AssociateBulk.js +449 -0
- package/source/views/associate/RecordSet-AssociateMatrix.js +680 -0
- package/source/views/associate/RecordSet-AssociateUnlink.js +610 -0
- package/source/views/associate/RecordSet-AssociationEditor.js +370 -0
- package/source/views/read/RecordSet-Read.js +114 -70
|
@@ -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;
|