pict-section-recordset 1.11.0 → 1.17.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.
@@ -0,0 +1,370 @@
1
+ const libPictView = require('pict-view');
2
+
3
+ /**
4
+ * The Association Editor — a small, embeddable widget that manages one many-to-many association from
5
+ * the perspective of a single anchor record (e.g. the Authors of THIS book). It renders:
6
+ * - a searchable entity picker of the OTHER side, with the currently-associated rows culled out, and
7
+ * - a list of the current associations, each with a remove button.
8
+ *
9
+ * It is instantiated once per (anchor recordset, association) by the read view's Association tab, with
10
+ * a unique Hash. All join + display data flows through the shared `RecordSetAssociationManager`; the
11
+ * picker comes from `pict-section-picker` (a soft dependency — the host registers it as
12
+ * `Pict-Section-Picker`). Instance-specific state is passed via the render Record (not a per-instance
13
+ * AppData address), so the shared templates stay address-stable across instances.
14
+ *
15
+ * Configuration (set by the read view when it creates the instance):
16
+ * - AssociationHash {string} - the association to manage.
17
+ * - ThisRecordSet {string} - the anchor recordset name (resolves which side is "this side").
18
+ * - ThisID {string|number} - the anchor record's id (re-set before each render).
19
+ * - DefaultDestinationAddress - the tab body to render into.
20
+ * - PickerMode {'single'|'multi'} - the add control: 'single' (default) stages one pick that an
21
+ * explicit Add button (or Enter) commits; 'multi' stages chips that one Add commits together.
22
+ * Driven from the RecordSetReadTabs Association entry's PickerMode.
23
+ */
24
+
25
+ /** @type {Record<string, any>} */
26
+ const _DEFAULT_CONFIGURATION_AssociationEditor = (
27
+ {
28
+ ViewIdentifier: 'PRSP-AssociationEditor',
29
+
30
+ DefaultRenderable: 'PRSP_Renderable_AssociationEditor',
31
+ DefaultDestinationAddress: '#PRSP_AssociationEditor_Container',
32
+ DefaultTemplateRecordAddress: false,
33
+
34
+ AutoInitialize: false,
35
+ AutoInitializeOrdinal: 0,
36
+ AutoRender: false,
37
+ AutoRenderOrdinal: 0,
38
+ AutoSolveWithApp: false,
39
+ AutoSolveOrdinal: 0,
40
+
41
+ CSS: /*css*/`
42
+ .prsp-assoc { display: flex; flex-direction: column; gap: 0.85rem; padding: 0.25rem 0 0.5rem; }
43
+ .prsp-assoc-add { display: flex; flex-direction: column; gap: 0.35rem; }
44
+ .prsp-assoc-add-label { font-size: 0.72rem; font-weight: 650; text-transform: uppercase; letter-spacing: 0.05em; color: var(--theme-color-text-muted, #6b7686); }
45
+ .prsp-assoc-add-row { display: flex; align-items: flex-start; gap: 0.5rem; }
46
+ .prsp-assoc-picker-host { flex: 1 1 auto; min-width: 0; max-width: 460px; }
47
+ .prsp-assoc-add-btn { flex: 0 0 auto; display: inline-flex; align-items: center; gap: 0.35rem; cursor: pointer; font: inherit; font-size: 0.9rem; font-weight: 600;
48
+ padding: 0.45rem 0.85rem; border-radius: 8px; border: 1px solid var(--theme-color-brand-primary, #156dd1);
49
+ background: var(--theme-color-brand-primary, #156dd1); color: #fff; }
50
+ .prsp-assoc-add-btn:hover { background: var(--theme-color-brand-primary-hover, #1259ad); }
51
+ .prsp-assoc-list { display: flex; flex-direction: column; gap: 0.3rem; }
52
+ .prsp-assoc-list-head { display: flex; align-items: baseline; justify-content: space-between; gap: 1rem; }
53
+ .prsp-assoc-list-title { font-size: 0.72rem; font-weight: 650; text-transform: uppercase; letter-spacing: 0.05em; color: var(--theme-color-text-muted, #6b7686); }
54
+ .prsp-assoc-count { font-size: 0.78rem; color: var(--theme-color-text-muted, #6b7686); }
55
+ .prsp-assoc-row { display: flex; align-items: center; gap: 0.6rem; padding: 0.45rem 0.6rem; border: 1px solid var(--theme-color-border-light, #e8ebf0); border-radius: 8px;
56
+ background: var(--theme-color-background-primary, #fff); }
57
+ .prsp-assoc-row:hover { border-color: var(--theme-color-border-default, #d7dce3); }
58
+ .prsp-assoc-row-name { flex: 0 1 auto; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--theme-color-text-primary, #1f2733); font-size: 0.92rem; }
59
+ .prsp-assoc-row-chips { flex: 1 1 auto; display: flex; flex-wrap: wrap; gap: 0.3rem; min-width: 0; }
60
+ .prsp-assoc-chip { flex: 0 0 auto; display: inline-flex; align-items: center; font-size: 0.72rem; font-weight: 600; line-height: 1.25;
61
+ padding: 0.05rem 0.4rem; border-radius: 5px; white-space: nowrap;
62
+ background: var(--theme-color-background-tertiary, #eceef2); color: var(--theme-color-text-secondary, #45596b); }
63
+ .prsp-assoc-row-id { flex: 0 0 auto; font-size: 0.74rem; color: var(--theme-color-text-muted, #6b7686); font-family: ui-monospace, SFMono-Regular, Menlo, monospace; }
64
+ .prsp-assoc-remove { flex: 0 0 auto; display: inline-flex; align-items: center; cursor: pointer; border: none; background: transparent; color: var(--theme-color-text-muted, #6b7686); padding: 0.2rem; border-radius: 5px; font: inherit; }
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
+ .prsp-assoc-empty { padding: 0.7rem 0.2rem; color: var(--theme-color-text-muted, #6b7686); font-size: 0.88rem; font-style: italic; }
67
+ .prsp-assoc-note { color: var(--theme-color-status-error, #b62828); font-size: 0.86rem; }
68
+ `,
69
+ CSSPriority: 500,
70
+
71
+ Templates:
72
+ [
73
+ {
74
+ Hash: 'PRSP-AssociationEditor-Template',
75
+ Template: /*html*/`
76
+ <!-- DefaultPackage pict view template: [PRSP-AssociationEditor-Template] -->
77
+ <div class="prsp-assoc">
78
+ <div class="prsp-assoc-add">
79
+ <span class="prsp-assoc-add-label">{~D:Record.AddLabel~}</span>
80
+ <div class="prsp-assoc-add-row">
81
+ <div class="prsp-assoc-picker-host" id="{~D:Record.PickerHostID~}"></div>
82
+ <button type="button" class="prsp-assoc-add-btn" id="{~D:Record.AddButtonID~}" onclick="_Pict.views['{~D:Record.ViewHash~}'].addStaged()" onkeydown="if (event.key === 'Enter') { event.preventDefault(); _Pict.views['{~D:Record.ViewHash~}'].addStaged(); }">{~I:Plus~} {~D:Record.AddButtonLabel~}</button>
83
+ </div>
84
+ {~NE:Record.PickerMissing^<div class="prsp-assoc-note">The entity picker (pict-section-picker) is not registered, so associations cannot be added here.</div>~}
85
+ </div>
86
+ <div class="prsp-assoc-list">
87
+ <div class="prsp-assoc-list-head">
88
+ <span class="prsp-assoc-list-title">{~D:Record.ListLabel~}</span>
89
+ <span class="prsp-assoc-count">{~D:Record.Count~}</span>
90
+ </div>
91
+ {~TS:PRSP-AssociationEditor-Row:Record.Items~}
92
+ {~TS:PRSP-AssociationEditor-Empty:Record.EmptySlot~}
93
+ </div>
94
+ </div>
95
+ <!-- DefaultPackage end view template: [PRSP-AssociationEditor-Template] -->
96
+ `
97
+ },
98
+ {
99
+ Hash: 'PRSP-AssociationEditor-Empty',
100
+ Template: /*html*/`<div class="prsp-assoc-empty">{~D:Record.EmptyText~}</div>`
101
+ },
102
+ {
103
+ Hash: 'PRSP-AssociationEditor-Row',
104
+ Template: /*html*/`
105
+ <div class="prsp-assoc-row">
106
+ <span class="prsp-assoc-row-name">{~D:Record.Display~}</span>
107
+ <span class="prsp-assoc-row-chips">{~TS:PRSP-AssociationEditor-Chip:Record.Chips~}</span>
108
+ <span class="prsp-assoc-row-id">#{~D:Record.OtherID~}</span>
109
+ <button type="button" class="prsp-assoc-remove" title="Remove association" onclick="_Pict.views['{~D:Record.ViewHash~}'].removeItem({~D:Record.JoinID~})">{~I:Trash~}</button>
110
+ </div>
111
+ `
112
+ },
113
+ {
114
+ Hash: 'PRSP-AssociationEditor-Chip',
115
+ Template: /*html*/`<span class="prsp-assoc-chip">{~D:Record.Text~}</span>`
116
+ }
117
+ ],
118
+
119
+ Renderables:
120
+ [
121
+ {
122
+ RenderableHash: 'PRSP_Renderable_AssociationEditor',
123
+ TemplateHash: 'PRSP-AssociationEditor-Template',
124
+ ContentDestinationAddress: '#PRSP_AssociationEditor_Container',
125
+ RenderMethod: 'replace'
126
+ }
127
+ ],
128
+
129
+ Manifests: {}
130
+ });
131
+
132
+ class viewRecordSetAssociationEditor extends libPictView
133
+ {
134
+ constructor(pFable, pOptions, pServiceHash)
135
+ {
136
+ let tmpOptions = Object.assign({}, _DEFAULT_CONFIGURATION_AssociationEditor, pOptions);
137
+ super(pFable, tmpOptions, pServiceHash);
138
+
139
+ /** @type {import('pict')} */
140
+ this.pict;
141
+
142
+ // The other-side ids currently associated (the live cull set the picker reads via a closure).
143
+ this._otherIDs = [];
144
+ // The current list items (so removeItem can find the exact join record by JoinID).
145
+ this._lastItems = [];
146
+ }
147
+
148
+ /** @return {any} The association manager provider. */
149
+ get manager()
150
+ {
151
+ return this.pict.providers.RecordSetAssociationManager;
152
+ }
153
+
154
+ /** @return {string} A DOM/address-safe key derived from this instance's Hash. */
155
+ get safeKey()
156
+ {
157
+ return String(this.Hash).replace(/[^A-Za-z0-9]/g, '_');
158
+ }
159
+
160
+ /**
161
+ * Load the current associations and (re)paint the whole widget, then mount the picker. This is the
162
+ * editor's entry point — the read view's Association tab calls it (not the framework `render()`),
163
+ * so the async data load happens before the template renders (the read-view pattern).
164
+ *
165
+ * @return {Promise<boolean>}
166
+ */
167
+ async renderEditor()
168
+ {
169
+ const tmpSides = this.manager ? this.manager.resolveSides(this.options.AssociationHash, this.options.ThisRecordSet) : false;
170
+ if (!tmpSides)
171
+ {
172
+ this.pict.log.warn(`AssociationEditor [${this.Hash}]: association [${this.options.AssociationHash}] could not be resolved for [${this.options.ThisRecordSet}].`);
173
+ return false;
174
+ }
175
+
176
+ const tmpThisID = this.options.ThisID;
177
+ let tmpItems = [];
178
+ if (tmpThisID !== undefined && tmpThisID !== null && tmpThisID !== '')
179
+ {
180
+ tmpItems = await this.manager.listAssociatedRecords(this.options.AssociationHash, this.options.ThisRecordSet, tmpThisID);
181
+ }
182
+
183
+ // Stamp the view hash on each row so the row template's remove button can reach this instance.
184
+ for (let i = 0; i < tmpItems.length; i++)
185
+ {
186
+ tmpItems[i].ViewHash = this.Hash;
187
+ }
188
+ this._lastItems = tmpItems;
189
+ this._otherIDs = tmpItems.map((pItem) => pItem.OtherID);
190
+
191
+ const tmpOtherLabel = tmpSides.otherSide.Title || tmpSides.otherSide.RecordSet || tmpSides.otherSide.Entity;
192
+ const tmpPickerPresent = !!this.pict.providers['Pict-Section-Picker'];
193
+ const tmpRecord =
194
+ {
195
+ ViewHash: this.Hash,
196
+ PickerHostID: `${this.safeKey}_Picker`,
197
+ AddButtonID: `${this.safeKey}_AddBtn`,
198
+ AddButtonLabel: (this.options.PickerMode === 'multi') ? 'Add selected' : 'Add',
199
+ AddLabel: `Add ${tmpOtherLabel}`,
200
+ ListLabel: `Current ${tmpOtherLabel}`,
201
+ Count: `${tmpItems.length} ${tmpItems.length === 1 ? 'record' : 'records'}`,
202
+ Items: tmpItems,
203
+ // One-or-zero-element slot drives the empty-state line (TS parses inner tags; NE would not).
204
+ EmptySlot: (tmpItems.length === 0) ? [ { EmptyText: `No ${tmpOtherLabel} associated yet — use the search above to add some.` } ] : [],
205
+ PickerMissing: !tmpPickerPresent,
206
+ };
207
+
208
+ return new Promise((resolve) =>
209
+ {
210
+ this.renderAsync(this.options.DefaultRenderable, this.options.DefaultDestinationAddress, tmpRecord,
211
+ (pError) =>
212
+ {
213
+ if (pError)
214
+ {
215
+ this.pict.log.error(`AssociationEditor [${this.Hash}]: render error.`, pError);
216
+ return resolve(false);
217
+ }
218
+ this._mountPicker(tmpSides, tmpRecord.PickerHostID);
219
+ this.pict.CSSMap.injectCSS();
220
+ return resolve(true);
221
+ });
222
+ });
223
+ }
224
+
225
+ /**
226
+ * Create (or reconfigure) the other-side entity picker into its host element and render it. The
227
+ * picker's BaseFilter culls the currently-associated ids via a closure over `this._otherIDs`, so it
228
+ * re-evaluates on every search as associations change.
229
+ *
230
+ * @param {Record<string, any>} pSides - resolved sides from the manager.
231
+ * @param {string} pPickerHostID - the picker host element id.
232
+ */
233
+ _mountPicker(pSides, pPickerHostID)
234
+ {
235
+ const tmpPickerProvider = this.pict.providers['Pict-Section-Picker'];
236
+ if (!tmpPickerProvider)
237
+ {
238
+ return;
239
+ }
240
+ const tmpPickerHash = `${this.safeKey}_PickerView`;
241
+ const tmpValueAddress = `AppData.PRSPAssocPicker.${this.safeKey}`;
242
+ const tmpMulti = (this.options.PickerMode === 'multi');
243
+ // Reset the scratch value so the picker mounts empty (and re-culls) after each add.
244
+ if (!this.pict.AppData.PRSPAssocPicker) { this.pict.AppData.PRSPAssocPicker = {}; }
245
+ this.pict.AppData.PRSPAssocPicker[this.safeKey] = tmpMulti ? [] : null;
246
+
247
+ const tmpConfig = this.manager.buildOtherPickerConfig(this.options.AssociationHash, this.options.ThisRecordSet, () => this._otherIDs,
248
+ {
249
+ Mode: tmpMulti ? 'multi' : 'single',
250
+ DestinationAddress: `#${pPickerHostID}`,
251
+ ValueAddress: tmpValueAddress,
252
+ Placeholder: `Search ${pSides.otherSide.Title || pSides.otherSide.RecordSet || pSides.otherSide.Entity}…`,
253
+ // No add-on-select: a single-select pick just stages the value and moves focus to the Add
254
+ // button (so Enter commits). Multi mode stages chips; the Add button commits them.
255
+ OnChange: tmpMulti ? undefined : (() => this._focusAddButton()),
256
+ });
257
+ if (!tmpConfig)
258
+ {
259
+ return;
260
+ }
261
+ tmpPickerProvider.createEntityPicker(tmpPickerHash, tmpConfig);
262
+ this.pict.views[tmpPickerHash].render();
263
+ }
264
+
265
+ /** Move focus to the Add button so a single-select pick can be committed by pressing Enter. */
266
+ _focusAddButton()
267
+ {
268
+ const tmpButton = document.getElementById(`${this.safeKey}_AddBtn`);
269
+ if (tmpButton)
270
+ {
271
+ tmpButton.focus();
272
+ }
273
+ }
274
+
275
+ /**
276
+ * Commit the staged selection(s) — the Add button (and Enter). Reads the picker's value: a single
277
+ * scalar, or (multi mode) an array of ids. Creates a join for each, then reloads + repaints so the
278
+ * new rows appear and are culled from the picker.
279
+ *
280
+ * @return {Promise<void>}
281
+ */
282
+ async addStaged()
283
+ {
284
+ const tmpStaged = this.pict.AppData.PRSPAssocPicker ? this.pict.AppData.PRSPAssocPicker[this.safeKey] : null;
285
+ const tmpIDs = (this.options.PickerMode === 'multi')
286
+ ? (Array.isArray(tmpStaged) ? tmpStaged.slice() : [])
287
+ : ((tmpStaged === undefined || tmpStaged === null || tmpStaged === '') ? [] : [ tmpStaged ]);
288
+ if (tmpIDs.length < 1)
289
+ {
290
+ return;
291
+ }
292
+ let tmpFailures = 0;
293
+ for (let i = 0; i < tmpIDs.length; i++)
294
+ {
295
+ try
296
+ {
297
+ await this.manager.createJoin(this.options.AssociationHash, this.options.ThisRecordSet, this.options.ThisID, tmpIDs[i]);
298
+ }
299
+ catch (pError)
300
+ {
301
+ tmpFailures++;
302
+ this.pict.log.error(`AssociationEditor [${this.Hash}]: failed to create association for ${tmpIDs[i]}.`, pError);
303
+ }
304
+ }
305
+ if (tmpFailures > 0)
306
+ {
307
+ this._toast(`${tmpFailures} association(s) could not be added.`, 'error');
308
+ }
309
+ await this.renderEditor();
310
+ }
311
+
312
+ /**
313
+ * Remove one association (a list row's remove handler). Confirms via the host modal, deletes the
314
+ * join, then reloads + repaints.
315
+ *
316
+ * @param {string|number} pJoinID - the join record id to delete.
317
+ * @return {Promise<void>}
318
+ */
319
+ async removeItem(pJoinID)
320
+ {
321
+ const tmpItem = this._lastItems.find((pItem) => String(pItem.JoinID) === String(pJoinID));
322
+ if (!tmpItem)
323
+ {
324
+ return;
325
+ }
326
+ const fRemove = async () =>
327
+ {
328
+ try
329
+ {
330
+ await this.manager.removeJoin(this.options.AssociationHash, tmpItem.JoinRecord);
331
+ }
332
+ catch (pError)
333
+ {
334
+ this.pict.log.error(`AssociationEditor [${this.Hash}]: failed to remove association.`, pError);
335
+ this._toast('Could not remove the association.', 'error');
336
+ return;
337
+ }
338
+ await this.renderEditor();
339
+ };
340
+
341
+ const tmpModal = this.pict.views['Pict-Section-Modal'];
342
+ if (tmpModal && typeof tmpModal.confirm === 'function')
343
+ {
344
+ const tmpOk = await tmpModal.confirm(`Remove the association with "${tmpItem.Display}"?`,
345
+ { title: 'Remove association', confirmLabel: 'Remove', cancelLabel: 'Cancel', dangerous: true });
346
+ if (!tmpOk)
347
+ {
348
+ return;
349
+ }
350
+ }
351
+ return fRemove();
352
+ }
353
+
354
+ /**
355
+ * Non-blocking notification via the host modal's toast, when available.
356
+ * @param {string} pMessage @param {string} pType
357
+ */
358
+ _toast(pMessage, pType)
359
+ {
360
+ const tmpModal = this.pict.views['Pict-Section-Modal'];
361
+ if (tmpModal && typeof tmpModal.toast === 'function')
362
+ {
363
+ tmpModal.toast(pMessage, { type: pType || 'info' });
364
+ }
365
+ }
366
+ }
367
+
368
+ module.exports = viewRecordSetAssociationEditor;
369
+
370
+ module.exports.default_configuration = _DEFAULT_CONFIGURATION_AssociationEditor;
@@ -1,4 +1,5 @@
1
1
  const libPictRecordSetRecordView = require('../RecordSet-RecordBaseView.js');
