pict-section-recordset 1.18.0 → 1.20.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.20.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,146 @@ 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
+ // Minimal update — the join id + only the changed columns. The read-decorated join row can carry
562
+ // derived, non-column fields (e.g. a joined ModuleConfig) that the server rejects on write; Meadow
563
+ // updates only the fields provided, so a minimal record is both correct and safe.
564
+ const tmpJoinIDField = this.getJoinIDField(tmpAssociation);
565
+ const tmpRecord = Object.assign({ [tmpJoinIDField]: pJoinRecord[tmpJoinIDField] }, pValues || {});
566
+ const tmpEntityProvider = this._entityProvider(tmpAssociation.JoinURLPrefix);
567
+ return new Promise((resolve, reject) =>
568
+ {
569
+ tmpEntityProvider.updateEntity(tmpAssociation.JoinEntity, tmpRecord, (pError, pBody) =>
570
+ {
571
+ if (pError)
572
+ {
573
+ return reject(pError);
574
+ }
575
+ // Meadow returns a non-2xx error in the body (not the callback) — surface it as a failure.
576
+ if (pBody && pBody.ErrorCode)
577
+ {
578
+ return reject(new Error(`AssociationManager: join update rejected (ErrorCode ${pBody.ErrorCode}).`));
579
+ }
580
+ this._clearAssociationCache(tmpEntityProvider);
581
+ return resolve(pBody);
582
+ });
583
+ });
584
+ }
585
+
586
+ /**
587
+ * The per-join config columns an association exposes as inline-editable (empty for a plain join).
588
+ * @param {string} pAssociationHash
589
+ * @return {Array<Record<string, any>>}
590
+ */
591
+ getJoinEditableFields(pAssociationHash)
592
+ {
593
+ const tmpAssociation = this.associations[pAssociationHash];
594
+ return (tmpAssociation && Array.isArray(tmpAssociation.JoinEditableFields)) ? tmpAssociation.JoinEditableFields : [];
595
+ }
596
+
597
+ /**
598
+ * Whether an association has a host-provided defaults synthesizer registered.
599
+ * @param {string} pAssociationHash
600
+ * @return {boolean}
601
+ */
602
+ hasDefaultSynthesizer(pAssociationHash)
603
+ {
604
+ const tmpAssociation = this.associations[pAssociationHash];
605
+ return !!(tmpAssociation && (typeof tmpAssociation.SynthesizeDefaults === 'function'));
606
+ }
607
+
608
+ /**
609
+ * Synthesize default associations for one anchor record — THE consistent defaults lifecycle function.
610
+ * PSRS owns the "apply" half (dedupe against existing joins, then createJoin each new one with its
611
+ * config); the host owns only WHAT the defaults are, via the association's overridable
612
+ * `SynthesizeDefaults(context)` hook. No-op when no hook is registered.
613
+ *
614
+ * The hook receives `{ association, thisSide, otherSide, thisID, existingJoins, manager, pict }` and
615
+ * returns `Array<{ value, joinValues? }>` — `value` is the OTHER side's key value (what goes in the
616
+ * join's other JoinField), `joinValues` is optional extra config to stamp on that join row.
617
+ *
618
+ * @param {string} pAssociationHash
619
+ * @param {string} pThisRecordSetName
620
+ * @param {string|number} pThisID
621
+ * @return {Promise<{ created: number, skipped: number }>}
622
+ */
623
+ async synthesizeDefaults(pAssociationHash, pThisRecordSetName, pThisID)
624
+ {
625
+ const tmpSides = this.resolveSides(pAssociationHash, pThisRecordSetName);
626
+ if (!tmpSides || !this.hasDefaultSynthesizer(pAssociationHash) || pThisID === undefined || pThisID === null || pThisID === '')
627
+ {
628
+ return { created: 0, skipped: 0 };
629
+ }
630
+ const tmpExistingJoins = await this.listJoinRecords(pAssociationHash, pThisRecordSetName, pThisID);
631
+ const tmpExisting = {};
632
+ tmpExistingJoins.forEach((pJoin) =>
633
+ {
634
+ const tmpValue = pJoin[tmpSides.otherSide.JoinField];
635
+ if (tmpValue !== undefined && tmpValue !== null && tmpValue !== '') { tmpExisting[`${tmpValue}`] = true; }
636
+ });
637
+
638
+ let tmpDefaults = [];
639
+ try
640
+ {
641
+ tmpDefaults = await tmpSides.association.SynthesizeDefaults(
642
+ {
643
+ association: tmpSides.association,
644
+ thisSide: tmpSides.thisSide,
645
+ otherSide: tmpSides.otherSide,
646
+ thisID: pThisID,
647
+ existingJoins: tmpExistingJoins,
648
+ manager: this,
649
+ pict: this.pict,
650
+ });
651
+ }
652
+ catch (pError)
653
+ {
654
+ this.pict.log.error(`AssociationManager: SynthesizeDefaults hook for [${pAssociationHash}] threw: ${pError.message || pError}`);
655
+ return { created: 0, skipped: 0 };
656
+ }
657
+
658
+ let tmpCreated = 0;
659
+ let tmpSkipped = 0;
660
+ const tmpList = Array.isArray(tmpDefaults) ? tmpDefaults : [];
661
+ for (let i = 0; i < tmpList.length; i++)
662
+ {
663
+ const tmpDefault = tmpList[i] || {};
664
+ const tmpValue = (tmpDefault.value !== undefined) ? tmpDefault.value : tmpDefault.Value;
665
+ if (tmpValue === undefined || tmpValue === null || tmpValue === '' || tmpExisting[`${tmpValue}`])
666
+ {
667
+ tmpSkipped++;
668
+ continue;
669
+ }
670
+ try
671
+ {
672
+ await this.createJoin(pAssociationHash, pThisRecordSetName, pThisID, tmpValue, tmpDefault.joinValues || tmpDefault.JoinValues || {});
673
+ tmpExisting[`${tmpValue}`] = true;
674
+ tmpCreated++;
675
+ }
676
+ catch (pError)
677
+ {
678
+ this.pict.log.warn(`AssociationManager: synthesize createJoin failed for [${pAssociationHash}] value [${tmpValue}]: ${pError.message || pError}`);
679
+ tmpSkipped++;
680
+ }
681
+ }
682
+ return { created: tmpCreated, skipped: tmpSkipped };
683
+ }
684
+
511
685
  /**
512
686
  * Build a `createEntityPicker` config for one side, optionally culling a live set of ids (a function
513
687
  * so the cull re-evaluates on every search as associations change).
@@ -538,7 +712,7 @@ class PictRecordSetAssociationManager extends libPictProvider
538
712
  tmpConfig.BaseFilter = () =>
539
713
  {
540
714
  const tmpIDs = pGetExcludedIDsFn();
541
- return (Array.isArray(tmpIDs) && tmpIDs.length > 0) ? `FBL~${pSide.IDField}~NIN~${tmpIDs.join(',')}` : '';
715
+ return (Array.isArray(tmpIDs) && tmpIDs.length > 0) ? `FBL~${pSide.IDField}~NIN~${this._encodeList(tmpIDs)}` : '';
542
716
  };
543
717
  }
544
718
  return Object.assign(tmpConfig, pOverrides || {});
@@ -65,6 +65,16 @@ const _DEFAULT_CONFIGURATION_AssociationEditor = (
65
65
  .prsp-assoc-remove:hover { color: var(--theme-color-status-error, #b62828); background: color-mix(in srgb, var(--theme-color-status-error, #b62828) 10%, transparent); }
66
66
  .prsp-assoc-empty { padding: 0.7rem 0.2rem; color: var(--theme-color-text-muted, #6b7686); font-size: 0.88rem; font-style: italic; }
67
67
  .prsp-assoc-note { color: var(--theme-color-status-error, #b62828); font-size: 0.86rem; }
68
+ .prsp-assoc-head-right { display: flex; align-items: center; gap: 0.6rem; }
69
+ .prsp-assoc-synth-btn { display: inline-flex; align-items: center; gap: 0.3rem; cursor: pointer; font: inherit; font-size: 0.76rem; font-weight: 600;
70
+ padding: 0.2rem 0.55rem; border-radius: 6px; border: 1px solid var(--theme-color-border-default, #d7dce3);
71
+ background: var(--theme-color-background-tertiary, #eceef2); color: var(--theme-color-text-secondary, #45596b); }
72
+ .prsp-assoc-synth-btn:hover { background: var(--theme-color-background-secondary, #e2e6ec); }
73
+ .prsp-assoc-row-cfg { flex: 0 0 auto; display: flex; align-items: center; gap: 0.65rem; }
74
+ .prsp-assoc-cfg { display: inline-flex; align-items: center; gap: 0.25rem; font-size: 0.78rem; color: var(--theme-color-text-secondary, #45596b); white-space: nowrap; cursor: default; }
75
+ .prsp-assoc-cfg input[type="checkbox"] { cursor: pointer; }
76
+ .prsp-assoc-cfg-num { width: 3.4rem; font: inherit; font-size: 0.8rem; padding: 0.1rem 0.3rem; border: 1px solid var(--theme-color-border-light, #e8ebf0); border-radius: 5px; }
77
+ .prsp-assoc-cfg-sel { font: inherit; font-size: 0.8rem; padding: 0.05rem 0.2rem; border: 1px solid var(--theme-color-border-light, #e8ebf0); border-radius: 5px; }
68
78
  `,
69
79
  CSSPriority: 500,
70
80
 
@@ -86,7 +96,10 @@ const _DEFAULT_CONFIGURATION_AssociationEditor = (
86
96
  <div class="prsp-assoc-list">
87
97
  <div class="prsp-assoc-list-head">
88
98
  <span class="prsp-assoc-list-title">{~D:Record.ListLabel~}</span>
89
- <span class="prsp-assoc-count">{~D:Record.Count~}</span>
99
+ <span class="prsp-assoc-head-right">
100
+ <span class="prsp-assoc-count">{~D:Record.Count~}</span>
101
+ {~TS:PRSP-AssociationEditor-SynthBtn:Record.SynthesizeSlot~}
102
+ </span>
90
103
  </div>
91
104
  {~TS:PRSP-AssociationEditor-Row:Record.Items~}
92
105
  {~TS:PRSP-AssociationEditor-Empty:Record.EmptySlot~}
@@ -105,6 +118,7 @@ const _DEFAULT_CONFIGURATION_AssociationEditor = (
105
118
  <div class="prsp-assoc-row">
106
119
  <span class="prsp-assoc-row-name">{~D:Record.Display~}</span>
107
120
  <span class="prsp-assoc-row-chips">{~TS:PRSP-AssociationEditor-Chip:Record.Chips~}</span>
121
+ <span class="prsp-assoc-row-cfg">{~TS:PRSP-AssociationEditor-EditField:Record.EditFields~}</span>
108
122
  <span class="prsp-assoc-row-id">#{~D:Record.OtherID~}</span>
109
123
  <button type="button" class="prsp-assoc-remove" title="Remove association" onclick="_Pict.views['{~D:Record.ViewHash~}'].removeItem({~D:Record.JoinID~})">{~I:Trash~}</button>
110
124
  </div>
@@ -113,6 +127,36 @@ const _DEFAULT_CONFIGURATION_AssociationEditor = (
113
127
  {
114
128
  Hash: 'PRSP-AssociationEditor-Chip',
115
129
  Template: /*html*/`<span class="prsp-assoc-chip">{~D:Record.Text~}</span>`
