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.
- package/README.md +13 -0
- package/package.json +1 -1
- package/source/Pict-Section-RecordSet.js +7 -0
- package/source/providers/RecordSet-AssociationManager.js +561 -0
- package/source/providers/RecordSet-Router.js +3 -0
- package/source/services/RecordsSet-MetaController.js +22 -0
- package/source/views/RecordSet-Filters.js +4 -0
- package/source/views/associate/RecordSet-AssociateBulk.js +449 -0
- package/source/views/associate/RecordSet-AssociateMatrix.js +680 -0
- package/source/views/associate/RecordSet-AssociateUnlink.js +610 -0
- package/source/views/associate/RecordSet-AssociationEditor.js +370 -0
- package/source/views/read/RecordSet-Read.js +114 -70
|
@@ -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-
|
|
145
|
-
{
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
}
|
|
149
|
-
.psrs-
|
|
150
|
-
{
|
|
151
|
-
|
|
152
|
-
}
|
|
153
|
-
|
|
154
|
-
{
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
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
|
-
<
|
|
197
|
-
|
|
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
|
-
|
|
202
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
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-${
|
|
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-${
|
|
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
|
}
|