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
|
@@ -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
|
-
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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~${
|
|
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.
|
|
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.
|
|
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.
|
|
465
|
-
tmpRecord[tmpSides.otherSide.
|
|
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~${
|
|
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-
|
|
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
|
-
//
|
|
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
|