130
+ },
131
+ {
132
+ Hash: 'PRSP-AssociationEditor-SynthBtn',
133
+ Template: /*html*/`<button type="button" class="prsp-assoc-synth-btn" title="Add the default associations" onclick="_Pict.views['{~D:Record.ViewHash~}'].synthesizeDefaults()">{~I:Download~} Add defaults</button>`
134
+ },
135
+ {
136
+ // One editable per-join config control (a "rich" join's settings, e.g. Journal / Spreadsheet
137
+ // / Ordinal). The kind flags pick the input; onchange writes through to the join row.
138
+ Hash: 'PRSP-AssociationEditor-EditField',
139
+ Template: /*html*/`{~TIfAbs:PRSP-AssociationEditor-EditCheckbox:Record:Record.IsCheckbox^TRUE^~}{~TIfAbs:PRSP-AssociationEditor-EditNumber:Record:Record.IsNumber^TRUE^~}{~TIfAbs:PRSP-AssociationEditor-EditSelect:Record:Record.IsSelect^TRUE^~}{~TIfAbs:PRSP-AssociationEditor-EditText:Record:Record.IsText^TRUE^~}`
140
+ },
141
+ {
142
+ Hash: 'PRSP-AssociationEditor-EditCheckbox',
143
+ Template: /*html*/`<label class="prsp-assoc-cfg"><input type="checkbox" {~D:Record.CheckedAttr~} onchange="_Pict.views['{~D:Record.ViewHash~}'].updateField({~D:Record.JoinID~},'{~D:Record.Field~}',this.checked)" />{~D:Record.Label~}</label>`
144
+ },
145
+ {
146
+ Hash: 'PRSP-AssociationEditor-EditNumber',
147
+ Template: /*html*/`<label class="prsp-assoc-cfg">{~D:Record.Label~}<input type="number" class="prsp-assoc-cfg-num" value="{~D:Record.Value~}" onchange="_Pict.views['{~D:Record.ViewHash~}'].updateField({~D:Record.JoinID~},'{~D:Record.Field~}',this.value)" /></label>`
148
+ },
149
+ {
150
+ Hash: 'PRSP-AssociationEditor-EditSelect',
151
+ Template: /*html*/`<label class="prsp-assoc-cfg">{~D:Record.Label~}<select class="prsp-assoc-cfg-sel" onchange="_Pict.views['{~D:Record.ViewHash~}'].updateField({~D:Record.JoinID~},'{~D:Record.Field~}',this.value)">{~TS:PRSP-AssociationEditor-EditOption:Record.Options~}</select></label>`
152
+ },
153
+ {
154
+ Hash: 'PRSP-AssociationEditor-EditText',
155
+ Template: /*html*/`<label class="prsp-assoc-cfg">{~D:Record.Label~}<input type="text" class="prsp-assoc-cfg-sel" value="{~D:Record.Value~}" onchange="_Pict.views['{~D:Record.ViewHash~}'].updateField({~D:Record.JoinID~},'{~D:Record.Field~}',this.value)" /></label>`
156
+ },
157
+ {
158
+ Hash: 'PRSP-AssociationEditor-EditOption',
159
+ Template: /*html*/`<option value="{~D:Record.Value~}" {~D:Record.SelectedAttr~}>{~D:Record.Label~}</option>`
116
160
  }
