pict-section-recordset 1.19.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.19.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": [
@@ -558,7 +558,11 @@ class PictRecordSetAssociationManager extends libPictProvider
558
558
  {
559
559
  return Promise.reject(new Error(`AssociationManager: cannot update join for [${pAssociationHash}].`));
560
560
  }
561
- const tmpRecord = Object.assign({}, pJoinRecord, pValues || {});
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 || {});
562
566
  const tmpEntityProvider = this._entityProvider(tmpAssociation.JoinURLPrefix);
563
567
  return new Promise((resolve, reject) =>
564
568
  {
@@ -568,6 +572,11 @@ class PictRecordSetAssociationManager extends libPictProvider
568
572
  {
569
573
  return reject(pError);
570
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
+ }
571
580
  this._clearAssociationCache(tmpEntityProvider);
572
581
  return resolve(pBody);
573
582
  });
@@ -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