2
+ const libViewAssociationEditor = require('../associate/RecordSet-AssociationEditor.js');
2
3
 
3
4
  // Identity + audit field names stamped on (virtually) every Meadow entity. These are
4
5
  // surfaced through the record audit header (the first-class activity line + the Details
@@ -136,75 +137,34 @@ const _DEFAULT_CONFIGURATION__Read = (
136
137
  Hash: 'PRSP-Read-Split-Template',
137
138
  Template: /*html*/`
138
139
  <!-- DefaultPackage pict view template: [PRSP-Read-Split-Template] -->
139
- <h1>{~D:Record.RecordSet~} {~D:Record.GUIDAddress~} [{~D:Record.RecordConfiguration.GUIDRecord~}]</h1>
140
- <!--
141
- {~DJ:Record~}
142
- -->
143
140
  <style>
144
- .psrs-split-view
145
- {
146
- display: flex;
147
- height: 100%;
148
- }
149
- .psrs-left-panel
150
- {
151
- overflow: scroll;
152
- }
153
- .psrs-right-panel
154
- {
155
- overflow: scroll;
156
- }
157
- #psrs-resize
158
- {
159
- width: 1px;
160
- padding-left: 10px;
161
- padding-right: 10px;
162
- cursor: col-resize;
163
- }
164
- #psrs-resize > div
165
- {
166
- width: 1px;
167
- height: 100%;
168
- background-color: rgba(0,0,0,0.5);
169
- }
170
- #PRSP-Read-Tab-Nav
171
- {
172
- display: flex;
173
- border-bottom: 1px solid rgba(0,0,0,0.5);
174
- margin-bottom: 20px;
175
- width: 100%;
176
- }
177
- .psrs-tab.is-active
178
- {
179
- border: 1px solid rgba(0,0,0,0.5);
180
- }
181
- .psrs-tab
182
- {
183
- border-right: 1px solid rgba(0,0,0,0.5);
184
- border-left: 1px solid rgba(0,0,0,0.5);
185
- padding: 10px;
186
- }
187
- .psrs-tab-body
188
- {
189
- display: none;
190
- }
191
- .psrs-tab-body.is-active
192
- {
193
- display: inherit;
194
- }
141
+ .psrs-split-tabnav { display: flex; justify-content: flex-end; gap: 0.4rem; margin: 0.4rem 0 0.2rem; }
142
+ #PRSP-Read-Tab-Nav { display: inline-flex; gap: 0.35rem; flex-wrap: wrap; }
143
+ .psrs-tab { padding: 0.4rem 0.85rem; border: 1px solid var(--theme-color-border-default, #d7dce3); border-radius: 8px; cursor: pointer; font-size: 0.88rem; color: var(--theme-color-text-secondary, #45505f); background: var(--theme-color-background-panel, #fff); user-select: none; }
144
+ .psrs-tab:hover { background: var(--theme-color-background-tertiary, #eceef2); color: var(--theme-color-text-primary, #1f2733); }
145
+ .psrs-tab.is-active { border-color: var(--theme-color-brand-primary, #156dd1); background: var(--theme-color-background-selected, #e3edfb); color: var(--theme-color-brand-primary, #156dd1); font-weight: 600; }
146
+ .psrs-split-view { display: flex; height: 100%; }
147
+ .psrs-left-panel { overflow: auto; }
148
+ .psrs-right-panel { overflow: auto; flex: 1 1 auto; }
149
+ #psrs-resize { flex: 0 0 auto; padding-left: 10px; padding-right: 10px; cursor: col-resize; }
150
+ #psrs-resize > div { width: 1px; height: 100%; background-color: var(--theme-color-border-default, rgba(0,0,0,0.2)); }
151
+ .psrs-tab-body { display: none; }
152
+ .psrs-tab-body.is-active { display: block; }
153
+ /* Collapsed (default): no association is open, so the record takes the full width and the
154
+ association pane + resizer are hidden until a tab is chosen. */
155
+ .psrs-split-view.psrs-collapsed .psrs-right-panel,
156
+ .psrs-split-view.psrs-collapsed #psrs-resize { display: none; }
157
+ .psrs-split-view.psrs-collapsed .psrs-left-panel { min-width: 100% !important; width: 100%; }
195
158
  </style>
196
- <div class="psrs-split-view">
197
- <div class="psrs-left-panel" style="min-width: 50%;">
159
+ <h1>{~D:Record.RecordSet~} {~D:Record.GUIDAddress~} [{~D:Record.RecordConfiguration.GUIDRecord~}]</h1>
160
+ <div class="psrs-split-tabnav">{~T:PRSP-Read-RecordTabNav-Template~}</div>
161
+ <div class="psrs-split-view psrs-collapsed">
162
+ <div class="psrs-left-panel" style="min-width: {~D:Record.SplitLeftWidth~};">
198
163
  {~T:PRSP-Read-RecordRead-Template~}
199
164
  </div>
200
- <div id="psrs-resize">
201
- <div></div>
202
- </div>
203
- <div class="psrs-right-panel" style="width: 100%;">
204
- <div id="PRSP-Read-Tabs-Container">
205
- {~T:PRSP-Read-RecordTabNav-Template~}
206
- {~T:PRSP-Read-RecordTab-Template~}
207
- </div>
165
+ <div id="psrs-resize"><div></div></div>
166
+ <div class="psrs-right-panel">
167
+ {~T:PRSP-Read-RecordTab-Template~}
208
168
  </div>
209
169
  </div>
210
170
  <!-- DefaultPackage end view template: [PRSP-Read-Split-Template] -->
@@ -787,6 +747,10 @@ class viewRecordSetRead extends libPictRecordSetRecordView
787
747
  "RenderDestination": this.options.DefaultDestinationAddress,
788
748
 
789
749
  "Record": false,
750
+
751
+ // Split layout: the starting width of the record (left) pane; the rest goes to the tabs
752
+ // (right) pane. Any CSS width ('40%', '360px', …); default 50%. The divider stays draggable.
753
+ "SplitLeftWidth": pRecordConfiguration.RecordSetReadSplitLeftWidth || '50%',
790
754
  };
791
755
 
792
756
  this.GUID = pRecordGUID;
@@ -912,24 +876,38 @@ class viewRecordSetRead extends libPictRecordSetRecordView
912
876
  document.getElementById('PRSP-Read-SaveButton').classList.add('record-button-bar-hidden');
913
877
  document.getElementById('PRSP-Read-CancelButton').classList.add('record-button-bar-hidden');
914
878
  }
915
- this.setTab(this.activeTab || this.tabs?.[0]?.Hash);
879
+ // Split opens to the record alone via the Full Record tab; other tabbed layouts default to the first tab.
880
+ this.setTab(this.activeTab || (this.layoutType === 'Split' ? 'FullRecord' : this.tabs?.[0]?.Hash));
916
881
  return true;
917
882
  }.bind(this));
918
883
  }
919
884
 
920
885
  async setTab(t)
921
886
  {
922
- if (this.activeTab !== t)
887
+ // Split layout opens to the record alone via the "Full Record" tab. Choosing an association tab
888
+ // expands its editor beside the record; choosing "Full Record" (or re-choosing the active tab)
889
+ // collapses back to the record-only view. Other layouts always activate the target.
890
+ const tmpSplit = (this.layoutType === 'Split');
891
+ const tmpNewActive = (tmpSplit && (!t || t === 'FullRecord' || t === this.activeTab)) ? 'FullRecord' : t;
892
+ if (this.activeTab !== tmpNewActive)
923
893
  {
924
894
  await this.onBeforeTabChange();
925
895
  }
926
- this.activeTab = t;
896
+ this.activeTab = tmpNewActive;
897
+ if (tmpSplit)
898
+ {
899
+ const tmpSplitView = document.querySelector('.psrs-split-view');
900
+ if (tmpSplitView)
901
+ {
902
+ tmpSplitView.classList.toggle('psrs-collapsed', (tmpNewActive === 'FullRecord'));
903
+ }
904
+ }
927
905
  const tabSet = document.querySelectorAll('.psrs-tab');
928
906
  const tabBodySet = document.querySelectorAll('.psrs-tab-body');
929
907
  for (const tb of tabSet)
930
908
  {
931
909
  tb.classList.remove('is-active');
932
- if (tb.id == `PSRS-TabNav-${ t }`)
910
+ if (tmpNewActive && tb.id == `PSRS-TabNav-${ tmpNewActive }`)
933
911
  {
934
912
  tb.classList.add('is-active');
935
913
  }
@@ -937,7 +915,7 @@ class viewRecordSetRead extends libPictRecordSetRecordView
937
915
  for (const tb of tabBodySet)
938
916
  {
939
917
  tb.classList.remove('is-active');
940
- if (tb.id == `PSRS-Tab-${ t }`)
918
+ if (tmpNewActive && tb.id == `PSRS-Tab-${ tmpNewActive }`)
941
919
  {
942
920
  tb.classList.add('is-active');
943
921
  }
@@ -1105,6 +1083,22 @@ class viewRecordSetRead extends libPictRecordSetRecordView
1105
1083
  `,
1106
1084
  render: () => {}
1107
1085
  }
