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
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
|
@@ -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,588 @@
|
|
|
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
|
+
/** @type {string} - EntityProvider cache scope for join-list reads; cleared on every join write so
|
|
43
|
+
* the editor and pickers never show a stale (cached) association list after an add or remove. */
|
|
44
|
+
this._cacheScope = 'RecordSetAssociation';
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Invalidate the cached join-list reads after a write so the next list reflects it immediately
|
|
49
|
+
* (pict's EntityProvider caches getEntitySet by filter for ~10s; clearScope no-ops on the default
|
|
50
|
+
* empty scope, which is why join reads run under a dedicated scope).
|
|
51
|
+
* @param {Record<string, any>} pEntityProvider - the (scoped) EntityProvider used for the join.
|
|
52
|
+
*/
|
|
53
|
+
_clearAssociationCache(pEntityProvider)
|
|
54
|
+
{
|
|
55
|
+
try
|
|
56
|
+
{
|
|
57
|
+
if (pEntityProvider && (typeof pEntityProvider.clearScope === 'function'))
|
|
58
|
+
{
|
|
59
|
+
pEntityProvider.clearScope(this._cacheScope);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
catch (pError)
|
|
63
|
+
{
|
|
64
|
+
this.pict.log.warn(`AssociationManager: association cache clear failed: ${pError.message || pError}`);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Normalize one side definition, filling the light-config defaults: `Entity` falls back to
|
|
70
|
+
* `RecordSet`, `IDField` to `ID<Entity>`, `DisplayField` to `Name`, `SearchFields` to
|
|
71
|
+
* `[DisplayField]`, and `Sort` to `DisplayField`.
|
|
72
|
+
*
|
|
73
|
+
* @param {Record<string, any>} pSide
|
|
74
|
+
* @return {Record<string, any>}
|
|
75
|
+
*/
|
|
76
|
+
_normalizeSide(pSide)
|
|
77
|
+
{
|
|
78
|
+
const tmpSide = pSide || {};
|
|
79
|
+
const tmpEntity = tmpSide.Entity || tmpSide.RecordSet;
|
|
80
|
+
const tmpDisplayField = tmpSide.DisplayField || 'Name';
|
|
81
|
+
return {
|
|
82
|
+
RecordSet: tmpSide.RecordSet || tmpEntity,
|
|
83
|
+
Entity: tmpEntity,
|
|
84
|
+
IDField: tmpSide.IDField || `ID${tmpEntity}`,
|
|
85
|
+
DisplayField: tmpDisplayField,
|
|
86
|
+
SearchFields: (Array.isArray(tmpSide.SearchFields) && tmpSide.SearchFields.length > 0) ? tmpSide.SearchFields : [ tmpDisplayField ],
|
|
87
|
+
// No default sort: alphabetical-by-display sorts empty values first (blank rows). The picker's
|
|
88
|
+
// natural (PK) order is predictable; a host can opt into a Sort column explicitly.
|
|
89
|
+
Sort: tmpSide.Sort || false,
|
|
90
|
+
Title: tmpSide.Title || false,
|
|
91
|
+
URLPrefix: tmpSide.URLPrefix || '',
|
|
92
|
+
// Extra fields rendered as disambiguation chips in the picker (and the editor list), e.g.
|
|
93
|
+
// ['ISBN'] or [{ Field: 'PublicationYear', Label: 'Year' }]. Passed to the picker as EntityTags.
|
|
94
|
+
ChipFields: Array.isArray(tmpSide.ChipFields) ? tmpSide.ChipFields : [],
|
|
95
|
+
// Columns for the matrix screen's record table — for picking complex records by several fields.
|
|
96
|
+
TableColumns: this._normalizeColumns(tmpSide.TableColumns, tmpDisplayField),
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Normalize a side's TableColumns into `{ Key, DisplayName, Template? }` entries (string shorthand →
|
|
102
|
+
* `{ Key, DisplayName: Key }`). Defaults to a single column on the DisplayField when unset.
|
|
103
|
+
*
|
|
104
|
+
* @param {Array<string|Record<string, any>>|undefined} pColumns @param {string} pDisplayField
|
|
105
|
+
* @return {Array<Record<string, any>>}
|
|
106
|
+
*/
|
|
107
|
+
_normalizeColumns(pColumns, pDisplayField)
|
|
108
|
+
{
|
|
109
|
+
if (!Array.isArray(pColumns) || pColumns.length < 1)
|
|
110
|
+
{
|
|
111
|
+
return [ { Key: pDisplayField, DisplayName: pDisplayField } ];
|
|
112
|
+
}
|
|
113
|
+
return pColumns.map((pColumn) =>
|
|
114
|
+
{
|
|
115
|
+
if (typeof pColumn === 'string') { return { Key: pColumn, DisplayName: pColumn, DefaultHidden: false }; }
|
|
116
|
+
return { Key: pColumn.Key, DisplayName: pColumn.DisplayName || pColumn.Key, Template: pColumn.Template || false, DefaultHidden: (pColumn.DefaultHidden === true) };
|
|
117
|
+
});
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Build a FoxHound LIKE filter for a search term across one or more fields (single → AND, multiple →
|
|
122
|
+
* OR'd in a paren group), mirroring the picker's entity search. Used by the matrix table fetch.
|
|
123
|
+
*
|
|
124
|
+
* @param {Array<string>} pSearchFields @param {string} pTerm
|
|
125
|
+
* @return {string}
|
|
126
|
+
*/
|
|
127
|
+
_buildSearchFilter(pSearchFields, pTerm)
|
|
128
|
+
{
|
|
129
|
+
const tmpEncoded = encodeURIComponent(`%${pTerm}%`);
|
|
130
|
+
if (pSearchFields.length === 1)
|
|
131
|
+
{
|
|
132
|
+
return `FBV~${pSearchFields[0]}~LK~${tmpEncoded}`;
|
|
133
|
+
}
|
|
134
|
+
const tmpInner = pSearchFields.map((pField, pIndex) => `${pIndex === 0 ? 'FBV' : 'FBVOR'}~${pField}~LK~${tmpEncoded}`).join('~');
|
|
135
|
+
return `FOP~0~(~0~${tmpInner}~FCP~0~)~0`;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Fetch one page of a side's records for the matrix table (search across its SearchFields + optional
|
|
140
|
+
* Sort, offset/limit paging). Returns the raw records + a `hasMore` flag.
|
|
141
|
+
*
|
|
142
|
+
* @param {string} pAssociationHash @param {string} pRecordSetName
|
|
143
|
+
* @param {string} pSearch @param {number} pCursor @param {number} pPageSize
|
|
144
|
+
* @return {Promise<{records: Array<Record<string, any>>, hasMore: boolean}>}
|
|
145
|
+
*/
|
|
146
|
+
fetchSidePage(pAssociationHash, pRecordSetName, pSearch, pCursor, pPageSize)
|
|
147
|
+
{
|
|
148
|
+
const tmpSides = this.resolveSides(pAssociationHash, pRecordSetName);
|
|
149
|
+
if (!tmpSides)
|
|
150
|
+
{
|
|
151
|
+
return Promise.resolve({ records: [], hasMore: false });
|
|
152
|
+
}
|
|
153
|
+
const tmpSide = tmpSides.thisSide;
|
|
154
|
+
const tmpEntityProvider = this._entityProvider(tmpSide.URLPrefix);
|
|
155
|
+
const tmpStanzas = [];
|
|
156
|
+
if (pSearch) { tmpStanzas.push(this._buildSearchFilter(tmpSide.SearchFields, pSearch)); }
|
|
157
|
+
if (tmpSide.Sort) { tmpStanzas.push(`FSF~${tmpSide.Sort}~ASC~0`); }
|
|
158
|
+
const tmpFilter = tmpStanzas.filter(Boolean).join('~');
|
|
159
|
+
return new Promise((resolve) =>
|
|
160
|
+
{
|
|
161
|
+
tmpEntityProvider.getEntitySetPage(tmpSide.Entity, tmpFilter, pCursor, pPageSize, (pError, pRecords) =>
|
|
162
|
+
{
|
|
163
|
+
const tmpList = (!pError && Array.isArray(pRecords)) ? pRecords : [];
|
|
164
|
+
if (pError) { this.pict.log.warn(`AssociationManager: matrix fetch failed for ${tmpSide.Entity}.`, pError); }
|
|
165
|
+
return resolve({ records: tmpList, hasMore: (tmpList.length >= pPageSize) });
|
|
166
|
+
});
|
|
167
|
+
});
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* Register (or replace) an association definition.
|
|
172
|
+
*
|
|
173
|
+
* @param {string} pHash - Unique association hash (the `Association` key hosts reference).
|
|
174
|
+
* @param {Record<string, any>} pDefinition - `{ JoinEntity, JoinURLPrefix?, DefaultJoinValues?, SideA, SideB }`.
|
|
175
|
+
* @return {Record<string, any>|false} The normalized association, or false on invalid input.
|
|
176
|
+
*/
|
|
177
|
+
addAssociation(pHash, pDefinition)
|
|
178
|
+
{
|
|
179
|
+
if (!pHash || !pDefinition || !pDefinition.JoinEntity || !pDefinition.SideA || !pDefinition.SideB)
|
|
180
|
+
{
|
|
181
|
+
this.pict.log.error(`AssociationManager: addAssociation called with invalid definition for [${pHash}].`, pDefinition);
|
|
182
|
+
return false;
|
|
183
|
+
}
|
|
184
|
+
const tmpAssociation = {
|
|
185
|
+
Hash: pHash,
|
|
186
|
+
JoinEntity: pDefinition.JoinEntity,
|
|
187
|
+
JoinURLPrefix: pDefinition.JoinURLPrefix || '',
|
|
188
|
+
DefaultJoinValues: pDefinition.DefaultJoinValues || {},
|
|
189
|
+
SideA: this._normalizeSide(pDefinition.SideA),
|
|
190
|
+
SideB: this._normalizeSide(pDefinition.SideB),
|
|
191
|
+
};
|
|
192
|
+
this.associations[pHash] = tmpAssociation;
|
|
193
|
+
return tmpAssociation;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/**
|
|
197
|
+
* @param {string} pHash
|
|
198
|
+
* @return {Record<string, any>|undefined}
|
|
199
|
+
*/
|
|
200
|
+
getAssociation(pHash)
|
|
201
|
+
{
|
|
202
|
+
return this.associations[pHash];
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/**
|
|
206
|
+
* Resolve which side of an association is "this side" (the rendering recordset) vs the "other side"
|
|
207
|
+
* (the one being associated). Matches on `RecordSet` first, then `Entity`.
|
|
208
|
+
*
|
|
209
|
+
* @param {string} pAssociationHash
|
|
210
|
+
* @param {string} pThisRecordSetName
|
|
211
|
+
* @return {{ association: Record<string, any>, thisSide: Record<string, any>, otherSide: Record<string, any> }|false}
|
|
212
|
+
*/
|
|
213
|
+
resolveSides(pAssociationHash, pThisRecordSetName)
|
|
214
|
+
{
|
|
215
|
+
const tmpAssociation = this.associations[pAssociationHash];
|
|
216
|
+
if (!tmpAssociation)
|
|
217
|
+
{
|
|
218
|
+
this.pict.log.warn(`AssociationManager: no association registered for hash [${pAssociationHash}].`);
|
|
219
|
+
return false;
|
|
220
|
+
}
|
|
221
|
+
if (tmpAssociation.SideA.RecordSet === pThisRecordSetName || tmpAssociation.SideA.Entity === pThisRecordSetName)
|
|
222
|
+
{
|
|
223
|
+
return { association: tmpAssociation, thisSide: tmpAssociation.SideA, otherSide: tmpAssociation.SideB };
|
|
224
|
+
}
|
|
225
|
+
if (tmpAssociation.SideB.RecordSet === pThisRecordSetName || tmpAssociation.SideB.Entity === pThisRecordSetName)
|
|
226
|
+
{
|
|
227
|
+
return { association: tmpAssociation, thisSide: tmpAssociation.SideB, otherSide: tmpAssociation.SideA };
|
|
228
|
+
}
|
|
229
|
+
this.pict.log.warn(`AssociationManager: recordset [${pThisRecordSetName}] is neither side of association [${pAssociationHash}].`);
|
|
230
|
+
return false;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/**
|
|
234
|
+
* The EntityProvider to use for a given URL prefix. The shared (cached) `pict.EntityProvider` for the
|
|
235
|
+
* default prefix; a lazily-created, prefix-scoped instance otherwise (mirrors the recordset provider).
|
|
236
|
+
*
|
|
237
|
+
* @param {string} [pURLPrefix]
|
|
238
|
+
* @return {any}
|
|
239
|
+
*/
|
|
240
|
+
_entityProvider(pURLPrefix)
|
|
241
|
+
{
|
|
242
|
+
if (!pURLPrefix)
|
|
243
|
+
{
|
|
244
|
+
return this.pict.EntityProvider;
|
|
245
|
+
}
|
|
246
|
+
if (!this._scopedEntityProviders[pURLPrefix])
|
|
247
|
+
{
|
|
248
|
+
const tmpProvider = this.pict.instantiateServiceProviderWithoutRegistration('EntityProvider');
|
|
249
|
+
tmpProvider.options.urlPrefix = pURLPrefix;
|
|
250
|
+
this._scopedEntityProviders[pURLPrefix] = tmpProvider;
|
|
251
|
+
}
|
|
252
|
+
return this._scopedEntityProviders[pURLPrefix];
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/**
|
|
256
|
+
* The join entity's identity column for an association (`ID<JoinEntity>`).
|
|
257
|
+
* @param {Record<string, any>} pAssociation
|
|
258
|
+
* @return {string}
|
|
259
|
+
*/
|
|
260
|
+
getJoinIDField(pAssociation)
|
|
261
|
+
{
|
|
262
|
+
return `ID${pAssociation.JoinEntity}`;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
/**
|
|
266
|
+
* Compose a side's ChipFields into display chip strings for a record — the same `ISBN` / `{Field,
|
|
267
|
+
* Label, Template}` spec the picker's EntityTags uses, so the editor's current-associations list shows
|
|
268
|
+
* the same disambiguation chips as the add picker.
|
|
269
|
+
*
|
|
270
|
+
* @param {Array<string|Record<string, any>>} pChipFields
|
|
271
|
+
* @param {Record<string, any>} pRecord
|
|
272
|
+
* @return {Array<any>}
|
|
273
|
+
*/
|
|
274
|
+
composeChips(pChipFields, pRecord)
|
|
275
|
+
{
|
|
276
|
+
if (!Array.isArray(pChipFields) || pChipFields.length < 1 || !pRecord)
|
|
277
|
+
{
|
|
278
|
+
return [];
|
|
279
|
+
}
|
|
280
|
+
return pChipFields
|
|
281
|
+
.map((pSpec) =>
|
|
282
|
+
{
|
|
283
|
+
if (typeof pSpec === 'string') { return pRecord[pSpec]; }
|
|
284
|
+
if (pSpec && typeof pSpec === 'object')
|
|
285
|
+
{
|
|
286
|
+
const tmpValue = pSpec.Template ? this.pict.parseTemplate(pSpec.Template, pRecord) : pRecord[pSpec.Field];
|
|
287
|
+
if (tmpValue === undefined || tmpValue === null || tmpValue === '') { return ''; }
|
|
288
|
+
return pSpec.Label ? `${pSpec.Label}: ${tmpValue}` : tmpValue;
|
|
289
|
+
}
|
|
290
|
+
return '';
|
|
291
|
+
})
|
|
292
|
+
.filter((pChip) => (pChip !== undefined && pChip !== null && pChip !== ''));
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
/**
|
|
296
|
+
* Fetch the raw join rows for one anchor record (this side's id).
|
|
297
|
+
*
|
|
298
|
+
* @param {string} pAssociationHash
|
|
299
|
+
* @param {string} pThisRecordSetName
|
|
300
|
+
* @param {string|number} pThisID
|
|
301
|
+
* @return {Promise<Array<Record<string, any>>>}
|
|
302
|
+
*/
|
|
303
|
+
listJoinRecords(pAssociationHash, pThisRecordSetName, pThisID)
|
|
304
|
+
{
|
|
305
|
+
const tmpSides = this.resolveSides(pAssociationHash, pThisRecordSetName);
|
|
306
|
+
if (!tmpSides || pThisID === undefined || pThisID === null || pThisID === '')
|
|
307
|
+
{
|
|
308
|
+
return Promise.resolve([]);
|
|
309
|
+
}
|
|
310
|
+
const tmpEntityProvider = this._entityProvider(tmpSides.association.JoinURLPrefix);
|
|
311
|
+
const tmpFilter = `FBV~${tmpSides.thisSide.IDField}~EQ~${encodeURIComponent(pThisID)}`;
|
|
312
|
+
return new Promise((resolve) =>
|
|
313
|
+
{
|
|
314
|
+
tmpEntityProvider.getEntitySet(tmpSides.association.JoinEntity, tmpFilter, (pError, pRecords) =>
|
|
315
|
+
{
|
|
316
|
+
if (pError)
|
|
317
|
+
{
|
|
318
|
+
this.pict.log.warn(`AssociationManager: failed to list ${tmpSides.association.JoinEntity} for ${tmpSides.thisSide.IDField}=${pThisID}.`, pError);
|
|
319
|
+
return resolve([]);
|
|
320
|
+
}
|
|
321
|
+
return resolve(Array.isArray(pRecords) ? pRecords : []);
|
|
322
|
+
}, '', { Scope: this._cacheScope, NoCount: true });
|
|
323
|
+
});
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
/**
|
|
327
|
+
* Fetch the join rows for MANY "this side" ids at once (`FBL~<thisIDField>~INN~<ids>`). Drives the
|
|
328
|
+
* dual-column matrix screen's existing-pair dedup, so cross-linking never creates duplicate joins.
|
|
329
|
+
*
|
|
330
|
+
* @param {string} pAssociationHash
|
|
331
|
+
* @param {string} pThisRecordSetName
|
|
332
|
+
* @param {Array<string|number>} pThisIDs
|
|
333
|
+
* @return {Promise<Array<Record<string, any>>>}
|
|
334
|
+
*/
|
|
335
|
+
listJoinRecordsForIDs(pAssociationHash, pThisRecordSetName, pThisIDs)
|
|
336
|
+
{
|
|
337
|
+
const tmpSides = this.resolveSides(pAssociationHash, pThisRecordSetName);
|
|
338
|
+
if (!tmpSides || !Array.isArray(pThisIDs) || pThisIDs.length < 1)
|
|
339
|
+
{
|
|
340
|
+
return Promise.resolve([]);
|
|
341
|
+
}
|
|
342
|
+
const tmpEntityProvider = this._entityProvider(tmpSides.association.JoinURLPrefix);
|
|
343
|
+
const tmpFilter = `FBL~${tmpSides.thisSide.IDField}~INN~${pThisIDs.join(',')}`;
|
|
344
|
+
return new Promise((resolve) =>
|
|
345
|
+
{
|
|
346
|
+
tmpEntityProvider.getEntitySet(tmpSides.association.JoinEntity, tmpFilter, (pError, pRecords) =>
|
|
347
|
+
{
|
|
348
|
+
if (pError)
|
|
349
|
+
{
|
|
350
|
+
this.pict.log.warn(`AssociationManager: failed to list ${tmpSides.association.JoinEntity} for ${tmpSides.thisSide.IDField} IN (${pThisIDs.join(',')}).`, pError);
|
|
351
|
+
return resolve([]);
|
|
352
|
+
}
|
|
353
|
+
return resolve(Array.isArray(pRecords) ? pRecords : []);
|
|
354
|
+
}, '', { Scope: this._cacheScope, NoCount: true });
|
|
355
|
+
});
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
/**
|
|
359
|
+
* The other-side ids currently associated with one anchor record (for culling the picker).
|
|
360
|
+
*
|
|
361
|
+
* @param {string} pAssociationHash
|
|
362
|
+
* @param {string} pThisRecordSetName
|
|
363
|
+
* @param {string|number} pThisID
|
|
364
|
+
* @return {Promise<Array<any>>}
|
|
365
|
+
*/
|
|
366
|
+
async listAssociatedIDs(pAssociationHash, pThisRecordSetName, pThisID)
|
|
367
|
+
{
|
|
368
|
+
const tmpSides = this.resolveSides(pAssociationHash, pThisRecordSetName);
|
|
369
|
+
if (!tmpSides)
|
|
370
|
+
{
|
|
371
|
+
return [];
|
|
372
|
+
}
|
|
373
|
+
const tmpJoins = await this.listJoinRecords(pAssociationHash, pThisRecordSetName, pThisID);
|
|
374
|
+
return tmpJoins
|
|
375
|
+
.map((pJoin) => pJoin[tmpSides.otherSide.IDField])
|
|
376
|
+
.filter((pValue) => (pValue !== undefined && pValue !== null && pValue !== ''));
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
/**
|
|
380
|
+
* Resolve the other-side records currently associated with one anchor, decorated for the list UI:
|
|
381
|
+
* `{ JoinID, OtherID, Display, OtherRecord, JoinRecord }`. One join row per item (so the remove
|
|
382
|
+
* button can delete the exact join record), with the other-side display resolved in a single
|
|
383
|
+
* `FBL~<otherIDField>~INN~<ids>` fetch.
|
|
384
|
+
*
|
|
385
|
+
* @param {string} pAssociationHash
|
|
386
|
+
* @param {string} pThisRecordSetName
|
|
387
|
+
* @param {string|number} pThisID
|
|
388
|
+
* @return {Promise<Array<Record<string, any>>>}
|
|
389
|
+
*/
|
|
390
|
+
async listAssociatedRecords(pAssociationHash, pThisRecordSetName, pThisID)
|
|
391
|
+
{
|
|
392
|
+
const tmpSides = this.resolveSides(pAssociationHash, pThisRecordSetName);
|
|
393
|
+
if (!tmpSides)
|
|
394
|
+
{
|
|
395
|
+
return [];
|
|
396
|
+
}
|
|
397
|
+
const tmpJoinIDField = this.getJoinIDField(tmpSides.association);
|
|
398
|
+
const tmpJoins = await this.listJoinRecords(pAssociationHash, pThisRecordSetName, pThisID);
|
|
399
|
+
const tmpOtherIDs = tmpJoins
|
|
400
|
+
.map((pJoin) => pJoin[tmpSides.otherSide.IDField])
|
|
401
|
+
.filter((pValue) => (pValue !== undefined && pValue !== null && pValue !== ''));
|
|
402
|
+
|
|
403
|
+
let tmpByID = {};
|
|
404
|
+
if (tmpOtherIDs.length > 0)
|
|
405
|
+
{
|
|
406
|
+
const tmpEntityProvider = this._entityProvider(tmpSides.otherSide.URLPrefix);
|
|
407
|
+
const tmpFilter = `FBL~${tmpSides.otherSide.IDField}~INN~${tmpOtherIDs.join(',')}`;
|
|
408
|
+
const tmpOthers = await new Promise((resolve) =>
|
|
409
|
+
{
|
|
410
|
+
tmpEntityProvider.getEntitySet(tmpSides.otherSide.Entity, tmpFilter, (pError, pRecords) =>
|
|
411
|
+
{
|
|
412
|
+
if (pError)
|
|
413
|
+
{
|
|
414
|
+
this.pict.log.warn(`AssociationManager: failed to resolve ${tmpSides.otherSide.Entity} display records.`, pError);
|
|
415
|
+
return resolve([]);
|
|
416
|
+
}
|
|
417
|
+
return resolve(Array.isArray(pRecords) ? pRecords : []);
|
|
418
|
+
});
|
|
419
|
+
});
|
|
420
|
+
for (let i = 0; i < tmpOthers.length; i++)
|
|
421
|
+
{
|
|
422
|
+
tmpByID[tmpOthers[i][tmpSides.otherSide.IDField]] = tmpOthers[i];
|
|
423
|
+
}
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
return tmpJoins
|
|
427
|
+
.filter((pJoin) => (pJoin[tmpSides.otherSide.IDField] !== undefined && pJoin[tmpSides.otherSide.IDField] !== null && pJoin[tmpSides.otherSide.IDField] !== ''))
|
|
428
|
+
.map((pJoin) =>
|
|
429
|
+
{
|
|
430
|
+
const tmpOtherID = pJoin[tmpSides.otherSide.IDField];
|
|
431
|
+
const tmpOtherRecord = tmpByID[tmpOtherID] || {};
|
|
432
|
+
const tmpDisplay = (tmpOtherRecord[tmpSides.otherSide.DisplayField] !== undefined && tmpOtherRecord[tmpSides.otherSide.DisplayField] !== null && tmpOtherRecord[tmpSides.otherSide.DisplayField] !== '')
|
|
433
|
+
? tmpOtherRecord[tmpSides.otherSide.DisplayField]
|
|
434
|
+
: `#${tmpOtherID}`;
|
|
435
|
+
return {
|
|
436
|
+
JoinID: pJoin[tmpJoinIDField],
|
|
437
|
+
OtherID: tmpOtherID,
|
|
438
|
+
Display: tmpDisplay,
|
|
439
|
+
Chips: this.composeChips(tmpSides.otherSide.ChipFields, tmpOtherRecord).map((pChip) => ({ Text: pChip })),
|
|
440
|
+
OtherRecord: tmpOtherRecord,
|
|
441
|
+
JoinRecord: pJoin,
|
|
442
|
+
};
|
|
443
|
+
});
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
/**
|
|
447
|
+
* Create one join row linking an anchor record to an other-side record. Stamps any configured
|
|
448
|
+
* `DefaultJoinValues` (e.g. a constant tenant id). The join's GUID is auto-generated server-side.
|
|
449
|
+
*
|
|
450
|
+
* @param {string} pAssociationHash
|
|
451
|
+
* @param {string} pThisRecordSetName
|
|
452
|
+
* @param {string|number} pThisID
|
|
453
|
+
* @param {string|number} pOtherID
|
|
454
|
+
* @return {Promise<Record<string, any>>}
|
|
455
|
+
*/
|
|
456
|
+
createJoin(pAssociationHash, pThisRecordSetName, pThisID, pOtherID)
|
|
457
|
+
{
|
|
458
|
+
const tmpSides = this.resolveSides(pAssociationHash, pThisRecordSetName);
|
|
459
|
+
if (!tmpSides)
|
|
460
|
+
{
|
|
461
|
+
return Promise.reject(new Error(`AssociationManager: cannot create join for [${pAssociationHash}] from [${pThisRecordSetName}].`));
|
|
462
|
+
}
|
|
463
|
+
const tmpRecord = Object.assign({}, tmpSides.association.DefaultJoinValues);
|
|
464
|
+
tmpRecord[tmpSides.thisSide.IDField] = pThisID;
|
|
465
|
+
tmpRecord[tmpSides.otherSide.IDField] = pOtherID;
|
|
466
|
+
const tmpEntityProvider = this._entityProvider(tmpSides.association.JoinURLPrefix);
|
|
467
|
+
return new Promise((resolve, reject) =>
|
|
468
|
+
{
|
|
469
|
+
tmpEntityProvider.createEntity(tmpSides.association.JoinEntity, tmpRecord, (pError, pBody) =>
|
|
470
|
+
{
|
|
471
|
+
if (pError)
|
|
472
|
+
{
|
|
473
|
+
return reject(pError);
|
|
474
|
+
}
|
|
475
|
+
this._clearAssociationCache(tmpEntityProvider);
|
|
476
|
+
return resolve(pBody);
|
|
477
|
+
});
|
|
478
|
+
});
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
/**
|
|
482
|
+
* Delete one join row (the row a list item carries as `JoinRecord`, or any record with the join id).
|
|
483
|
+
*
|
|
484
|
+
* @param {string} pAssociationHash
|
|
485
|
+
* @param {Record<string, any>} pJoinRecord
|
|
486
|
+
* @return {Promise<Record<string, any>>}
|
|
487
|
+
*/
|
|
488
|
+
removeJoin(pAssociationHash, pJoinRecord)
|
|
489
|
+
{
|
|
490
|
+
const tmpAssociation = this.associations[pAssociationHash];
|
|
491
|
+
if (!tmpAssociation || !pJoinRecord)
|
|
492
|
+
{
|
|
493
|
+
return Promise.reject(new Error(`AssociationManager: cannot remove join for [${pAssociationHash}].`));
|
|
494
|
+
}
|
|
495
|
+
const tmpJoinID = pJoinRecord[this.getJoinIDField(tmpAssociation)];
|
|
496
|
+
const tmpEntityProvider = this._entityProvider(tmpAssociation.JoinURLPrefix);
|
|
497
|
+
return new Promise((resolve, reject) =>
|
|
498
|
+
{
|
|
499
|
+
tmpEntityProvider.deleteEntity(tmpAssociation.JoinEntity, tmpJoinID, (pError, pBody) =>
|
|
500
|
+
{
|
|
501
|
+
if (pError)
|
|
502
|
+
{
|
|
503
|
+
return reject(pError);
|
|
504
|
+
}
|
|
505
|
+
this._clearAssociationCache(tmpEntityProvider);
|
|
506
|
+
return resolve(pBody);
|
|
507
|
+
});
|
|
508
|
+
});
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
/**
|
|
512
|
+
* Build a `createEntityPicker` config for one side, optionally culling a live set of ids (a function
|
|
513
|
+
* so the cull re-evaluates on every search as associations change).
|
|
514
|
+
*
|
|
515
|
+
* @param {Record<string, any>} pSide - A normalized side.
|
|
516
|
+
* @param {(() => Array<any>)|false} pGetExcludedIDsFn - Returns ids to exclude (NIN), or falsy for none.
|
|
517
|
+
* @param {Record<string, any>} [pOverrides] - Extra picker options (DestinationAddress, ValueAddress, Mode, OnChange, …).
|
|
518
|
+
* @return {Record<string, any>}
|
|
519
|
+
*/
|
|
520
|
+
_pickerConfigForSide(pSide, pGetExcludedIDsFn, pOverrides)
|
|
521
|
+
{
|
|
522
|
+
const tmpConfig = {
|
|
523
|
+
Entity: pSide.Entity,
|
|
524
|
+
ValueField: pSide.IDField,
|
|
525
|
+
TextField: pSide.DisplayField,
|
|
526
|
+
SearchFields: pSide.SearchFields,
|
|
527
|
+
Sort: pSide.Sort,
|
|
528
|
+
};
|
|
529
|
+
// Disambiguation chips (ISBN, year, …) — the picker renders these as badges on each option/chip.
|
|
530
|
+
// TagLast so they sit AFTER the label ("Title ISBN Year"), matching the editor list rows.
|
|
531
|
+
if (Array.isArray(pSide.ChipFields) && pSide.ChipFields.length > 0)
|
|
532
|
+
{
|
|
533
|
+
tmpConfig.EntityTags = pSide.ChipFields;
|
|
534
|
+
tmpConfig.TagLast = true;
|
|
535
|
+
}
|
|
536
|
+
if (typeof pGetExcludedIDsFn === 'function')
|
|
537
|
+
{
|
|
538
|
+
tmpConfig.BaseFilter = () =>
|
|
539
|
+
{
|
|
540
|
+
const tmpIDs = pGetExcludedIDsFn();
|
|
541
|
+
return (Array.isArray(tmpIDs) && tmpIDs.length > 0) ? `FBL~${pSide.IDField}~NIN~${tmpIDs.join(',')}` : '';
|
|
542
|
+
};
|
|
543
|
+
}
|
|
544
|
+
return Object.assign(tmpConfig, pOverrides || {});
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
/**
|
|
548
|
+
* Picker config for the OTHER side of an association (the records being associated), culling the
|
|
549
|
+
* currently-associated ids.
|
|
550
|
+
*
|
|
551
|
+
* @param {string} pAssociationHash
|
|
552
|
+
* @param {string} pThisRecordSetName
|
|
553
|
+
* @param {(() => Array<any>)|false} pGetExcludedIDsFn
|
|
554
|
+
* @param {Record<string, any>} [pOverrides]
|
|
555
|
+
* @return {Record<string, any>|false}
|
|
556
|
+
*/
|
|
557
|
+
buildOtherPickerConfig(pAssociationHash, pThisRecordSetName, pGetExcludedIDsFn, pOverrides)
|
|
558
|
+
{
|
|
559
|
+
const tmpSides = this.resolveSides(pAssociationHash, pThisRecordSetName);
|
|
560
|
+
if (!tmpSides)
|
|
561
|
+
{
|
|
562
|
+
return false;
|
|
563
|
+
}
|
|
564
|
+
return this._pickerConfigForSide(tmpSides.otherSide, pGetExcludedIDsFn, pOverrides);
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
/**
|
|
568
|
+
* Picker config for the ANCHOR side of an association (the bulk screen's "pick a record to associate
|
|
569
|
+
* to" control). No cull.
|
|
570
|
+
*
|
|
571
|
+
* @param {string} pAssociationHash
|
|
572
|
+
* @param {string} pAnchorRecordSetName
|
|
573
|
+
* @param {Record<string, any>} [pOverrides]
|
|
574
|
+
* @return {Record<string, any>|false}
|
|
575
|
+
*/
|
|
576
|
+
buildAnchorPickerConfig(pAssociationHash, pAnchorRecordSetName, pOverrides)
|
|
577
|
+
{
|
|
578
|
+
const tmpSides = this.resolveSides(pAssociationHash, pAnchorRecordSetName);
|
|
579
|
+
if (!tmpSides)
|
|
580
|
+
{
|
|
581
|
+
return false;
|
|
582
|
+
}
|
|
583
|
+
return this._pickerConfigForSide(tmpSides.thisSide, false, pOverrides);
|
|
584
|
+
}
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
module.exports = PictRecordSetAssociationManager;
|
|
588
|
+
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
|
}
|