pict-section-recordset 1.18.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.18.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": [
@@ -78,10 +78,20 @@ class PictRecordSetAssociationManager extends libPictProvider
78
78
  const tmpSide = pSide || {};
79
79
  const tmpEntity = tmpSide.Entity || tmpSide.RecordSet;
80
80
  const tmpDisplayField = tmpSide.DisplayField || 'Name';
81
+ const tmpIDField = tmpSide.IDField || `ID${tmpEntity}`;
81
82
  return {
82
83
  RecordSet: tmpSide.RecordSet || tmpEntity,
83
84
  Entity: tmpEntity,
84
- 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,
85
95
  DisplayField: tmpDisplayField,
86
96
  SearchFields: (Array.isArray(tmpSide.SearchFields) && tmpSide.SearchFields.length > 0) ? tmpSide.SearchFields : [ tmpDisplayField ],
87
97
  // No default sort: alphabetical-by-display sorts empty values first (blank rows). The picker's
@@ -135,6 +145,17 @@ class PictRecordSetAssociationManager extends libPictProvider
135
145
  return `FOP~0~(~0~${tmpInner}~FCP~0~)~0`;
136
146
  }
137
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
+
138
159
  /**
139
160
  * Fetch one page of a side's records for the matrix table (search across its SearchFields + optional
140
161
  * Sort, offset/limit paging). Returns the raw records + a `hasMore` flag.
@@ -186,6 +207,17 @@ class PictRecordSetAssociationManager extends libPictProvider
186
207
  JoinEntity: pDefinition.JoinEntity,
187
208
  JoinURLPrefix: pDefinition.JoinURLPrefix || '',
188
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),
189
221
  SideA: this._normalizeSide(pDefinition.SideA),
190
222
  SideB: this._normalizeSide(pDefinition.SideB),
191
223
  };
@@ -308,7 +340,7 @@ class PictRecordSetAssociationManager extends libPictProvider
308
340
  return Promise.resolve([]);
309
341
  }
310
342
  const tmpEntityProvider = this._entityProvider(tmpSides.association.JoinURLPrefix);
311
- const tmpFilter = `FBV~${tmpSides.thisSide.IDField}~EQ~${encodeURIComponent(pThisID)}`;
343
+ const tmpFilter = `FBV~${tmpSides.thisSide.JoinField}~EQ~${encodeURIComponent(pThisID)}`;
312
344
  return new Promise((resolve) =>
313
345
  {
314
346
  tmpEntityProvider.getEntitySet(tmpSides.association.JoinEntity, tmpFilter, (pError, pRecords) =>
@@ -340,7 +372,7 @@ class PictRecordSetAssociationManager extends libPictProvider
340
372
  return Promise.resolve([]);
341
373
  }
342
374
  const tmpEntityProvider = this._entityProvider(tmpSides.association.JoinURLPrefix);
343
- const tmpFilter = `FBL~${tmpSides.thisSide.IDField}~INN~${pThisIDs.join(',')}`;
375
+ const tmpFilter = `FBL~${tmpSides.thisSide.JoinField}~INN~${this._encodeList(pThisIDs)}`;
344
376
  return new Promise((resolve) =>
345
377
  {
346
378
  tmpEntityProvider.getEntitySet(tmpSides.association.JoinEntity, tmpFilter, (pError, pRecords) =>
@@ -372,7 +404,7 @@ class PictRecordSetAssociationManager extends libPictProvider
372
404
  }
373
405
  const tmpJoins = await this.listJoinRecords(pAssociationHash, pThisRecordSetName, pThisID);
374
406
  return tmpJoins
375
- .map((pJoin) => pJoin[tmpSides.otherSide.IDField])
407
+ .map((pJoin) => pJoin[tmpSides.otherSide.JoinField])
376
408
  .filter((pValue) => (pValue !== undefined && pValue !== null && pValue !== ''));
377
409
  }
378
410
 
@@ -397,14 +429,14 @@ class PictRecordSetAssociationManager extends libPictProvider
397
429
  const tmpJoinIDField = this.getJoinIDField(tmpSides.association);
398
430
  const tmpJoins = await this.listJoinRecords(pAssociationHash, pThisRecordSetName, pThisID);
399
431
  const tmpOtherIDs = tmpJoins
400
- .map((pJoin) => pJoin[tmpSides.otherSide.IDField])
432
+ .map((pJoin) => pJoin[tmpSides.otherSide.JoinField])
401
433
  .filter((pValue) => (pValue !== undefined && pValue !== null && pValue !== ''));
402
434
 
403
435
  let tmpByID = {};
404
436
  if (tmpOtherIDs.length > 0)
405
437
  {
406
438
  const tmpEntityProvider = this._entityProvider(tmpSides.otherSide.URLPrefix);
407
- const tmpFilter = `FBL~${tmpSides.otherSide.IDField}~INN~${tmpOtherIDs.join(',')}`;
439
+ const tmpFilter = `FBL~${tmpSides.otherSide.IDField}~INN~${this._encodeList(tmpOtherIDs)}`;
408
440
  const tmpOthers = await new Promise((resolve) =>
409
441
  {
410
442
  tmpEntityProvider.getEntitySet(tmpSides.otherSide.Entity, tmpFilter, (pError, pRecords) =>
@@ -424,10 +456,10 @@ class PictRecordSetAssociationManager extends libPictProvider
424
456
  }
425
457
 
426
458
  return tmpJoins
427
- .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] !== ''))
428
460
  .map((pJoin) =>
429
461
  {
430
- const tmpOtherID = pJoin[tmpSides.otherSide.IDField];
462
+ const tmpOtherID = pJoin[tmpSides.otherSide.JoinField];
431
463
  const tmpOtherRecord = tmpByID[tmpOtherID] || {};
432
464
  const tmpDisplay = (tmpOtherRecord[tmpSides.otherSide.DisplayField] !== undefined && tmpOtherRecord[tmpSides.otherSide.DisplayField] !== null && tmpOtherRecord[tmpSides.otherSide.DisplayField] !== '')
433
465
  ? tmpOtherRecord[tmpSides.otherSide.DisplayField]
@@ -451,18 +483,20 @@ class PictRecordSetAssociationManager extends libPictProvider
451
483
  * @param {string} pThisRecordSetName
452
484
  * @param {string|number} pThisID
453
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).
454
488
  * @return {Promise<Record<string, any>>}
455
489
  */
