pict-section-recordset 1.11.0 → 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.
- package/README.md +13 -0
- package/package.json +1 -1
- package/source/Pict-Section-RecordSet.js +7 -0
- package/source/providers/RecordSet-AssociationManager.js +561 -0
- package/source/providers/RecordSet-Router.js +3 -0
- package/source/services/RecordsSet-MetaController.js +22 -0
- package/source/views/RecordSet-Filters.js +4 -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
|
@@ -728,6 +728,10 @@ class ViewRecordSetSUBSETFilters extends libPictView
|
|
|
728
728
|
// TextField when the entity has no list-entry template.
|
|
729
729
|
TextTemplate: tmpDescriptor.EntityListEntryTemplate || undefined,
|
|
730
730
|
Placeholder: `Select ${pMount.Label}…`,
|
|
731
|
+
// A filter's natural zero state is "Any" — the pinned clear row / inline × empty the
|
|
732
|
+
// selection, OnChange(null) flows through the upsert, and the clause is removed.
|
|
733
|
+
AllowClear: true,
|
|
734
|
+
ClearLabel: 'Any',
|
|
731
735
|
OnChange: (pValue) => this.applyQuickFilterEntity(pRecordSet, pViewContext, pMount.Field, pMount.ClauseKey, pValue),
|
|
732
736
|
BaseFilter: tmpScopeBaseFilter,
|
|
733
737
|
});
|
|
@@ -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;
|