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 CHANGED
@@ -81,12 +81,25 @@ Meadow soft-deletes: lists normally return only `Deleted = 0` rows. Set `RecordS
81
81
 
82
82
  Deleted rows render at reduced opacity (`prsp-row-deleted`), and their default **View** link routes to `/PSRS/:RecordSet/ViewDeleted/:GUID` — a read route whose lookup explicitly includes soft-deleted records (a plain View would find nothing) and which renders a "This record has been deleted" banner above the record. Pair with the audit tier's *Deleted / Deleted on / Deleted by* columns in the column chooser.
83
83
 
84
+ ## Associations (joined-entity management)
85
+
86
+ First-class, opt-in UI for managing many-to-many **joins** (the `XxxYyyJoin` convention — a join row with its own `ID<Join>` plus an `ID<X>` and `ID<Y>`). Three interfaces, all driven by light configuration:
87
+
88
+ - **Association Editor** — a small embeddable widget added as a **read-view tab**: a searchable picker of the other entity (already-joined rows culled out) + an explicit **Add** button, over a list of the current associations, each removable. Opt Book→Authors and Author→Books in *independently*; `PickerMode` (`single`/`multi`) is per-tab.
89
+ - **Bulk Associate screen** — a single-anchor page (`/PSRS/:RecordSet/Associate/:Association`): pick one anchor record, multi-select many other-side records, create all the joins at once.
90
+ - **Matrix (cross-link) screen** — a dual-**table** page (`/PSRS/AssociateMatrix/:Association`) for linking complex records: each side is a record table with **configurable columns** (`TableColumns`, plus a per-table **Columns** chooser the user toggles — saved in localStorage), checkbox rows, and per-table search. Multi-select on both sides; a live stats header counts the **cross-product** (every left × every right); "Link selected" creates them all, skipping existing pairs.
91
+ - **Bulk Unlink screen** — the removal counterpart (`/PSRS/AssociateUnlink/:Association/:AnchorRecordSet`): pick a specific book *or* store, see all its current links in a selectable table (same columns/chooser/search + select-all), check rows, and "Unlink selected" deletes those joins together.
92
+
93
+ Define each join **once**, symmetrically, in a top-level `Associations` registry; then opt in per record set via a `RecordSetReadTabs` entry of `"Type": "Association"` (with `"ReadLayout": "Tab"` or `"Split"`) and/or a `RecordSetBulkAssociations` entry. With `Split`, the record stays in a resizable left pane and the association tabs sit top-right, opening to the record alone until you pick a tab. The picker comes from [pict-section-picker](https://github.com/fable-retold/pict-section-picker) and remove-confirmation from [pict-section-modal](https://github.com/fable-retold/pict-section-modal) (both soft dependencies, reached by provider hash). See the bookstore example for the full wiring (`Book`↔`Author` tabs both sides; `Book`↔`BookStore` catalog tabs + the "Assign Books to Store" bulk screen + the "Bulk Link" matrix screen), and `CLAUDE-pict-section-recordset.md` for the config reference and the `RecordSetAssociationManager` API.
94
+
84
95
  ## Related Packages
85
96
 
86
97
  - [pict](https://github.com/fable-retold/pict) - MVC application framework
87
98
  - [pict-view](https://github.com/fable-retold/pict-view) - View base class
88
99
  - [pict-provider](https://github.com/fable-retold/pict-provider) - Data provider base class
89
100
  - [pict-section-form](https://github.com/fable-retold/pict-section-form) - Form section component
101
+ - [pict-section-picker](https://github.com/fable-retold/pict-section-picker) - Searchable entity picker (the association add control)
102
+ - [pict-section-modal](https://github.com/fable-retold/pict-section-modal) - Modal/confirm dialogs (association remove confirmation)
90
103
 
91
104
  ## License
92
105
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pict-section-recordset",
3
- "version": "1.11.0",
3
+ "version": "1.17.0",
4
4
  "description": "Pict dynamic record set management views",
5
5
  "main": "source/Pict-Section-RecordSet.js",
6
6
  "files": [
@@ -11,3 +11,10 @@ module.exports.PictRecordSetApplication = require('./application/Pict-Applicatio
11
11
  module.exports.RecordSetProviderBase = require('./providers/RecordSet-RecordProvider-Base.js');
12
12
  module.exports.RecordSetProviderMeadowEndpoints = require('./providers/RecordSet-RecordProvider-MeadowEndpoints.js');
13
13
  module.exports.ColumnDataProvider = require('./providers/Column-Data-Provider.js');
14
+ module.exports.AssociationManager = require('./providers/RecordSet-AssociationManager.js');
15
+
16
+ // Joined-entity association views (embeddable read-tab editor + bulk associate screen)
17
+ module.exports.AssociationEditorView = require('./views/associate/RecordSet-AssociationEditor.js');
18
+ module.exports.AssociateBulkView = require('./views/associate/RecordSet-AssociateBulk.js');
19
+ module.exports.AssociateMatrixView = require('./views/associate/RecordSet-AssociateMatrix.js');
20
+ module.exports.AssociateUnlinkView = require('./views/associate/RecordSet-AssociateUnlink.js');
@@ -0,0 +1,561 @@
1
+ const libPictProvider = require('pict-provider');
2
+
3
+ /** @type {Record<string, any>} */
4
+ const _DEFAULT_PROVIDER_CONFIGURATION =
5
+ {
6
+ ProviderIdentifier: 'Pict-RecordSet-AssociationManager',
7
+
8
+ AutoInitialize: true,
9
+ AutoInitializeOrdinal: 0
10
+ };
11
+
12
+ /**
13
+ * Central registry + data layer for joined-entity ASSOCIATIONS (the `XxxYyyJoin` convention: a join
14
+ * row carrying its own `ID<Join>` plus an `ID<X>` and an `ID<Y>` pointing at either side).
15
+ *
16
+ * One `Association` is defined ONCE, symmetrically, with a `SideA` and a `SideB`. The UI (the
17
+ * read-tab Association Editor and the Bulk Associate screen) resolves which side is "this side" by
18
+ * matching the rendering recordset's name against the two sides — so opting Book→Authors and
19
+ * Author→Books in are independent, light-config decisions.
20
+ *
21
+ * All join + display reads/writes go through the shared, cached `pict.EntityProvider` (the same one
22
+ * the picker uses), so there is no bespoke REST plumbing and lookups de-duplicate across the app.
23
+ */
24
+ class PictRecordSetAssociationManager extends libPictProvider
25
+ {
26
+ constructor(pFable, pOptions, pServiceHash)
27
+ {
28
+ let tmpOptions = Object.assign({}, _DEFAULT_PROVIDER_CONFIGURATION, pOptions);
29
+ super(pFable, tmpOptions, pServiceHash);
30
+
31
+ /** @type {Record<string, any>} */
32
+ this.options;
33
+ /** @type {import('pict')} */
34
+ this.pict;
35
+
36
+ /** @type {Record<string, Record<string, any>>} - Registered associations keyed by hash. */
37
+ this.associations = {};
38
+
39
+ /** @type {Record<string, any>} - Lazily-created EntityProviders scoped to a non-default URL prefix. */
40
+ this._scopedEntityProviders = {};
41
+ }
42
+
43
+ /**
44
+ * Normalize one side definition, filling the light-config defaults: `Entity` falls back to
45
+ * `RecordSet`, `IDField` to `ID<Entity>`, `DisplayField` to `Name`, `SearchFields` to
46
+ * `[DisplayField]`, and `Sort` to `DisplayField`.
47
+ *
48
+ * @param {Record<string, any>} pSide
49
+ * @return {Record<string, any>}
50
+ */
51
+ _normalizeSide(pSide)
52
+ {
53
+ const tmpSide = pSide || {};
54
+ const tmpEntity = tmpSide.Entity || tmpSide.RecordSet;
55
+ const tmpDisplayField = tmpSide.DisplayField || 'Name';
56
+ return {
57
+ RecordSet: tmpSide.RecordSet || tmpEntity,
58
+ Entity: tmpEntity,
59
+ IDField: tmpSide.IDField || `ID${tmpEntity}`,
60
+ DisplayField: tmpDisplayField,
61
+ SearchFields: (Array.isArray(tmpSide.SearchFields) && tmpSide.SearchFields.length > 0) ? tmpSide.SearchFields : [ tmpDisplayField ],
62
+ // No default sort: alphabetical-by-display sorts empty values first (blank rows). The picker's
63
+ // natural (PK) order is predictable; a host can opt into a Sort column explicitly.
64
+ Sort: tmpSide.Sort || false,
65
+ Title: tmpSide.Title || false,
66
+ URLPrefix: tmpSide.URLPrefix || '',
67
+ // Extra fields rendered as disambiguation chips in the picker (and the editor list), e.g.
68
+ // ['ISBN'] or [{ Field: 'PublicationYear', Label: 'Year' }]. Passed to the picker as EntityTags.
69
+ ChipFields: Array.isArray(tmpSide.ChipFields) ? tmpSide.ChipFields : [],
70
+ // Columns for the matrix screen's record table — for picking complex records by several fields.
71
+ TableColumns: this._normalizeColumns(tmpSide.TableColumns, tmpDisplayField),
72
+ };
73
+ }
74
+
75
+ /**
76
+ * Normalize a side's TableColumns into `{ Key, DisplayName, Template? }` entries (string shorthand →
77
+ * `{ Key, DisplayName: Key }`). Defaults to a single column on the DisplayField when unset.
78
+ *
79
+ * @param {Array<string|Record<string, any>>|undefined} pColumns @param {string} pDisplayField
80
+ * @return {Array<Record<string, any>>}
81
+ */
82
+ _normalizeColumns(pColumns, pDisplayField)
83
+ {
84
+ if (!Array.isArray(pColumns) || pColumns.length < 1)
85
+ {
86
+ return [ { Key: pDisplayField, DisplayName: pDisplayField } ];
87
+ }
88
+ return pColumns.map((pColumn) =>
89
+ {
90
+ if (typeof pColumn === 'string') { return { Key: pColumn, DisplayName: pColumn, DefaultHidden: false }; }
91
+ return { Key: pColumn.Key, DisplayName: pColumn.DisplayName || pColumn.Key, Template: pColumn.Template || false, DefaultHidden: (pColumn.DefaultHidden === true) };
92
+ });
93
+ }
94
+
95
+ /**
96
+ * Build a FoxHound LIKE filter for a search term across one or more fields (single → AND, multiple →
97
+ * OR'd in a paren group), mirroring the picker's entity search. Used by the matrix table fetch.
98
+ *
99
+ * @param {Array<string>} pSearchFields @param {string} pTerm
100
+ * @return {string}
101
+ */
102
+ _buildSearchFilter(pSearchFields, pTerm)
103
+ {
104
+ const tmpEncoded = encodeURIComponent(`%${pTerm}%`);
105
+ if (pSearchFields.length === 1)
106
+ {
107
+ return `FBV~${pSearchFields[0]}~LK~${tmpEncoded}`;
108
+ }
109
+ const tmpInner = pSearchFields.map((pField, pIndex) => `${pIndex === 0 ? 'FBV' : 'FBVOR'}~${pField}~LK~${tmpEncoded}`).join('~');
110
+ return `FOP~0~(~0~${tmpInner}~FCP~0~)~0`;
111
+ }
112
+
113
+ /**
114
+ * Fetch one page of a side's records for the matrix table (search across its SearchFields + optional
115
+ * Sort, offset/limit paging). Returns the raw records + a `hasMore` flag.
116
+ *
117
+ * @param {string} pAssociationHash @param {string} pRecordSetName
118
+ * @param {string} pSearch @param {number} pCursor @param {number} pPageSize
119
+ * @return {Promise<{records: Array<Record<string, any>>, hasMore: boolean}>}
120
+ */
121
+ fetchSidePage(pAssociationHash, pRecordSetName, pSearch, pCursor, pPageSize)
122
+ {
123
+ const tmpSides = this.resolveSides(pAssociationHash, pRecordSetName);
124
+ if (!tmpSides)
125
+ {
126
+ return Promise.resolve({ records: [], hasMore: false });
127
+ }
128
+ const tmpSide = tmpSides.thisSide;
129
+ const tmpEntityProvider = this._entityProvider(tmpSide.URLPrefix);
130
+ const tmpStanzas = [];
131
+ if (pSearch) { tmpStanzas.push(this._buildSearchFilter(tmpSide.SearchFields, pSearch)); }
132
+ if (tmpSide.Sort) { tmpStanzas.push(`FSF~${tmpSide.Sort}~ASC~0`); }
133
+ const tmpFilter = tmpStanzas.filter(Boolean).join('~');
134
+ return new Promise((resolve) =>
135
+ {
136
+ tmpEntityProvider.getEntitySetPage(tmpSide.Entity, tmpFilter, pCursor, pPageSize, (pError, pRecords) =>
137
+ {
138
+ const tmpList = (!pError && Array.isArray(pRecords)) ? pRecords : [];
139
+ if (pError) { this.pict.log.warn(`AssociationManager: matrix fetch failed for ${tmpSide.Entity}.`, pError); }
140
+ return resolve({ records: tmpList, hasMore: (tmpList.length >= pPageSize) });
141
+ });
142
+ });
143
+ }
144
+
145
+ /**
146
+ * Register (or replace) an association definition.
147
+ *
148
+ * @param {string} pHash - Unique association hash (the `Association` key hosts reference).
149
+ * @param {Record<string, any>} pDefinition - `{ JoinEntity, JoinURLPrefix?, DefaultJoinValues?, SideA, SideB }`.
150
+ * @return {Record<string, any>|false} The normalized association, or false on invalid input.
151
+ */
152
+ addAssociation(pHash, pDefinition)
153
+ {
154
+ if (!pHash || !pDefinition || !pDefinition.JoinEntity || !pDefinition.SideA || !pDefinition.SideB)
155
+ {
156
+ this.pict.log.error(`AssociationManager: addAssociation called with invalid definition for [${pHash}].`, pDefinition);
157
+ return false;
158
+ }
159
+ const tmpAssociation = {
160
+ Hash: pHash,
161
+ JoinEntity: pDefinition.JoinEntity,
162
+ JoinURLPrefix: pDefinition.JoinURLPrefix || '',
163
+ DefaultJoinValues: pDefinition.DefaultJoinValues || {},
164
+ SideA: this._normalizeSide(pDefinition.SideA),
165
+ SideB: this._normalizeSide(pDefinition.SideB),
166
+ };
167
+ this.associations[pHash] = tmpAssociation;
168
+ return tmpAssociation;
169
+ }
170
+
171
+ /**
172
+ * @param {string} pHash
173
+ * @return {Record<string, any>|undefined}
174
+ */
175
+ getAssociation(pHash)
176
+ {
177
+ return this.associations[pHash];
178
+ }
179
+
180
+ /**
181
+ * Resolve which side of an association is "this side" (the rendering recordset) vs the "other side"
182
+ * (the one being associated). Matches on `RecordSet` first, then `Entity`.
183
+ *
184
+ * @param {string} pAssociationHash
185
+ * @param {string} pThisRecordSetName
186
+ * @return {{ association: Record<string, any>, thisSide: Record<string, any>, otherSide: Record<string, any> }|false}
187
+ */
188
+ resolveSides(pAssociationHash, pThisRecordSetName)
189
+ {
190
+ const tmpAssociation = this.associations[pAssociationHash];
191
+ if (!tmpAssociation)
192
+ {
193
+ this.pict.log.warn(`AssociationManager: no association registered for hash [${pAssociationHash}].`);
194
+ return false;
195
+ }
196
+ if (tmpAssociation.SideA.RecordSet === pThisRecordSetName || tmpAssociation.SideA.Entity === pThisRecordSetName)
197
+ {
198
+ return { association: tmpAssociation, thisSide: tmpAssociation.SideA, otherSide: tmpAssociation.SideB };
199
+ }
200
+ if (tmpAssociation.SideB.RecordSet === pThisRecordSetName || tmpAssociation.SideB.Entity === pThisRecordSetName)
201
+ {
202
+ return { association: tmpAssociation, thisSide: tmpAssociation.SideB, otherSide: tmpAssociation.SideA };
203
+ }
204
+ this.pict.log.warn(`AssociationManager: recordset [${pThisRecordSetName}] is neither side of association [${pAssociationHash}].`);
205
+ return false;
206
+ }
207
+
208
+ /**
209
+ * The EntityProvider to use for a given URL prefix. The shared (cached) `pict.EntityProvider` for the
210
+ * default prefix; a lazily-created, prefix-scoped instance otherwise (mirrors the recordset provider).
211
+ *
212
+ * @param {string} [pURLPrefix]
213
+ * @return {any}
214
+ */
215
+ _entityProvider(pURLPrefix)
216
+ {
217
+ if (!pURLPrefix)
218
+ {
219
+ return this.pict.EntityProvider;
220
+ }
221
+ if (!this._scopedEntityProviders[pURLPrefix])
222
+ {
223
+ const tmpProvider = this.pict.instantiateServiceProviderWithoutRegistration('EntityProvider');
224
+ tmpProvider.options.urlPrefix = pURLPrefix;
225
+ this._scopedEntityProviders[pURLPrefix] = tmpProvider;
226
+ }
227
+ return this._scopedEntityProviders[pURLPrefix];
228
+ }
229
+
230
+ /**
231
+ * The join entity's identity column for an association (`ID<JoinEntity>`).
232
+ * @param {Record<string, any>} pAssociation
233
+ * @return {string}
234
+ */
235
+ getJoinIDField(pAssociation)
236
+ {
237
+ return `ID${pAssociation.JoinEntity}`;
238
+ }
239
+
240
+ /**
241
+ * Compose a side's ChipFields into display chip strings for a record — the same `ISBN` / `{Field,
242
+ * Label, Template}` spec the picker's EntityTags uses, so the editor's current-associations list shows
243
+ * the same disambiguation chips as the add picker.
244
+ *
245
+ * @param {Array<string|Record<string, any>>} pChipFields
246
+ * @param {Record<string, any>} pRecord
247
+ * @return {Array<any>}
248
+ */
249
+ composeChips(pChipFields, pRecord)
250
+ {
251
+ if (!Array.isArray(pChipFields) || pChipFields.length < 1 || !pRecord)
252
+ {
253
+ return [];
254
+ }
255
+ return pChipFields
256
+ .map((pSpec) =>
257
+ {
258
+ if (typeof pSpec === 'string') { return pRecord[pSpec]; }
259
+ if (pSpec && typeof pSpec === 'object')
260
+ {
261
+ const tmpValue = pSpec.Template ? this.pict.parseTemplate(pSpec.Template, pRecord) : pRecord[pSpec.Field];
262
+ if (tmpValue === undefined || tmpValue === null || tmpValue === '') { return ''; }
263
+ return pSpec.Label ? `${pSpec.Label}: ${tmpValue}` : tmpValue;
264
+ }
265
+ return '';
266
+ })
267
+ .filter((pChip) => (pChip !== undefined && pChip !== null && pChip !== ''));
268
+ }
269
+
270
+ /**
271
+ * Fetch the raw join rows for one anchor record (this side's id).
272
+ *
273
+ * @param {string} pAssociationHash
274
+ * @param {string} pThisRecordSetName
275
+ * @param {string|number} pThisID
276
+ * @return {Promise<Array<Record<string, any>>>}
277
+ */
278
+ listJoinRecords(pAssociationHash, pThisRecordSetName, pThisID)
279
+ {
280
+ const tmpSides = this.resolveSides(pAssociationHash, pThisRecordSetName);
281
+ if (!tmpSides || pThisID === undefined || pThisID === null || pThisID === '')
282
+ {
283
+ return Promise.resolve([]);
284
+ }
285
+ const tmpEntityProvider = this._entityProvider(tmpSides.association.JoinURLPrefix);
286
+ const tmpFilter = `FBV~${tmpSides.thisSide.IDField}~EQ~${encodeURIComponent(pThisID)}`;
287
+ return new Promise((resolve) =>
288
+ {
289
+ tmpEntityProvider.getEntitySet(tmpSides.association.JoinEntity, tmpFilter, (pError, pRecords) =>
290
+ {
291
+ if (pError)
292
+ {
293
+ this.pict.log.warn(`AssociationManager: failed to list ${tmpSides.association.JoinEntity} for ${tmpSides.thisSide.IDField}=${pThisID}.`, pError);
294
+ return resolve([]);
295
+ }
296
+ return resolve(Array.isArray(pRecords) ? pRecords : []);
297
+ });
298
+ });
299
+ }
300
+
301
+ /**
302
+ * Fetch the join rows for MANY "this side" ids at once (`FBL~<thisIDField>~INN~<ids>`). Drives the
303
+ * dual-column matrix screen's existing-pair dedup, so cross-linking never creates duplicate joins.
304
+ *
305
+ * @param {string} pAssociationHash
306
+ * @param {string} pThisRecordSetName
307
+ * @param {Array<string|number>} pThisIDs
308
+ * @return {Promise<Array<Record<string, any>>>}
309
+ */
310
+ listJoinRecordsForIDs(pAssociationHash, pThisRecordSetName, pThisIDs)
311
+ {
312
+ const tmpSides = this.resolveSides(pAssociationHash, pThisRecordSetName);
313
+ if (!tmpSides || !Array.isArray(pThisIDs) || pThisIDs.length < 1)
314
+ {
315
+ return Promise.resolve([]);
316
+ }
317
+ const tmpEntityProvider = this._entityProvider(tmpSides.association.JoinURLPrefix);
318
+ const tmpFilter = `FBL~${tmpSides.thisSide.IDField}~INN~${pThisIDs.join(',')}`;
319
+ return new Promise((resolve) =>
320
+ {
321
+ tmpEntityProvider.getEntitySet(tmpSides.association.JoinEntity, tmpFilter, (pError, pRecords) =>
322
+ {
323
+ if (pError)
324
+ {
325
+ this.pict.log.warn(`AssociationManager: failed to list ${tmpSides.association.JoinEntity} for ${tmpSides.thisSide.IDField} IN (${pThisIDs.join(',')}).`, pError);
326
+ return resolve([]);
327
+ }
328
+ return resolve(Array.isArray(pRecords) ? pRecords : []);
329
+ });
330
+ });
331
+ }
332
+
333
+ /**
334
+ * The other-side ids currently associated with one anchor record (for culling the picker).
335
+ *
336
+ * @param {string} pAssociationHash
337
+ * @param {string} pThisRecordSetName
338
+ * @param {string|number} pThisID
339
+ * @return {Promise<Array<any>>}
340
+ */
341
+ async listAssociatedIDs(pAssociationHash, pThisRecordSetName, pThisID)
342
+ {
343
+ const tmpSides = this.resolveSides(pAssociationHash, pThisRecordSetName);
344
+ if (!tmpSides)
345
+ {
346
+ return [];
347
+ }
348
+ const tmpJoins = await this.listJoinRecords(pAssociationHash, pThisRecordSetName, pThisID);
349
+ return tmpJoins
350
+ .map((pJoin) => pJoin[tmpSides.otherSide.IDField])
351
+ .filter((pValue) => (pValue !== undefined && pValue !== null && pValue !== ''));
352
+ }
353
+
354
+ /**
355
+ * Resolve the other-side records currently associated with one anchor, decorated for the list UI:
356
+ * `{ JoinID, OtherID, Display, OtherRecord, JoinRecord }`. One join row per item (so the remove
357
+ * button can delete the exact join record), with the other-side display resolved in a single
358
+ * `FBL~<otherIDField>~INN~<ids>` fetch.
359
+ *
360
+ * @param {string} pAssociationHash
361
+ * @param {string} pThisRecordSetName
362
+ * @param {string|number} pThisID
363
+ * @return {Promise<Array<Record<string, any>>>}
364
+ */
365
+ async listAssociatedRecords(pAssociationHash, pThisRecordSetName, pThisID)
366
+ {
367
+ const tmpSides = this.resolveSides(pAssociationHash, pThisRecordSetName);
368
+ if (!tmpSides)
369
+ {
370
+ return [];
371
+ }
372
+ const tmpJoinIDField = this.getJoinIDField(tmpSides.association);
373
+ const tmpJoins = await this.listJoinRecords(pAssociationHash, pThisRecordSetName, pThisID);
374
+ const tmpOtherIDs = tmpJoins
375
+ .map((pJoin) => pJoin[tmpSides.otherSide.IDField])
376
+ .filter((pValue) => (pValue !== undefined && pValue !== null && pValue !== ''));
377
+
378
+ let tmpByID = {};
379
+ if (tmpOtherIDs.length > 0)
380
+ {
381
+ const tmpEntityProvider = this._entityProvider(tmpSides.otherSide.URLPrefix);
382
+ const tmpFilter = `FBL~${tmpSides.otherSide.IDField}~INN~${tmpOtherIDs.join(',')}`;
383
+ const tmpOthers = await new Promise((resolve) =>
384
+ {
385
+ tmpEntityProvider.getEntitySet(tmpSides.otherSide.Entity, tmpFilter, (pError, pRecords) =>
386
+ {
387
+ if (pError)
388
+ {
389
+ this.pict.log.warn(`AssociationManager: failed to resolve ${tmpSides.otherSide.Entity} display records.`, pError);
390
+ return resolve([]);
391
+ }
392
+ return resolve(Array.isArray(pRecords) ? pRecords : []);
393
+ });
394
+ });
395
+ for (let i = 0; i < tmpOthers.length; i++)
396
+ {
397
+ tmpByID[tmpOthers[i][tmpSides.otherSide.IDField]] = tmpOthers[i];
398
+ }
399
+ }
400
+
401
+ return tmpJoins
402
+ .filter((pJoin) => (pJoin[tmpSides.otherSide.IDField] !== undefined && pJoin[tmpSides.otherSide.IDField] !== null && pJoin[tmpSides.otherSide.IDField] !== ''))
403
+ .map((pJoin) =>
404
+ {
405
+ const tmpOtherID = pJoin[tmpSides.otherSide.IDField];
406
+ const tmpOtherRecord = tmpByID[tmpOtherID] || {};
407
+ const tmpDisplay = (tmpOtherRecord[tmpSides.otherSide.DisplayField] !== undefined && tmpOtherRecord[tmpSides.otherSide.DisplayField] !== null && tmpOtherRecord[tmpSides.otherSide.DisplayField] !== '')
408
+ ? tmpOtherRecord[tmpSides.otherSide.DisplayField]
409
+ : `#${tmpOtherID}`;
410
+ return {
411
+ JoinID: pJoin[tmpJoinIDField],
412
+ OtherID: tmpOtherID,
413
+ Display: tmpDisplay,
414
+ Chips: this.composeChips(tmpSides.otherSide.ChipFields, tmpOtherRecord).map((pChip) => ({ Text: pChip })),
415
+ OtherRecord: tmpOtherRecord,
416
+ JoinRecord: pJoin,
417
+ };
418
+ });
419
+ }
420
+
421
+ /**
422
+ * Create one join row linking an anchor record to an other-side record. Stamps any configured
423
+ * `DefaultJoinValues` (e.g. a constant tenant id). The join's GUID is auto-generated server-side.
424
+ *
425
+ * @param {string} pAssociationHash
426
+ * @param {string} pThisRecordSetName
427
+ * @param {string|number} pThisID
428
+ * @param {string|number} pOtherID
429
+ * @return {Promise<Record<string, any>>}
430
+ */
431
+ createJoin(pAssociationHash, pThisRecordSetName, pThisID, pOtherID)
432
+ {
433
+ const tmpSides = this.resolveSides(pAssociationHash, pThisRecordSetName);
434
+ if (!tmpSides)
435
+ {
436
+ return Promise.reject(new Error(`AssociationManager: cannot create join for [${pAssociationHash}] from [${pThisRecordSetName}].`));
437
+ }
438
+ const tmpRecord = Object.assign({}, tmpSides.association.DefaultJoinValues);
439
+ tmpRecord[tmpSides.thisSide.IDField] = pThisID;
440
+ tmpRecord[tmpSides.otherSide.IDField] = pOtherID;
441
+ const tmpEntityProvider = this._entityProvider(tmpSides.association.JoinURLPrefix);
442
+ return new Promise((resolve, reject) =>
443
+ {
444
+ tmpEntityProvider.createEntity(tmpSides.association.JoinEntity, tmpRecord, (pError, pBody) =>
445
+ {
446
+ if (pError)
447
+ {
448
+ return reject(pError);
449
+ }
450
+ return resolve(pBody);
451
+ });
452
+ });
453
+ }
454
+
455
+ /**
456
+ * Delete one join row (the row a list item carries as `JoinRecord`, or any record with the join id).
457
+ *
458
+ * @param {string} pAssociationHash
459
+ * @param {Record<string, any>} pJoinRecord
460
+ * @return {Promise<Record<string, any>>}
461
+ */
462
+ removeJoin(pAssociationHash, pJoinRecord)
463
+ {
464
+ const tmpAssociation = this.associations[pAssociationHash];
465
+ if (!tmpAssociation || !pJoinRecord)
466
+ {
467
+ return Promise.reject(new Error(`AssociationManager: cannot remove join for [${pAssociationHash}].`));
468
+ }
469
+ const tmpJoinID = pJoinRecord[this.getJoinIDField(tmpAssociation)];
470
+ const tmpEntityProvider = this._entityProvider(tmpAssociation.JoinURLPrefix);
471
+ return new Promise((resolve, reject) =>
472
+ {
473
+ tmpEntityProvider.deleteEntity(tmpAssociation.JoinEntity, tmpJoinID, (pError, pBody) =>
474
+ {
475
+ if (pError)
476
+ {
477
+ return reject(pError);
478
+ }
479
+ return resolve(pBody);
480
+ });
481
+ });
482
+ }
483
+
484
+ /**
485
+ * Build a `createEntityPicker` config for one side, optionally culling a live set of ids (a function
486
+ * so the cull re-evaluates on every search as associations change).
487
+ *
488
+ * @param {Record<string, any>} pSide - A normalized side.
489
+ * @param {(() => Array<any>)|false} pGetExcludedIDsFn - Returns ids to exclude (NIN), or falsy for none.
490
+ * @param {Record<string, any>} [pOverrides] - Extra picker options (DestinationAddress, ValueAddress, Mode, OnChange, …).
491
+ * @return {Record<string, any>}
492
+ */
493
+ _pickerConfigForSide(pSide, pGetExcludedIDsFn, pOverrides)
494
+ {
495
+ const tmpConfig = {
496
+ Entity: pSide.Entity,
497
+ ValueField: pSide.IDField,
498
+ TextField: pSide.DisplayField,
499
+ SearchFields: pSide.SearchFields,
500
+ Sort: pSide.Sort,
501
+ };
502
+ // Disambiguation chips (ISBN, year, …) — the picker renders these as badges on each option/chip.
503
+ // TagLast so they sit AFTER the label ("Title ISBN Year"), matching the editor list rows.
504
+ if (Array.isArray(pSide.ChipFields) && pSide.ChipFields.length > 0)
505
+ {
506
+ tmpConfig.EntityTags = pSide.ChipFields;
507
+ tmpConfig.TagLast = true;
508
+ }
509
+ if (typeof pGetExcludedIDsFn === 'function')
510
+ {
511
+ tmpConfig.BaseFilter = () =>
512
+ {
513
+ const tmpIDs = pGetExcludedIDsFn();
514
+ return (Array.isArray(tmpIDs) && tmpIDs.length > 0) ? `FBL~${pSide.IDField}~NIN~${tmpIDs.join(',')}` : '';
515
+ };
516
+ }
517
+ return Object.assign(tmpConfig, pOverrides || {});
518
+ }
519
+
520
+ /**
521
+ * Picker config for the OTHER side of an association (the records being associated), culling the
522
+ * currently-associated ids.
523
+ *
524
+ * @param {string} pAssociationHash
525
+ * @param {string} pThisRecordSetName
526
+ * @param {(() => Array<any>)|false} pGetExcludedIDsFn
527
+ * @param {Record<string, any>} [pOverrides]
528
+ * @return {Record<string, any>|false}
529
+ */
530
+ buildOtherPickerConfig(pAssociationHash, pThisRecordSetName, pGetExcludedIDsFn, pOverrides)
531
+ {
532
+ const tmpSides = this.resolveSides(pAssociationHash, pThisRecordSetName);
533
+ if (!tmpSides)
534
+ {
535
+ return false;
536
+ }
537
+ return this._pickerConfigForSide(tmpSides.otherSide, pGetExcludedIDsFn, pOverrides);
538
+ }
539
+
540
+ /**
541
+ * Picker config for the ANCHOR side of an association (the bulk screen's "pick a record to associate
542
+ * to" control). No cull.
543
+ *
544
+ * @param {string} pAssociationHash
545
+ * @param {string} pAnchorRecordSetName
546
+ * @param {Record<string, any>} [pOverrides]
547
+ * @return {Record<string, any>|false}
548
+ */
549
+ buildAnchorPickerConfig(pAssociationHash, pAnchorRecordSetName, pOverrides)
550
+ {
551
+ const tmpSides = this.resolveSides(pAssociationHash, pAnchorRecordSetName);
552
+ if (!tmpSides)
553
+ {
554
+ return false;
555
+ }
556
+ return this._pickerConfigForSide(tmpSides.thisSide, false, pOverrides);
557
+ }
558
+ }
559
+
560
+ module.exports = PictRecordSetAssociationManager;
561
+ module.exports.default_configuration = _DEFAULT_PROVIDER_CONFIGURATION;
@@ -48,6 +48,9 @@ class PictRecordSetRouter extends libPictProvider
48
48
  this.pict.views['RSP-RecordSet-Read'].addRoutes(pRouter);
49
49
  this.pict.views['RSP-RecordSet-Create'].addRoutes(pRouter);
50
50
  this.pict.views['RSP-RecordSet-Dashboard'].addRoutes(pRouter);
51
+ this.pict.views['RSP-RecordSet-Associate'].addRoutes(pRouter);
52
+ this.pict.views['RSP-RecordSet-AssociateMatrix'].addRoutes(pRouter);
53
+ this.pict.views['RSP-RecordSet-AssociateUnlink'].addRoutes(pRouter);
51
54
 
52
55
  this.pict.PictSectionRecordSet.addRoutes(pRouter);
53
56
  }
@@ -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);