456
- createJoin(pAssociationHash, pThisRecordSetName, pThisID, pOtherID)
490
+ createJoin(pAssociationHash, pThisRecordSetName, pThisID, pOtherID, pJoinValues)
457
491
  {
458
492
  const tmpSides = this.resolveSides(pAssociationHash, pThisRecordSetName);
459
493
  if (!tmpSides)
460
494
  {
461
495
  return Promise.reject(new Error(`AssociationManager: cannot create join for [${pAssociationHash}] from [${pThisRecordSetName}].`));
462
496
  }
463
- const tmpRecord = Object.assign({}, tmpSides.association.DefaultJoinValues);
464
- tmpRecord[tmpSides.thisSide.IDField] = pThisID;
465
- 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;
466
500
  const tmpEntityProvider = this._entityProvider(tmpSides.association.JoinURLPrefix);
467
501
  return new Promise((resolve, reject) =>
468
502
  {
@@ -508,6 +542,137 @@ class PictRecordSetAssociationManager extends libPictProvider
508
542
  });
509
543
  }
510
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
+
511
676
  /**
512
677
  * Build a `createEntityPicker` config for one side, optionally culling a live set of ids (a function
513
678
  * so the cull re-evaluates on every search as associations change).
@@ -538,7 +703,7 @@ class PictRecordSetAssociationManager extends libPictProvider
538
703
  tmpConfig.BaseFilter = () =>
539
704
  {
540
705
  const tmpIDs = pGetExcludedIDsFn();
541
- 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)}` : '';
542
707
  };
543
708
  }
544
709
  return Object.assign(tmpConfig, pOverrides || {});