1086
+ ] : config.ReadLayout == 'Split' ?
1087
+ [
1088
+ // Split layout: a leading "Full Record" tab that collapses the association pane back to the
1089
+ // record-only view (the record itself lives in the left pane, so this body stays empty).
1090
+ {
1091
+ Type: 'FullRecord',
1092
+ Hash: 'FullRecord',
1093
+ Title: config.RecordSetReadFullRecordTabTitle || 'Full Record',
1094
+ Template: /*html*/`
1095
+ <div id="PSRS-Tab-FullRecord" class="psrs-tab-body"></div>
1096
+ `,
1097
+ TabTemplate: /*html*/`
1098
+ <div class="psrs-tab" id="PSRS-TabNav-FullRecord" onclick="_Pict.views['RSP-RecordSet-Read'].setTab('FullRecord')">${ config.RecordSetReadFullRecordTabTitle || 'Full Record' }</div>
1099
+ `,
1100
+ render: () => {}
1101
+ }
1108
1102
  ] : [];
1109
1103
 
1110
1104
  for (const t of config.RecordSetReadTabs)
@@ -1267,6 +1261,56 @@ class viewRecordSetRead extends libPictRecordSetRecordView
1267
1261
  };
1268
1262
  validTabs.push(t);
1269
1263
  }