117
161
  ],
118
162
 
@@ -141,8 +185,10 @@ class viewRecordSetAssociationEditor extends libPictView
141
185
 
142
186
  // The other-side ids currently associated (the live cull set the picker reads via a closure).
143
187
  this._otherIDs = [];
144
- // The current list items (so removeItem can find the exact join record by JoinID).
188
+ // The current list items (so removeItem/updateField can find the exact join record by JoinID).
145
189
  this._lastItems = [];
190
+ // Guard so AutoSynthesizeWhenEmpty runs at most once per anchor (no re-synthesize loop).
191
+ this._autoSynthesizedFor = null;
146
192
  }
147
193
 
148
194
  /** @return {any} The association manager provider. */
@@ -178,12 +224,28 @@ class viewRecordSetAssociationEditor extends libPictView
178
224
  if (tmpThisID !== undefined && tmpThisID !== null && tmpThisID !== '')
179
225
  {
180
226
  tmpItems = await this.manager.listAssociatedRecords(this.options.AssociationHash, this.options.ThisRecordSet, tmpThisID);
227
+
228
+ // Opt-in: synthesize the default associations the first time an empty anchor is opened.
229
+ const tmpAssociation = this.manager.getAssociation(this.options.AssociationHash);
230
+ if (tmpAssociation && tmpAssociation.AutoSynthesizeWhenEmpty && (tmpItems.length === 0)
231
+ && this.manager.hasDefaultSynthesizer(this.options.AssociationHash) && (this._autoSynthesizedFor !== `${tmpThisID}`))
232
+ {
233
+ this._autoSynthesizedFor = `${tmpThisID}`;
234
+ const tmpSynth = await this.manager.synthesizeDefaults(this.options.AssociationHash, this.options.ThisRecordSet, tmpThisID);
235
+ if (tmpSynth && (tmpSynth.created > 0))
236
+ {
237
+ tmpItems = await this.manager.listAssociatedRecords(this.options.AssociationHash, this.options.ThisRecordSet, tmpThisID);
238
+ }
239
+ }
181
240
  }
