pict-section-recordset 1.17.0 → 1.19.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pict-section-recordset",
3
- "version": "1.17.0",
3
+ "version": "1.19.0",
4
4
  "description": "Pict dynamic record set management views",
5
5
  "main": "source/Pict-Section-RecordSet.js",
6
6
  "files": [
@@ -38,6 +38,31 @@ class PictRecordSetAssociationManager extends libPictProvider
38
38
 
39
39
  /** @type {Record<string, any>} - Lazily-created EntityProviders scoped to a non-default URL prefix. */
40
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
+ }
41
66
  }
42
67
 
43
68
  /**
@@ -53,10 +78,20 @@ class PictRecordSetAssociationManager extends libPictProvider
53
78
  const tmpSide = pSide || {};
54
79
  const tmpEntity = tmpSide.Entity || tmpSide.RecordSet;
55
80
  const tmpDisplayField = tmpSide.DisplayField || 'Name';
81
+ const tmpIDField = tmpSide.IDField || `ID${tmpEntity}`;
56
82
  return {
57
83
  RecordSet: tmpSide.RecordSet || tmpEntity,
58
84
  Entity: tmpEntity,
59
- IDField: tmpSide.IDField || `ID${tmpEntity}`,
85
+ // The field on THIS SIDE's record that identifies it for association purposes — the picker
86
+ // value, the display lookup key, and (by default) the value stored in the join. Usually the
87
+ // primary key (`ID<Entity>`), but can be any unique field (e.g. ObservationManifest is keyed
88
+ // by 'Name' on its join, so its side is { IDField: 'Name' }).
89
+ IDField: tmpIDField,
90
+ // The column on the JOIN ENTITY that references this side. Defaults to IDField — the common
91
+ // case where the join column has the same name as the side's id field. Set it when the join
92
+ // names this side differently: ProjectObservationManifestJoin stores the manifest by
93
+ // 'ObservationManifestName', so the manifest side is { IDField: 'Name', JoinField: 'ObservationManifestName' }.
94
+ JoinField: tmpSide.JoinField || tmpIDField,
60
95
  DisplayField: tmpDisplayField,
61
96
  SearchFields: (Array.isArray(tmpSide.SearchFields) && tmpSide.SearchFields.length > 0) ? tmpSide.SearchFields : [ tmpDisplayField ],
62
97
  // No default sort: alphabetical-by-display sorts empty values first (blank rows). The picker's
@@ -110,6 +145,17 @@ class PictRecordSetAssociationManager extends libPictProvider
110
145
  return `FOP~0~(~0~${tmpInner}~FCP~0~)~0`;
111
146
  }
112
147
 
148
+ /**
149
+ * URL-encode each value and comma-join, for `INN`/`NIN` filter lists. Numeric ids pass through; this
150
+ * keeps string keys (e.g. manifest names with spaces) intact when a side is keyed by a name column.
151
+ * @param {Array<string|number>} pValues
152
+ * @return {string}
153
+ */
154
+ _encodeList(pValues)
155
+ {
156
+ return (Array.isArray(pValues) ? pValues : []).map((pValue) => encodeURIComponent(pValue)).join(',');
157
+ }
158
+
113
159
  /**
114
160
  * Fetch one page of a side's records for the matrix table (search across its SearchFields + optional
115
161
  * Sort, offset/limit paging). Returns the raw records + a `hasMore` flag.
@@ -161,6 +207,17 @@ class PictRecordSetAssociationManager extends libPictProvider
161
207
  JoinEntity: pDefinition.JoinEntity,
162
208
  JoinURLPrefix: pDefinition.JoinURLPrefix || '',
163
209
  DefaultJoinValues: pDefinition.DefaultJoinValues || {},
210
+ // Per-join config columns the editor renders as inline-editable on each association row — for
211
+ // "rich" joins that carry settings (e.g. Journal/Spreadsheet/Ordinal). Each entry:
212
+ // { Field, Label?, Type? ('checkbox'|'number'|'text'|'select'), Options?, Width? }.
213
+ JoinEditableFields: Array.isArray(pDefinition.JoinEditableFields) ? pDefinition.JoinEditableFields : [],
214
+ // The defaults lifecycle hook — the overridable seam the host implements. An async
215
+ // `(context) => Array<{ value, joinValues? }>` returning the OTHER side's key values (+ optional
216
+ // per-join config) to seed for an anchor record. PSRS owns APPLYING them (dedupe + createJoin),
217
+ // so every association synthesizes defaults the same way; the host only decides what they are.
218
+ SynthesizeDefaults: (typeof pDefinition.SynthesizeDefaults === 'function') ? pDefinition.SynthesizeDefaults : false,
219
+ // Opt-in: auto-run synthesizeDefaults the first time the editor opens an anchor with zero joins.
220
+ AutoSynthesizeWhenEmpty: (pDefinition.AutoSynthesizeWhenEmpty === true),
164
221
  SideA: this._normalizeSide(pDefinition.SideA),
165
222
  SideB: this._normalizeSide(pDefinition.SideB),
166
223
  };
@@ -283,7 +340,7 @@ class PictRecordSetAssociationManager extends libPictProvider
283
340
  return Promise.resolve([]);
284
341
  }
285
342
  const tmpEntityProvider = this._entityProvider(tmpSides.association.JoinURLPrefix);
286
- const tmpFilter = `FBV~${tmpSides.thisSide.IDField}~EQ~${encodeURIComponent(pThisID)}`;
343
+ const tmpFilter = `FBV~${tmpSides.thisSide.JoinField}~EQ~${encodeURIComponent(pThisID)}`;
287
344
  return new Promise((resolve) =>
288
345
  {
289
346
  tmpEntityProvider.getEntitySet(tmpSides.association.JoinEntity, tmpFilter, (pError, pRecords) =>
@@ -294,7 +351,7 @@ class PictRecordSetAssociationManager extends libPictProvider
294
351
  return resolve([]);
295
352
  }
296
353
  return resolve(Array.isArray(pRecords) ? pRecords : []);
297
- });
354
+ }, '', { Scope: this._cacheScope, NoCount: true });
298
355
  });
299
356
  }
300
357
 
@@ -315,7 +372,7 @@ class PictRecordSetAssociationManager extends libPictProvider
315
372
  return Promise.resolve([]);
316
373
  }
317
374
  const tmpEntityProvider = this._entityProvider(tmpSides.association.JoinURLPrefix);
318
- const tmpFilter = `FBL~${tmpSides.thisSide.IDField}~INN~${pThisIDs.join(',')}`;
375
+ const tmpFilter = `FBL~${tmpSides.thisSide.JoinField}~INN~${this._encodeList(pThisIDs)}`;
319
376
  return new Promise((resolve) =>
320
377
  {
321
378
  tmpEntityProvider.getEntitySet(tmpSides.association.JoinEntity, tmpFilter, (pError, pRecords) =>
@@ -326,7 +383,7 @@ class PictRecordSetAssociationManager extends libPictProvider
326
383
  return resolve([]);
327
384
  }
328
385
  return resolve(Array.isArray(pRecords) ? pRecords : []);
329
- });
386
+ }, '', { Scope: this._cacheScope, NoCount: true });
330
387
  });
331
388
  }
332
389
 
@@ -347,7 +404,7 @@ class PictRecordSetAssociationManager extends libPictProvider
347
404
  }
348
405
  const tmpJoins = await this.listJoinRecords(pAssociationHash, pThisRecordSetName, pThisID);
349
406
  return tmpJoins
350
- .map((pJoin) => pJoin[tmpSides.otherSide.IDField])
407
+ .map((pJoin) => pJoin[tmpSides.otherSide.JoinField])
351
408
  .filter((pValue) => (pValue !== undefined && pValue !== null && pValue !== ''));
352
409
  }
353
410
 
@@ -372,14 +429,14 @@ class PictRecordSetAssociationManager extends libPictProvider
372
429
  const tmpJoinIDField = this.getJoinIDField(tmpSides.association);
373
430
  const tmpJoins = await this.listJoinRecords(pAssociationHash, pThisRecordSetName, pThisID);
374
431
  const tmpOtherIDs = tmpJoins
375
- .map((pJoin) => pJoin[tmpSides.otherSide.IDField])
432
+ .map((pJoin) => pJoin[tmpSides.otherSide.JoinField])
376
433
  .filter((pValue) => (pValue !== undefined && pValue !== null && pValue !== ''));
377
434
 
378
435
  let tmpByID = {};
379
436
  if (tmpOtherIDs.length > 0)
380
437
  {
381
438
  const tmpEntityProvider = this._entityProvider(tmpSides.otherSide.URLPrefix);
382
- const tmpFilter = `FBL~${tmpSides.otherSide.IDField}~INN~${tmpOtherIDs.join(',')}`;
439
+ const tmpFilter = `FBL~${tmpSides.otherSide.IDField}~INN~${this._encodeList(tmpOtherIDs)}`;
383
440
  const tmpOthers = await new Promise((resolve) =>
384
441
  {
385
442
  tmpEntityProvider.getEntitySet(tmpSides.otherSide.Entity, tmpFilter, (pError, pRecords) =>
@@ -399,10 +456,10 @@ class PictRecordSetAssociationManager extends libPictProvider
399
456
  }
400
457
 
401
458
  return tmpJoins
402
- .filter((pJoin) => (pJoin[tmpSides.otherSide.IDField] !== undefined && pJoin[tmpSides.otherSide.IDField] !== null && pJoin[tmpSides.otherSide.IDField] !== ''))
459
+ .filter((pJoin) => (pJoin[tmpSides.otherSide.JoinField] !== undefined && pJoin[tmpSides.otherSide.JoinField] !== null && pJoin[tmpSides.otherSide.JoinField] !== ''))
403
460
  .map((pJoin) =>
404
461
  {
405
- const tmpOtherID = pJoin[tmpSides.otherSide.IDField];
462
+ const tmpOtherID = pJoin[tmpSides.otherSide.JoinField];
406
463
  const tmpOtherRecord = tmpByID[tmpOtherID] || {};
407
464
  const tmpDisplay = (tmpOtherRecord[tmpSides.otherSide.DisplayField] !== undefined && tmpOtherRecord[tmpSides.otherSide.DisplayField] !== null && tmpOtherRecord[tmpSides.otherSide.DisplayField] !== '')
408
465
  ? tmpOtherRecord[tmpSides.otherSide.DisplayField]
@@ -426,18 +483,20 @@ class PictRecordSetAssociationManager extends libPictProvider
426
483
  * @param {string} pThisRecordSetName
427
484
  * @param {string|number} pThisID
428
485
  * @param {string|number} pOtherID
486
+ * @param {Record<string, any>} [pJoinValues] - Optional extra columns to stamp on the join row (e.g.
487
+ * per-association config like { Journal: 1, Ordinal: 3 } from a synthesized default).
429
488
  * @return {Promise<Record<string, any>>}
430
489
  */