1264
+ else if (t.Type == 'Association')
1265
+ {
1266
+ // An embeddable join-management widget for one association, anchored on THIS record.
1267
+ // Opt-in is light: a RecordSetReadTabs entry naming an Association registered in
1268
+ // settings.Associations. The manager resolves which side is "this side" from the
1269
+ // rendering recordset, so opting in Book->Authors and Author->Books are independent.
1270
+ const tmpAssociationManager = this.pict.providers.RecordSetAssociationManager;
1271
+ if (!tmpAssociationManager || !t.Association)
1272
+ {
1273
+ this.pict.log.info(`Skipping association tab because no association was included (or the manager is missing).`);
1274
+ continue;
1275
+ }
1276
+ const tmpSides = tmpAssociationManager.resolveSides(t.Association, config.RecordSet);
1277
+ if (!tmpSides)
1278
+ {
1279
+ this.pict.log.info(`Skipping association tab because association ${ t.Association } could not be resolved for ${ config.RecordSet }.`);
1280
+ continue;
1281
+ }
1282
+ const tmpEditorHash = `RSP-AssocEditor-${ config.RecordSet }-${ t.Association }`;
1283
+ if (!this.pict.views[tmpEditorHash])
1284
+ {
1285
+ this.pict.addView(tmpEditorHash, Object.assign({}, libViewAssociationEditor.default_configuration,
1286
+ {
1287
+ ViewIdentifier: tmpEditorHash,
1288
+ AssociationHash: t.Association,
1289
+ ThisRecordSet: config.RecordSet,
1290
+ DefaultDestinationAddress: `#PSRS-Tab-${ t.Hash }`,
1291
+ PickerMode: t.PickerMode || 'single',
1292
+ }), libViewAssociationEditor);
1293
+ }
1294
+ const tmpEditorView = this.pict.views[tmpEditorHash];
1295
+ tmpEditorView.options.AssociationHash = t.Association;
1296
+ tmpEditorView.options.ThisRecordSet = config.RecordSet;
1297
+ tmpEditorView.options.ThisID = record[tmpSides.thisSide.IDField];
1298
+ tmpEditorView.options.DefaultDestinationAddress = `#PSRS-Tab-${ t.Hash }`;
1299
+ tmpEditorView.options.PickerMode = t.PickerMode || 'single';
1300
+ t.Template = /*html*/`
1301
+ <div id="PSRS-Tab-${ t.Hash }" class="psrs-tab-body"></div>
1302
+ `;
1303
+ t.TabTemplate = /*html*/`
1304
+ <div class="psrs-tab" id="PSRS-TabNav-${ t.Hash }" onclick="_Pict.views['RSP-RecordSet-Read'].setTab('${ t.Hash }')">${ t.Title }</div>
1305
+ `;
1306
+ t.renderAsync = async () =>
1307
+ {
1308
+ tmpEditorView.options.ThisID = record[tmpSides.thisSide.IDField];
1309
+ tmpEditorView.options.DefaultDestinationAddress = `#PSRS-Tab-${ t.Hash }`;
1310
+ await tmpEditorView.renderEditor();
1311
+ };
1312
+ validTabs.push(t);
1313
+ }
1270
1314
  }
1271
1315
  return validTabs;
1272
1316
  }