182
241
 
183
- // Stamp the view hash on each row so the row template's remove button can reach this instance.
242
+ // Per-join editable config controls (for "rich" joins) empty array for a plain link.
243
+ const tmpEditableFields = this.manager.getJoinEditableFields(this.options.AssociationHash);
244
+ // Stamp the view hash + the editable controls on each row.
184
245
  for (let i = 0; i < tmpItems.length; i++)
185
246
  {
186
247
  tmpItems[i].ViewHash = this.Hash;
248
+ tmpItems[i].EditFields = this._buildEditFields(tmpEditableFields, tmpItems[i]);
187
249
  }
188
250
  this._lastItems = tmpItems;
189
251
  this._otherIDs = tmpItems.map((pItem) => pItem.OtherID);
@@ -203,6 +265,8 @@ class viewRecordSetAssociationEditor extends libPictView
203
265
  // One-or-zero-element slot drives the empty-state line (TS parses inner tags; NE would not).
204
266
  EmptySlot: (tmpItems.length === 0) ? [ { EmptyText: `No ${tmpOtherLabel} associated yet — use the search above to add some.` } ] : [],
205
267
  PickerMissing: !tmpPickerPresent,
268
+ // The "Add defaults" button shows only when the host registered a defaults synthesizer.
269
+ SynthesizeSlot: this.manager.hasDefaultSynthesizer(this.options.AssociationHash) ? [ { ViewHash: this.Hash } ] : [],
206
270
  };