431
- createJoin(pAssociationHash, pThisRecordSetName, pThisID, pOtherID)
490
+ createJoin(pAssociationHash, pThisRecordSetName, pThisID, pOtherID, pJoinValues)
432
491
  {
433
492
  const tmpSides = this.resolveSides(pAssociationHash, pThisRecordSetName);
434
493
  if (!tmpSides)
435
494
  {
436
495
  return Promise.reject(new Error(`AssociationManager: cannot create join for [${pAssociationHash}] from [${pThisRecordSetName}].`));
437
496
  }
438
- const tmpRecord = Object.assign({}, tmpSides.association.DefaultJoinValues);
439
- tmpRecord[tmpSides.thisSide.IDField] = pThisID;
440
- tmpRecord[tmpSides.otherSide.IDField] = pOtherID;
497
+ const tmpRecord = Object.assign({}, tmpSides.association.DefaultJoinValues, pJoinValues || {});
498
+ tmpRecord[tmpSides.thisSide.JoinField] = pThisID;
499
+ tmpRecord[tmpSides.otherSide.JoinField] = pOtherID;
441
500
  const tmpEntityProvider = this._entityProvider(tmpSides.association.JoinURLPrefix);
442
501
  return new Promise((resolve, reject) =>
443
502
  {
@@ -447,6 +506,7 @@ class PictRecordSetAssociationManager extends libPictProvider
447
506
  {
448
507
  return reject(pError);
449
508
  }
509
+ this._clearAssociationCache(tmpEntityProvider);
450
510
  return resolve(pBody);
451
511
  });
452
512
  });