207
271
 
208
272
  return new Promise((resolve) =>
@@ -351,6 +415,98 @@ class viewRecordSetAssociationEditor extends libPictView
351
415
  return fRemove();
352
416
  }
353
417
 
418
+ /**
419
+ * Shape an association's editable join-config fields into per-row render data (current value + the
420
+ * kind flags the row template dispatches on).
421
+ * @param {Array<Record<string, any>>} pFields - the association's JoinEditableFields.
422
+ * @param {Record<string, any>} pItem - one decorated association row (carries its JoinRecord).
423
+ * @return {Array<Record<string, any>>}
424
+ */
425
+ _buildEditFields(pFields, pItem)
426
+ {
427
+ if (!Array.isArray(pFields) || pFields.length < 1)
428
+ {
429
+ return [];
430
+ }
431
+ const tmpJoin = pItem.JoinRecord || {};
432
+ return pFields.map((pField) =>
433
+ {
434
+ const tmpType = pField.Type || 'text';
435
+ const tmpRaw = tmpJoin[pField.Field];
436
+ return {
437
+ ViewHash: this.Hash,
438
+ JoinID: pItem.JoinID,
439
+ Field: pField.Field,
440
+ Label: pField.Label || pField.Field,
441
+ IsCheckbox: (tmpType === 'checkbox'),
442
+ IsNumber: (tmpType === 'number'),
443
+ IsSelect: (tmpType === 'select'),
444
+ IsText: (tmpType === 'text'),
445
+ CheckedAttr: ((tmpType === 'checkbox') && (tmpRaw === 1 || tmpRaw === true || tmpRaw === '1')) ? 'checked' : '',
446
+ Value: (tmpRaw === undefined || tmpRaw === null) ? '' : tmpRaw,
447
+ Options: (Array.isArray(pField.Options) ? pField.Options : []).map((pOption) =>
448
+ {
449
+ const tmpValue = (pOption && typeof pOption === 'object') ? pOption.Value : pOption;
450
+ return { Value: tmpValue, Label: (pOption && typeof pOption === 'object') ? (pOption.Label || pOption.Value) : pOption, SelectedAttr: (`${tmpRaw}` === `${tmpValue}`) ? 'selected' : '' };
451
+ }),
452
+ };
453
+ });
454
+ }
455
+
456
+ /**
457
+ * Persist one per-join config change (a "rich" join's editable column). Writes through to the join
458
+ * row in place so focus is kept; reverts to the server state on failure.
459
+ * @param {string|number} pJoinID @param {string} pField @param {any} pValue
460
+ * @return {Promise<void>}
461
+ */
462
+ async updateField(pJoinID, pField, pValue)
463
+ {
464
+ const tmpItem = this._lastItems.find((pItem) => String(pItem.JoinID) === String(pJoinID));
465
+ if (!tmpItem || !tmpItem.JoinRecord)
466
+ {
467
+ return;
468
+ }
469
+ // Meadow booleans are 1/0.
470
+ const tmpValue = (pValue === true) ? 1 : ((pValue === false) ? 0 : pValue);
471
+ try
472
+ {
473
+ await this.manager.updateJoin(this.options.AssociationHash, tmpItem.JoinRecord, { [pField]: tmpValue });
474
+ tmpItem.JoinRecord[pField] = tmpValue;
475
+ }
476
+ catch (pError)
477
+ {
478
+ this.pict.log.error(`AssociationEditor [${this.Hash}]: failed to update join field ${pField}.`, pError);
479
+ this._toast('Could not save the change.', 'error');
480
+ await this.renderEditor();
481
+ }
482
+ }
483
+
484
+ /**
485
+ * Seed the default associations for this anchor via the association's `SynthesizeDefaults` hook (the
486
+ * "Add defaults" button). PSRS dedupes + creates; the host decides what the defaults are. Reloads after.
487
+ * @return {Promise<void>}
488
+ */
489
+ async synthesizeDefaults()
490
+ {
491
+ if (!this.manager.hasDefaultSynthesizer(this.options.AssociationHash))
492
+ {
493
+ return;
494
+ }
495
+ let tmpResult = { created: 0, skipped: 0 };
496
+ try
497
+ {
498
+ tmpResult = await this.manager.synthesizeDefaults(this.options.AssociationHash, this.options.ThisRecordSet, this.options.ThisID);
499
+ }
500
+ catch (pError)
501
+ {
502
+ this.pict.log.error(`AssociationEditor [${this.Hash}]: synthesize defaults failed.`, pError);
503
+ this._toast('Could not add the defaults.', 'error');
504
+ return;
505
+ }
506
+ this._toast((tmpResult.created > 0) ? `Added ${tmpResult.created} default${tmpResult.created === 1 ? '' : 's'}.` : 'No new defaults to add.', (tmpResult.created > 0) ? 'success' : 'info');
507
+ await this.renderEditor();
508
+ }
509
+
354
510
  /**
355
511
  * Non-blocking notification via the host modal's toast, when available.
356
512
  * @param {string} pMessage @param {string} pType