@@ -476,11 +536,143 @@ class PictRecordSetAssociationManager extends libPictProvider
476
536
  {
477
537
  return reject(pError);
478
538
  }
539
+ this._clearAssociationCache(tmpEntityProvider);
479
540
  return resolve(pBody);
480
541
  });
481
542
  });
482
543
  }
483
544
 
545
+ /**
546
+ * Update the config columns of an existing join row (a "rich" join's per-association settings, e.g.
547
+ * Journal / Spreadsheet / Ordinal). Merges pValues over the join record and PUTs it.
548
+ *
549
+ * @param {string} pAssociationHash
550
+ * @param {Record<string, any>} pJoinRecord - the full join row (as carried by listAssociatedRecords).
551
+ * @param {Record<string, any>} pValues - the columns to change.
552
+ * @return {Promise<Record<string, any>>}
553
+ */
554
+ updateJoin(pAssociationHash, pJoinRecord, pValues)
555
+ {
556
+ const tmpAssociation = this.associations[pAssociationHash];
557
+ if (!tmpAssociation || !pJoinRecord)
558
+ {
559
+ return Promise.reject(new Error(`AssociationManager: cannot update join for [${pAssociationHash}].`));
560
+ }
561
+ const tmpRecord = Object.assign({}, pJoinRecord, pValues || {});
562
+ const tmpEntityProvider = this._entityProvider(tmpAssociation.JoinURLPrefix);
563
+ return new Promise((resolve, reject) =>
564
+ {
565
+ tmpEntityProvider.updateEntity(tmpAssociation.JoinEntity, tmpRecord, (pError, pBody) =>
566
+ {
567
+ if (pError)
568
+ {
569
+ return reject(pError);
570
+ }
571
+ this._clearAssociationCache(tmpEntityProvider);
572
+ return resolve(pBody);
573
+ });
574
+ });
575
+ }
576
+
577
+ /**
578
+ * The per-join config columns an association exposes as inline-editable (empty for a plain join).
579
+ * @param {string} pAssociationHash
580
+ * @return {Array<Record<string, any>>}
581
+ */
582
+ getJoinEditableFields(pAssociationHash)
583
+ {
584
+ const tmpAssociation = this.associations[pAssociationHash];
585
+ return (tmpAssociation && Array.isArray(tmpAssociation.JoinEditableFields)) ? tmpAssociation.JoinEditableFields : [];
586
+ }
587
+
588
+ /**
589
+ * Whether an association has a host-provided defaults synthesizer registered.
590
+ * @param {string} pAssociationHash
591
+ * @return {boolean}
592
+ */
593
+ hasDefaultSynthesizer(pAssociationHash)
594
+ {
595
+ const tmpAssociation = this.associations[pAssociationHash];
596
+ return !!(tmpAssociation && (typeof tmpAssociation.SynthesizeDefaults === 'function'));
597
+ }
598
+
599
+ /**
600
+ * Synthesize default associations for one anchor record — THE consistent defaults lifecycle function.
601
+ * PSRS owns the "apply" half (dedupe against existing joins, then createJoin each new one with its
602
+ * config); the host owns only WHAT the defaults are, via the association's overridable
603
+ * `SynthesizeDefaults(context)` hook. No-op when no hook is registered.
604
+ *
605
+ * The hook receives `{ association, thisSide, otherSide, thisID, existingJoins, manager, pict }` and
606
+ * returns `Array<{ value, joinValues? }>` — `value` is the OTHER side's key value (what goes in the
607
+ * join's other JoinField), `joinValues` is optional extra config to stamp on that join row.
608
+ *
609
+ * @param {string} pAssociationHash
610
+ * @param {string} pThisRecordSetName
611
+ * @param {string|number} pThisID
612
+ * @return {Promise<{ created: number, skipped: number }>}
613
+ */
614
+ async synthesizeDefaults(pAssociationHash, pThisRecordSetName, pThisID)
615
+ {
616
+ const tmpSides = this.resolveSides(pAssociationHash, pThisRecordSetName);
617
+ if (!tmpSides || !this.hasDefaultSynthesizer(pAssociationHash) || pThisID === undefined || pThisID === null || pThisID === '')
618
+ {
619
+ return { created: 0, skipped: 0 };
620
+ }
621
+ const tmpExistingJoins = await this.listJoinRecords(pAssociationHash, pThisRecordSetName, pThisID);
622
+ const tmpExisting = {};
623
+ tmpExistingJoins.forEach((pJoin) =>
624
+ {
625
+ const tmpValue = pJoin[tmpSides.otherSide.JoinField];
626
+ if (tmpValue !== undefined && tmpValue !== null && tmpValue !== '') { tmpExisting[`${tmpValue}`] = true; }
627
+ });
628
+
629
+ let tmpDefaults = [];
630
+ try
631
+ {
632
+ tmpDefaults = await tmpSides.association.SynthesizeDefaults(
633
+ {
634
+ association: tmpSides.association,
635
+ thisSide: tmpSides.thisSide,
636
+ otherSide: tmpSides.otherSide,
637
+ thisID: pThisID,
638
+ existingJoins: tmpExistingJoins,
639
+ manager: this,
640
+ pict: this.pict,
641
+ });
642
+ }
643
+ catch (pError)
644
+ {
645
+ this.pict.log.error(`AssociationManager: SynthesizeDefaults hook for [${pAssociationHash}] threw: ${pError.message || pError}`);
646
+ return { created: 0, skipped: 0 };
647
+ }
648
+
649
+ let tmpCreated = 0;
650
+ let tmpSkipped = 0;
651
+ const tmpList = Array.isArray(tmpDefaults) ? tmpDefaults : [];
652
+ for (let i = 0; i < tmpList.length; i++)
653
+ {
654
+ const tmpDefault = tmpList[i] || {};
655
+ const tmpValue = (tmpDefault.value !== undefined) ? tmpDefault.value : tmpDefault.Value;
656
+ if (tmpValue === undefined || tmpValue === null || tmpValue === '' || tmpExisting[`${tmpValue}`])
657
+ {
658
+ tmpSkipped++;
659
+ continue;
660
+ }
661
+ try
662
+ {
663
+ await this.createJoin(pAssociationHash, pThisRecordSetName, pThisID, tmpValue, tmpDefault.joinValues || tmpDefault.JoinValues || {});
664
+ tmpExisting[`${tmpValue}`] = true;
665
+ tmpCreated++;
666
+ }
667
+ catch (pError)
668
+ {
669
+ this.pict.log.warn(`AssociationManager: synthesize createJoin failed for [${pAssociationHash}] value [${tmpValue}]: ${pError.message || pError}`);
670
+ tmpSkipped++;
671
+ }
672
+ }
673
+ return { created: tmpCreated, skipped: tmpSkipped };
674
+ }
675
+
484
676
  /**
485
677
  * Build a `createEntityPicker` config for one side, optionally culling a live set of ids (a function
486
678
  * so the cull re-evaluates on every search as associations change).
@@ -511,7 +703,7 @@ class PictRecordSetAssociationManager extends libPictProvider
511
703
  tmpConfig.BaseFilter = () =>
512
704
  {
513
705
  const tmpIDs = pGetExcludedIDsFn();
514
- return (Array.isArray(tmpIDs) && tmpIDs.length > 0) ? `FBL~${pSide.IDField}~NIN~${tmpIDs.join(',')}` : '';
706
+ return (Array.isArray(tmpIDs) && tmpIDs.length > 0) ? `FBL~${pSide.IDField}~NIN~${this._encodeList(tmpIDs)}` : '';
515
707
  };
516
708
  }
517
709
  return Object.assign(tmpConfig, pOverrides || {});