pict-section-recordset 1.11.1 → 1.18.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 +588 -0
- package/source/providers/RecordSet-Router.js +3 -0
- package/source/services/RecordsSet-MetaController.js +22 -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,610 @@
|
|
|
1
|
+
const libPictView = require('pict-view');
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The Bulk Unlink screen — the removal counterpart to Assign/Matrix. Pick one anchor record (a specific
|
|
5
|
+
* book OR a specific store), see ALL of its current associations in a selectable record table
|
|
6
|
+
* (configurable columns + a column chooser + search), check the ones to drop, and "Unlink selected"
|
|
7
|
+
* deletes those join rows at once. The anchor side is the route's `:AnchorRecordSet`, so the same screen
|
|
8
|
+
* unlinks from either side.
|
|
9
|
+
*
|
|
10
|
+
* Registered ONCE by the metacontroller as `RSP-RecordSet-AssociateUnlink` and parameterized by route:
|
|
11
|
+
* /PSRS/AssociateUnlink/:Association/:AnchorRecordSet
|
|
12
|
+
* /PSRS/AssociateUnlink/:Association/:AnchorRecordSet/:AnchorID
|
|
13
|
+
*
|
|
14
|
+
* Data flows through the shared `RecordSetAssociationManager` (listAssociatedRecords + removeJoin);
|
|
15
|
+
* column visibility persists through the `ColumnDataProvider` (the same host-overridable seam).
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
/** @type {Record<string, any>} */
|
|
19
|
+
const _DEFAULT_CONFIGURATION_AssociateUnlink = (
|
|
20
|
+
{
|
|
21
|
+
ViewIdentifier: 'PRSP-AssociateUnlink',
|
|
22
|
+
|
|
23
|
+
DefaultRenderable: 'PRSP_Renderable_AssociateUnlink',
|
|
24
|
+
DefaultDestinationAddress: '#PRSP_Container',
|
|
25
|
+
DefaultTemplateRecordAddress: false,
|
|
26
|
+
|
|
27
|
+
AutoInitialize: false,
|
|
28
|
+
AutoInitializeOrdinal: 0,
|
|
29
|
+
AutoRender: false,
|
|
30
|
+
AutoRenderOrdinal: 0,
|
|
31
|
+
AutoSolveWithApp: false,
|
|
32
|
+
AutoSolveOrdinal: 0,
|
|
33
|
+
|
|
34
|
+
CSS: /*css*/`
|
|
35
|
+
.prsp-unlink { display: flex; flex-direction: column; gap: 1rem; padding: 0.25rem 0 1rem; }
|
|
36
|
+
.prsp-unlink-header h2 { margin: 0 0 0.2rem; font-size: 1.25rem; color: var(--theme-color-text-primary, #1f2733); }
|
|
37
|
+
.prsp-unlink-sub { margin: 0; color: var(--theme-color-text-muted, #6b7686); font-size: 0.92rem; }
|
|
38
|
+
.prsp-unlink-card { border: 1px solid var(--theme-color-border-light, #e8ebf0); border-radius: 12px; padding: 0.8rem 0.9rem; background: var(--theme-color-background-primary, #fff); display: flex; flex-direction: column; gap: 0.6rem; }
|
|
39
|
+
.prsp-unlink-card-label { font-size: 0.72rem; font-weight: 650; text-transform: uppercase; letter-spacing: 0.05em; color: var(--theme-color-text-muted, #6b7686); }
|
|
40
|
+
.prsp-unlink-anchor-host { max-width: 520px; }
|
|
41
|
+
.prsp-unlink-bar { display: flex; align-items: center; justify-content: space-between; gap: 1rem; flex-wrap: wrap;
|
|
42
|
+
border: 1px solid var(--theme-color-border-light, #e8ebf0); border-radius: 12px; padding: 0.7rem 1rem; background: var(--theme-color-background-secondary, #f7f8fa); }
|
|
43
|
+
.prsp-unlink-stats { font-size: 0.95rem; color: var(--theme-color-text-secondary, #45505f); }
|
|
44
|
+
.prsp-unlink-stats strong { color: var(--theme-color-status-error, #b62828); font-size: 1.05rem; }
|
|
45
|
+
.prsp-unlink-btn { display: inline-flex; align-items: center; gap: 0.4rem; cursor: pointer; font: inherit; font-size: 0.92rem; font-weight: 600;
|
|
46
|
+
padding: 0.5rem 1rem; border-radius: 8px; border: 1px solid var(--theme-color-status-error, #b62828);
|
|
47
|
+
background: var(--theme-color-status-error, #b62828); color: #fff; }
|
|
48
|
+
.prsp-unlink-btn:hover { background: var(--theme-color-status-error-hover, #9c2020); }
|
|
49
|
+
.prsp-unlink-btn[disabled] { opacity: 0.5; cursor: not-allowed; }
|
|
50
|
+
.prsp-unlink-toolbar { display: flex; align-items: center; justify-content: space-between; gap: 0.6rem; flex-wrap: wrap; }
|
|
51
|
+
.prsp-unlink-search { flex: 1 1 220px; display: flex; align-items: center; gap: 0.4rem; padding: 0.4rem 0.6rem; border: 1px solid var(--theme-color-border-default, #d7dce3); border-radius: 8px; }
|
|
52
|
+
.prsp-unlink-search-ic { display: inline-flex; color: var(--theme-color-text-muted, #6b7686); font-size: 0.9rem; }
|
|
53
|
+
.prsp-unlink-search input { flex: 1 1 auto; min-width: 0; font: inherit; font-size: 0.9rem; border: none; outline: none; background: transparent; color: var(--theme-color-text-primary, #1f2733); }
|
|
54
|
+
.prsp-unlink-tools { display: flex; align-items: center; gap: 0.6rem; }
|
|
55
|
+
.prsp-unlink-colbtn { display: inline-flex; align-items: center; gap: 0.3rem; cursor: pointer; font: inherit; font-size: 0.78rem; padding: 0.2rem 0.5rem;
|
|
56
|
+
border: 1px solid var(--theme-color-border-default, #d7dce3); border-radius: 6px; background: var(--theme-color-background-panel, #fff); color: var(--theme-color-text-secondary, #45505f); }
|
|
57
|
+
.prsp-unlink-colbtn:hover { background: var(--theme-color-background-tertiary, #eceef2); color: var(--theme-color-text-primary, #1f2733); }
|
|
58
|
+
.prsp-unlink-colchooser-wrap { position: relative; }
|
|
59
|
+
.prsp-unlink-colchooser-backdrop { position: fixed; inset: 0; z-index: 30; display: none; }
|
|
60
|
+
.prsp-unlink-colchooser-wrap.is-open .prsp-unlink-colchooser-backdrop { display: block; }
|
|
61
|
+
.prsp-unlink-colchooser { position: absolute; right: 0; top: 0.3rem; z-index: 40; min-width: 200px; display: none;
|
|
62
|
+
background: var(--theme-color-background-panel, #fff); border: 1px solid var(--theme-color-border-default, #d7dce3); border-radius: 10px; box-shadow: 0 10px 28px rgba(17, 24, 39, 0.14); overflow: hidden; }
|
|
63
|
+
.prsp-unlink-colchooser-wrap.is-open .prsp-unlink-colchooser { display: block; }
|
|
64
|
+
.prsp-unlink-colchooser-list { max-height: 50vh; overflow-y: auto; padding: 0.25rem; }
|
|
65
|
+
.prsp-unlink-colrow { display: flex; align-items: center; gap: 0.5rem; width: 100%; text-align: left; cursor: pointer; font: inherit; font-size: 0.86rem;
|
|
66
|
+
padding: 0.4rem 0.55rem; border: none; border-radius: 6px; background: transparent; color: var(--theme-color-text-primary, #1f2733); }
|
|
67
|
+
.prsp-unlink-colrow:hover { background: var(--theme-color-background-tertiary, #eceef2); }
|
|
68
|
+
.prsp-unlink-colrow-check { flex: 0 0 auto; display: inline-flex; width: 1em; color: var(--theme-color-brand-primary, #156dd1); visibility: hidden; }
|
|
69
|
+
.prsp-unlink-colrow.is-on .prsp-unlink-colrow-check { visibility: visible; }
|
|
70
|
+
.prsp-unlink-colchooser-foot { display: flex; justify-content: flex-end; padding: 0.35rem 0.5rem; border-top: 1px solid var(--theme-color-border-light, #e8ebf0); }
|
|
71
|
+
.prsp-unlink-colreset { font: inherit; font-size: 0.8rem; cursor: pointer; border: none; background: transparent; color: var(--theme-color-text-muted, #6b7686); padding: 0.15rem 0.3rem; border-radius: 5px; }
|
|
72
|
+
.prsp-unlink-tablewrap { max-height: 56vh; overflow: auto; border: 1px solid var(--theme-color-border-light, #e8ebf0); border-radius: 8px; }
|
|
73
|
+
.prsp-utbl { width: 100%; border-collapse: collapse; font-size: 0.88rem; }
|
|
74
|
+
.prsp-utbl thead th { position: sticky; top: 0; z-index: 1; text-align: left; padding: 0.5rem 0.6rem; background: var(--theme-color-background-tertiary, #eceef2);
|
|
75
|
+
color: var(--theme-color-text-secondary, #45505f); font-size: 0.72rem; font-weight: 650; text-transform: uppercase; letter-spacing: 0.03em; white-space: nowrap; }
|
|
76
|
+
.prsp-utbl-th-check { width: 1.8rem; text-align: center; cursor: pointer; }
|
|
77
|
+
.prsp-utbl-row { cursor: pointer; border-top: 1px solid var(--theme-color-border-light, #e8ebf0); }
|
|
78
|
+
.prsp-utbl-row:hover { background: var(--theme-color-background-tertiary, #eceef2); }
|
|
79
|
+
.prsp-utbl-row.is-selected { background: color-mix(in srgb, var(--theme-color-status-error, #b62828) 9%, transparent); }
|
|
80
|
+
.prsp-utbl-row td { padding: 0.4rem 0.6rem; color: var(--theme-color-text-primary, #1f2733); max-width: 22rem; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
81
|
+
.prsp-utbl-td-check { width: 1.8rem; text-align: center; }
|
|
82
|
+
.prsp-utbl-check { display: inline-flex; color: var(--theme-color-status-error, #b62828); visibility: hidden; }
|
|
83
|
+
.prsp-utbl-row.is-selected .prsp-utbl-check { visibility: visible; }
|
|
84
|
+
.prsp-utbl-headcheck { display: inline-flex; color: var(--theme-color-text-muted, #6b7686); }
|
|
85
|
+
.prsp-unlink-empty { padding: 0.9rem; color: var(--theme-color-text-muted, #6b7686); font-size: 0.9rem; font-style: italic; text-align: center; }
|
|
86
|
+
.prsp-unlink-hint { color: var(--theme-color-text-muted, #6b7686); font-size: 0.92rem; font-style: italic; padding: 0.5rem 0.2rem; }
|
|
87
|
+
.prsp-unlink-note { color: var(--theme-color-status-error, #b62828); font-size: 0.86rem; }
|
|
88
|
+
`,
|
|
89
|
+
CSSPriority: 500,
|
|
90
|
+
|
|
91
|
+
Templates:
|
|
92
|
+
[
|
|
93
|
+
{
|
|
94
|
+
Hash: 'PRSP-AssociateUnlink-Template',
|
|
95
|
+
Template: /*html*/`
|
|
96
|
+
<!-- DefaultPackage pict view template: [PRSP-AssociateUnlink-Template] -->
|
|
97
|
+
<div class="prsp-unlink">
|
|
98
|
+
<div class="prsp-unlink-header">
|
|
99
|
+
<h2>{~D:Record.Title~}</h2>
|
|
100
|
+
<p class="prsp-unlink-sub">{~D:Record.Subtitle~}</p>
|
|
101
|
+
</div>
|
|
102
|
+
<div class="prsp-unlink-card">
|
|
103
|
+
<span class="prsp-unlink-card-label">{~D:Record.AnchorLabel~}</span>
|
|
104
|
+
<div class="prsp-unlink-anchor-host" id="{~D:Record.AnchorPickerHostID~}"></div>
|
|
105
|
+
{~NE:Record.PickerMissing^<div class="prsp-unlink-note">The entity picker (pict-section-picker) is not registered, so this screen cannot run.</div>~}
|
|
106
|
+
</div>
|
|
107
|
+
{~TS:PRSP-AssociateUnlink-Hint:Record.HintSlot~}
|
|
108
|
+
{~TS:PRSP-AssociateUnlink-Body:Record.BodySlot~}
|
|
109
|
+
</div>
|
|
110
|
+
<!-- DefaultPackage end view template: [PRSP-AssociateUnlink-Template] -->
|
|
111
|
+
`
|
|
112
|
+
},
|
|
113
|
+
{
|
|
114
|
+
Hash: 'PRSP-AssociateUnlink-Hint',
|
|
115
|
+
Template: /*html*/`<div class="prsp-unlink-hint">{~D:Record.Hint~}</div>`
|
|
116
|
+
},
|
|
117
|
+
{
|
|
118
|
+
Hash: 'PRSP-AssociateUnlink-Body',
|
|
119
|
+
Template: /*html*/`
|
|
120
|
+
<div class="prsp-unlink-bar">
|
|
121
|
+
<div class="prsp-unlink-stats" id="{~D:Record.StatsID~}"></div>
|
|
122
|
+
<button type="button" class="prsp-unlink-btn" id="{~D:Record.UnlinkButtonID~}" onclick="_Pict.views['RSP-RecordSet-AssociateUnlink'].unlinkSelected()">{~I:Trash~} Unlink selected</button>
|
|
123
|
+
</div>
|
|
124
|
+
<div class="prsp-unlink-card">
|
|
125
|
+
<div class="prsp-unlink-toolbar">
|
|
126
|
+
<div class="prsp-unlink-search">
|
|
127
|
+
<span class="prsp-unlink-search-ic">{~I:Search~}</span>
|
|
128
|
+
<input type="text" placeholder="Filter {~D:Record.OtherLabel~}…" autocomplete="off" oninput="_Pict.views['RSP-RecordSet-AssociateUnlink'].searchItems(this.value)">
|
|
129
|
+
</div>
|
|
130
|
+
<div class="prsp-unlink-tools">
|
|
131
|
+
<div class="prsp-unlink-colchooser-wrap" id="{~D:Record.ChooserID~}_Wrap">
|
|
132
|
+
<button type="button" class="prsp-unlink-colbtn" title="Choose columns" onclick="_Pict.views['RSP-RecordSet-AssociateUnlink'].toggleColumnChooser()">{~I:Settings~} Columns</button>
|
|
133
|
+
<div class="prsp-unlink-colchooser-backdrop" onclick="_Pict.views['RSP-RecordSet-AssociateUnlink'].closeColumnChooser()"></div>
|
|
134
|
+
<div class="prsp-unlink-colchooser" id="{~D:Record.ChooserID~}"></div>
|
|
135
|
+
</div>
|
|
136
|
+
</div>
|
|
137
|
+
</div>
|
|
138
|
+
<div class="prsp-unlink-tablewrap"><div id="{~D:Record.TableID~}"></div></div>
|
|
139
|
+
</div>
|
|
140
|
+
`
|
|
141
|
+
},
|
|
142
|
+
{
|
|
143
|
+
Hash: 'PRSP-AssociateUnlink-Table',
|
|
144
|
+
Template: /*html*/`
|
|
145
|
+
<table class="prsp-utbl">
|
|
146
|
+
<thead><tr><th class="prsp-utbl-th-check" title="Select all" onclick="_Pict.views['RSP-RecordSet-AssociateUnlink'].toggleSelectAll()"><span class="prsp-utbl-headcheck">{~D:Record.SelectAllIcon~}</span></th>{~TS:PRSP-AssociateUnlink-HeaderCell:Record.Columns~}</tr></thead>
|
|
147
|
+
<tbody>{~TS:PRSP-AssociateUnlink-Row:Record.Rows~}</tbody>
|
|
148
|
+
</table>
|
|
149
|
+
{~TS:PRSP-AssociateUnlink-Empty:Record.EmptySlot~}
|
|
150
|
+
`
|
|
151
|
+
},
|
|
152
|
+
{
|
|
153
|
+
Hash: 'PRSP-AssociateUnlink-HeaderCell',
|
|
154
|
+
Template: /*html*/`<th>{~D:Record.DisplayName~}</th>`
|
|
155
|
+
},
|
|
156
|
+
{
|
|
157
|
+
Hash: 'PRSP-AssociateUnlink-Row',
|
|
158
|
+
Template: /*html*/`<tr id="{~D:Record.RowID~}" class="prsp-utbl-row{~NE:Record.Selected^ is-selected~}" onclick="_Pict.views['RSP-RecordSet-AssociateUnlink'].toggleRow('{~D:Record.JoinID~}')"><td class="prsp-utbl-td-check"><span class="prsp-utbl-check">{~I:Check~}</span></td>{~TS:PRSP-AssociateUnlink-Cell:Record.Cells~}</tr>`
|
|
159
|
+
},
|
|
160
|
+
{
|
|
161
|
+
Hash: 'PRSP-AssociateUnlink-Cell',
|
|
162
|
+
Template: /*html*/`<td title="{~D:Record.Value~}">{~D:Record.Value~}</td>`
|
|
163
|
+
},
|
|
164
|
+
{
|
|
165
|
+
Hash: 'PRSP-AssociateUnlink-Empty',
|
|
166
|
+
Template: /*html*/`<div class="prsp-unlink-empty">{~D:Record.EmptyText~}</div>`
|
|
167
|
+
},
|
|
168
|
+
{
|
|
169
|
+
Hash: 'PRSP-AssociateUnlink-Chooser',
|
|
170
|
+
Template: /*html*/`
|
|
171
|
+
<div class="prsp-unlink-colchooser-list">{~TS:PRSP-AssociateUnlink-ColRow:Record.Rows~}</div>
|
|
172
|
+
<div class="prsp-unlink-colchooser-foot"><button type="button" class="prsp-unlink-colreset" onclick="_Pict.views['RSP-RecordSet-AssociateUnlink'].resetColumns()">Reset to defaults</button></div>
|
|
173
|
+
`
|
|
174
|
+
},
|
|
175
|
+
{
|
|
176
|
+
Hash: 'PRSP-AssociateUnlink-ColRow',
|
|
177
|
+
Template: /*html*/`<button type="button" class="prsp-unlink-colrow{~NE:Record.Visible^ is-on~}" onclick="_Pict.views['RSP-RecordSet-AssociateUnlink'].toggleColumn('{~D:Record.Key~}')"><span class="prsp-unlink-colrow-check">{~I:Check~}</span><span>{~D:Record.DisplayName~}</span></button>`
|
|
178
|
+
}
|
|
179
|
+
],
|
|
180
|
+
|
|
181
|
+
Renderables:
|
|
182
|
+
[
|
|
183
|
+
{
|
|
184
|
+
RenderableHash: 'PRSP_Renderable_AssociateUnlink',
|
|
185
|
+
TemplateHash: 'PRSP-AssociateUnlink-Template',
|
|
186
|
+
ContentDestinationAddress: '#PRSP_Container',
|
|
187
|
+
RenderMethod: 'replace'
|
|
188
|
+
}
|
|
189
|
+
],
|
|
190
|
+
|
|
191
|
+
Manifests: {}
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
class viewRecordSetAssociateUnlink extends libPictView
|
|
195
|
+
{
|
|
196
|
+
constructor(pFable, pOptions, pServiceHash)
|
|
197
|
+
{
|
|
198
|
+
let tmpOptions = Object.assign({}, _DEFAULT_CONFIGURATION_AssociateUnlink, pOptions);
|
|
199
|
+
super(pFable, tmpOptions, pServiceHash);
|
|
200
|
+
|
|
201
|
+
/** @type {import('pict') & { PictSectionRecordSet: any }} */
|
|
202
|
+
this.pict;
|
|
203
|
+
|
|
204
|
+
this._associationHash = null;
|
|
205
|
+
this._anchorSide = null;
|
|
206
|
+
this._otherSide = null;
|
|
207
|
+
this._anchorID = null;
|
|
208
|
+
this._items = []; // current associations: { JoinID, OtherID, Display, OtherRecord, JoinRecord, Chips }
|
|
209
|
+
this._selected = {}; // selected JoinIDs to remove
|
|
210
|
+
this._search = '';
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/** @return {any} The association manager provider. */
|
|
214
|
+
get manager()
|
|
215
|
+
{
|
|
216
|
+
return this.pict.providers.RecordSetAssociationManager;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/** @return {any} The column-visibility persistence provider (localStorage; host-overridable). */
|
|
220
|
+
get columnProvider()
|
|
221
|
+
{
|
|
222
|
+
return this.pict.providers.ColumnDataProvider;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/** A DOM/address-safe key for this screen. */
|
|
226
|
+
get safeKey()
|
|
227
|
+
{
|
|
228
|
+
return `${String(this._associationHash)}_${String(this._anchorSide && this._anchorSide.RecordSet)}`.replace(/[^A-Za-z0-9]/g, '_');
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
addRoutes(pPictRouter)
|
|
232
|
+
{
|
|
233
|
+
pPictRouter.router.on('/PSRS/AssociateUnlink/:Association/:AnchorRecordSet/:AnchorID', this.handleUnlinkRoute.bind(this));
|
|
234
|
+
pPictRouter.router.on('/PSRS/AssociateUnlink/:Association/:AnchorRecordSet', this.handleUnlinkRoute.bind(this));
|
|
235
|
+
return true;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/**
|
|
239
|
+
* Route handler — resolve the association + anchor side, then paint.
|
|
240
|
+
* @param {Record<string, any>} pRoutePayload
|
|
241
|
+
*/
|
|
242
|
+
handleUnlinkRoute(pRoutePayload)
|
|
243
|
+
{
|
|
244
|
+
if (typeof(pRoutePayload) != 'object')
|
|
245
|
+
{
|
|
246
|
+
throw new Error(`Pict RecordSet AssociateUnlink route handler called with invalid route payload.`);
|
|
247
|
+
}
|
|
248
|
+
this._associationHash = pRoutePayload.data.Association;
|
|
249
|
+
this._items = [];
|
|
250
|
+
this._selected = {};
|
|
251
|
+
this._search = '';
|
|
252
|
+
const tmpAssociation = this.manager ? this.manager.getAssociation(this._associationHash) : null;
|
|
253
|
+
if (!tmpAssociation)
|
|
254
|
+
{
|
|
255
|
+
this.pict.log.warn(`AssociateUnlink: association [${this._associationHash}] is not registered.`);
|
|
256
|
+
this._anchorSide = null;
|
|
257
|
+
this._otherSide = null;
|
|
258
|
+
return this.renderUnlink();
|
|
259
|
+
}
|
|
260
|
+
const tmpAnchorRecordSet = pRoutePayload.data.AnchorRecordSet || tmpAssociation.SideA.RecordSet;
|
|
261
|
+
const tmpSides = this.manager.resolveSides(this._associationHash, tmpAnchorRecordSet);
|
|
262
|
+
this._anchorSide = tmpSides ? tmpSides.thisSide : tmpAssociation.SideA;
|
|
263
|
+
this._otherSide = tmpSides ? tmpSides.otherSide : tmpAssociation.SideB;
|
|
264
|
+
this._anchorID = (pRoutePayload.data.AnchorID !== undefined && pRoutePayload.data.AnchorID !== '') ? pRoutePayload.data.AnchorID : null;
|
|
265
|
+
return this.renderUnlink();
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/** @return {string} */
|
|
269
|
+
_anchorLabel() { return this._anchorSide ? (this._anchorSide.Title || this._anchorSide.RecordSet || this._anchorSide.Entity) : ''; }
|
|
270
|
+
/** @return {string} */
|
|
271
|
+
_otherLabel() { return this._otherSide ? (this._otherSide.Title || this._otherSide.RecordSet || this._otherSide.Entity) : ''; }
|
|
272
|
+
|
|
273
|
+
/** Persistence scope for this anchor side's column visibility (distinct from list/matrix scopes). */
|
|
274
|
+
_columnScope() { return `Unlink_${this._associationHash}_${this._anchorSide && this._anchorSide.RecordSet}`; }
|
|
275
|
+
|
|
276
|
+
/**
|
|
277
|
+
* Paint the screen shell and mount the anchor picker; load + render the table when an anchor is set.
|
|
278
|
+
* @return {Promise<boolean>}
|
|
279
|
+
*/
|
|
280
|
+
async renderUnlink()
|
|
281
|
+
{
|
|
282
|
+
if (!this._anchorSide || !this._otherSide)
|
|
283
|
+
{
|
|
284
|
+
this.pict.log.warn(`AssociateUnlink: could not resolve sides for [${this._associationHash}].`);
|
|
285
|
+
return false;
|
|
286
|
+
}
|
|
287
|
+
const tmpHasAnchor = (this._anchorID !== undefined && this._anchorID !== null && this._anchorID !== '');
|
|
288
|
+
if (tmpHasAnchor)
|
|
289
|
+
{
|
|
290
|
+
this._items = await this.manager.listAssociatedRecords(this._associationHash, this._anchorSide.RecordSet, this._anchorID);
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
const tmpBodyData = {
|
|
294
|
+
OtherLabel: this._otherLabel(),
|
|
295
|
+
StatsID: `${this.safeKey}_Stats`,
|
|
296
|
+
UnlinkButtonID: `${this.safeKey}_Unlink`,
|
|
297
|
+
ChooserID: `${this.safeKey}_Chooser`,
|
|
298
|
+
TableID: `${this.safeKey}_Table`,
|
|
299
|
+
};
|
|
300
|
+
const tmpRecord = {
|
|
301
|
+
Title: this.options.ScreenTitle || `Unlink ${this._otherLabel()} from a ${this._singular(this._anchorLabel())}`,
|
|
302
|
+
Subtitle: `Pick a ${this._singular(this._anchorLabel())}, then check the ${this._otherLabel()} to unlink and remove them together.`,
|
|
303
|
+
AnchorLabel: this._anchorLabel(),
|
|
304
|
+
AnchorPickerHostID: `${this.safeKey}_Anchor`,
|
|
305
|
+
PickerMissing: !this.pict.providers['Pict-Section-Picker'],
|
|
306
|
+
HintSlot: tmpHasAnchor ? [] : [ { Hint: `Select a ${this._singular(this._anchorLabel())} above to see its current links.` } ],
|
|
307
|
+
BodySlot: tmpHasAnchor ? [ tmpBodyData ] : [],
|
|
308
|
+
};
|
|
309
|
+
|
|
310
|
+
return new Promise((resolve) =>
|
|
311
|
+
{
|
|
312
|
+
this.renderAsync(this.options.DefaultRenderable, this.options.DefaultDestinationAddress, tmpRecord,
|
|
313
|
+
(pError) =>
|
|
314
|
+
{
|
|
315
|
+
if (pError)
|
|
316
|
+
{
|
|
317
|
+
this.pict.log.error(`AssociateUnlink: render error.`, pError);
|
|
318
|
+
return resolve(false);
|
|
319
|
+
}
|
|
320
|
+
this._mountAnchorPicker(tmpRecord.AnchorPickerHostID);
|
|
321
|
+
if (tmpHasAnchor)
|
|
322
|
+
{
|
|
323
|
+
this._renderTable();
|
|
324
|
+
this.updateStats();
|
|
325
|
+
}
|
|
326
|
+
this.pict.CSSMap.injectCSS();
|
|
327
|
+
return resolve(true);
|
|
328
|
+
});
|
|
329
|
+
});
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
/** Mount the anchor (this side) picker — single select, preselected to the current anchor. */
|
|
333
|
+
_mountAnchorPicker(pHostID)
|
|
334
|
+
{
|
|
335
|
+
const tmpPickerProvider = this.pict.providers['Pict-Section-Picker'];
|
|
336
|
+
if (!tmpPickerProvider)
|
|
337
|
+
{
|
|
338
|
+
return;
|
|
339
|
+
}
|
|
340
|
+
const tmpPickerHash = `${this.safeKey}_AnchorPicker`;
|
|
341
|
+
const tmpValueAddress = `AppData.PRSPUnlinkAnchor.${this.safeKey}`;
|
|
342
|
+
if (!this.pict.AppData.PRSPUnlinkAnchor) { this.pict.AppData.PRSPUnlinkAnchor = {}; }
|
|
343
|
+
this.pict.AppData.PRSPUnlinkAnchor[this.safeKey] = (this._anchorID !== undefined && this._anchorID !== null) ? this._anchorID : null;
|
|
344
|
+
|
|
345
|
+
const tmpConfig = this.manager.buildAnchorPickerConfig(this._associationHash, this._anchorSide.RecordSet,
|
|
346
|
+
{
|
|
347
|
+
DestinationAddress: `#${pHostID}`,
|
|
348
|
+
ValueAddress: tmpValueAddress,
|
|
349
|
+
Placeholder: `Search ${this._anchorLabel()}…`,
|
|
350
|
+
OnChange: (pValue) => { this.selectAnchor(pValue); },
|
|
351
|
+
});
|
|
352
|
+
if (!tmpConfig)
|
|
353
|
+
{
|
|
354
|
+
return;
|
|
355
|
+
}
|
|
356
|
+
tmpPickerProvider.createEntityPicker(tmpPickerHash, tmpConfig);
|
|
357
|
+
this.pict.views[tmpPickerHash].setValue((this._anchorID !== undefined && this._anchorID !== null) ? this._anchorID : null);
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
/**
|
|
361
|
+
* The anchor picker's OnChange — load that anchor's links and repaint.
|
|
362
|
+
* @param {string|number} pAnchorID @return {Promise<void>}
|
|
363
|
+
*/
|
|
364
|
+
async selectAnchor(pAnchorID)
|
|
365
|
+
{
|
|
366
|
+
this._anchorID = (pAnchorID !== undefined && pAnchorID !== '') ? pAnchorID : null;
|
|
367
|
+
this._selected = {};
|
|
368
|
+
this._search = '';
|
|
369
|
+
await this.renderUnlink();
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
/** Effective visibility for a column (stored override wins, else `!DefaultHidden`). */
|
|
373
|
+
_isColumnVisible(pCol)
|
|
374
|
+
{
|
|
375
|
+
const tmpOverrides = this.columnProvider ? this.columnProvider.getColumnVisibilityOverrides(this._columnScope(), 'Unlink') : {};
|
|
376
|
+
if (tmpOverrides && Object.prototype.hasOwnProperty.call(tmpOverrides, pCol.Key))
|
|
377
|
+
{
|
|
378
|
+
return tmpOverrides[pCol.Key] === true;
|
|
379
|
+
}
|
|
380
|
+
return !pCol.DefaultHidden;
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
/** @return {Array<Record<string, any>>} The currently-visible other-side columns. */
|
|
384
|
+
_visibleColumns()
|
|
385
|
+
{
|
|
386
|
+
return this._otherSide.TableColumns.filter((pCol) => this._isColumnVisible(pCol));
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
/** @return {Array<Record<string, any>>} The items matching the current search filter (client-side). */
|
|
390
|
+
_filteredItems()
|
|
391
|
+
{
|
|
392
|
+
const tmpTerm = (this._search || '').trim().toLowerCase();
|
|
393
|
+
if (!tmpTerm)
|
|
394
|
+
{
|
|
395
|
+
return this._items;
|
|
396
|
+
}
|
|
397
|
+
const tmpColumns = this._visibleColumns();
|
|
398
|
+
return this._items.filter((pItem) =>
|
|
399
|
+
{
|
|
400
|
+
if (String(pItem.Display).toLowerCase().includes(tmpTerm)) { return true; }
|
|
401
|
+
return tmpColumns.some((pCol) =>
|
|
402
|
+
{
|
|
403
|
+
const tmpValue = pCol.Template ? this.pict.parseTemplate(pCol.Template, pItem.OtherRecord) : pItem.OtherRecord[pCol.Key];
|
|
404
|
+
return (tmpValue !== undefined && tmpValue !== null) && String(tmpValue).toLowerCase().includes(tmpTerm);
|
|
405
|
+
});
|
|
406
|
+
});
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
/** Build the row models for the (filtered) current associations and render the table. */
|
|
410
|
+
_renderTable()
|
|
411
|
+
{
|
|
412
|
+
const tmpColumns = this._visibleColumns();
|
|
413
|
+
const tmpItems = this._filteredItems();
|
|
414
|
+
const tmpRows = tmpItems.map((pItem) => (
|
|
415
|
+
{
|
|
416
|
+
JoinID: pItem.JoinID,
|
|
417
|
+
RowID: `${this.safeKey}_row_${pItem.JoinID}`,
|
|
418
|
+
Selected: !!this._selected[String(pItem.JoinID)],
|
|
419
|
+
Cells: tmpColumns.map((pCol) => ({ Value: pCol.Template ? this.pict.parseTemplate(pCol.Template, pItem.OtherRecord) : pItem.OtherRecord[pCol.Key] })),
|
|
420
|
+
}));
|
|
421
|
+
const tmpAllSelected = (tmpRows.length > 0 && tmpRows.every((pRow) => pRow.Selected));
|
|
422
|
+
const tmpTableRecord = {
|
|
423
|
+
Columns: tmpColumns.map((pCol) => ({ DisplayName: pCol.DisplayName })),
|
|
424
|
+
Rows: tmpRows,
|
|
425
|
+
SelectAllIcon: this.pict.icon(tmpAllSelected ? 'Check' : 'Minus'),
|
|
426
|
+
EmptySlot: (tmpRows.length === 0) ? [ { EmptyText: this._search ? `No ${this._otherLabel()} match the filter.` : `This ${this._singular(this._anchorLabel())} has no ${this._otherLabel()} linked.` } ] : [],
|
|
427
|
+
};
|
|
428
|
+
const tmpHTML = this.pict.parseTemplateByHash('PRSP-AssociateUnlink-Table', tmpTableRecord);
|
|
429
|
+
this.pict.ContentAssignment.assignContent(`#${this.safeKey}_Table`, tmpHTML);
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
/** Debounced client-side filter of the loaded links. @param {string} pTerm */
|
|
433
|
+
searchItems(pTerm)
|
|
434
|
+
{
|
|
435
|
+
this._search = pTerm;
|
|
436
|
+
this._renderTable();
|
|
437
|
+
this.updateStats();
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
/**
|
|
441
|
+
* Toggle a link's selection (the row click). Updates the set + row state + stats.
|
|
442
|
+
* @param {string|number} pJoinID
|
|
443
|
+
*/
|
|
444
|
+
toggleRow(pJoinID)
|
|
445
|
+
{
|
|
446
|
+
const tmpKey = String(pJoinID);
|
|
447
|
+
if (this._selected[tmpKey]) { delete this._selected[tmpKey]; }
|
|
448
|
+
else { this._selected[tmpKey] = true; }
|
|
449
|
+
const tmpRowElements = this.pict.ContentAssignment.getElement(`#${this.safeKey}_row_${pJoinID}`);
|
|
450
|
+
if (tmpRowElements && tmpRowElements.length > 0)
|
|
451
|
+
{
|
|
452
|
+
tmpRowElements[0].classList.toggle('is-selected', !!this._selected[tmpKey]);
|
|
453
|
+
}
|
|
454
|
+
this.updateStats();
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
/** Select-all / clear-all the currently-filtered rows (the header checkbox). */
|
|
458
|
+
toggleSelectAll()
|
|
459
|
+
{
|
|
460
|
+
const tmpFiltered = this._filteredItems();
|
|
461
|
+
const tmpAllSelected = (tmpFiltered.length > 0 && tmpFiltered.every((pItem) => this._selected[String(pItem.JoinID)]));
|
|
462
|
+
this._selected = {};
|
|
463
|
+
if (!tmpAllSelected)
|
|
464
|
+
{
|
|
465
|
+
for (let i = 0; i < tmpFiltered.length; i++) { this._selected[String(tmpFiltered[i].JoinID)] = true; }
|
|
466
|
+
}
|
|
467
|
+
this._renderTable();
|
|
468
|
+
this.updateStats();
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
/** @return {number} */
|
|
472
|
+
_selectedCount() { return Object.keys(this._selected).length; }
|
|
473
|
+
|
|
474
|
+
/** Recompute the stats line + the unlink button's enabled state. */
|
|
475
|
+
updateStats()
|
|
476
|
+
{
|
|
477
|
+
const tmpSelected = this._selectedCount();
|
|
478
|
+
const tmpTotal = this._items.length;
|
|
479
|
+
this.pict.ContentAssignment.assignContent(`#${this.safeKey}_Stats`,
|
|
480
|
+
`<strong>${tmpSelected}</strong> of ${tmpTotal} ${this._otherLabel()} selected to unlink`);
|
|
481
|
+
const tmpButtonElements = this.pict.ContentAssignment.getElement(`#${this.safeKey}_Unlink`);
|
|
482
|
+
if (tmpButtonElements && tmpButtonElements.length > 0)
|
|
483
|
+
{
|
|
484
|
+
tmpButtonElements[0].disabled = (tmpSelected < 1);
|
|
485
|
+
}
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
/**
|
|
489
|
+
* Remove every selected link (confirm via the host modal), then reload the anchor's links + repaint.
|
|
490
|
+
* @return {Promise<void>}
|
|
491
|
+
*/
|
|
492
|
+
async unlinkSelected()
|
|
493
|
+
{
|
|
494
|
+
const tmpSelectedJoinIDs = Object.keys(this._selected);
|
|
495
|
+
if (tmpSelectedJoinIDs.length < 1)
|
|
496
|
+
{
|
|
497
|
+
return;
|
|
498
|
+
}
|
|
499
|
+
const tmpItemsByJoin = {};
|
|
500
|
+
for (let i = 0; i < this._items.length; i++) { tmpItemsByJoin[String(this._items[i].JoinID)] = this._items[i]; }
|
|
501
|
+
|
|
502
|
+
const tmpModal = this.pict.views['Pict-Section-Modal'];
|
|
503
|
+
if (tmpModal && typeof tmpModal.confirm === 'function')
|
|
504
|
+
{
|
|
505
|
+
const tmpOk = await tmpModal.confirm(`Unlink ${tmpSelectedJoinIDs.length} ${this._otherLabel()} from this ${this._singular(this._anchorLabel())}?`,
|
|
506
|
+
{ title: 'Unlink', confirmLabel: `Unlink ${tmpSelectedJoinIDs.length}`, cancelLabel: 'Cancel', dangerous: true });
|
|
507
|
+
if (!tmpOk)
|
|
508
|
+
{
|
|
509
|
+
return;
|
|
510
|
+
}
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
let tmpRemoved = 0;
|
|
514
|
+
let tmpFailed = 0;
|
|
515
|
+
for (let i = 0; i < tmpSelectedJoinIDs.length; i++)
|
|
516
|
+
{
|
|
517
|
+
const tmpItem = tmpItemsByJoin[tmpSelectedJoinIDs[i]];
|
|
518
|
+
if (!tmpItem) { continue; }
|
|
519
|
+
try
|
|
520
|
+
{
|
|
521
|
+
await this.manager.removeJoin(this._associationHash, tmpItem.JoinRecord);
|
|
522
|
+
tmpRemoved++;
|
|
523
|
+
}
|
|
524
|
+
catch (pError)
|
|
525
|
+
{
|
|
526
|
+
tmpFailed++;
|
|
527
|
+
this.pict.log.error(`AssociateUnlink: failed to remove join ${tmpItem.JoinID}.`, pError);
|
|
528
|
+
}
|
|
529
|
+
}
|
|
530
|
+
this._toast(`${tmpRemoved} unlinked${tmpFailed > 0 ? `, ${tmpFailed} failed` : ''}.`, tmpFailed > 0 ? 'error' : 'success');
|
|
531
|
+
|
|
532
|
+
this._selected = {};
|
|
533
|
+
await this.renderUnlink();
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
// --- Column chooser (mirrors the matrix's, scoped per anchor side) ---
|
|
537
|
+
|
|
538
|
+
/** Open/close the column chooser. */
|
|
539
|
+
toggleColumnChooser()
|
|
540
|
+
{
|
|
541
|
+
const tmpWrapElements = this.pict.ContentAssignment.getElement(`#${this.safeKey}_Chooser_Wrap`);
|
|
542
|
+
if (!tmpWrapElements || tmpWrapElements.length < 1) { return; }
|
|
543
|
+
if (tmpWrapElements[0].classList.contains('is-open'))
|
|
544
|
+
{
|
|
545
|
+
tmpWrapElements[0].classList.remove('is-open');
|
|
546
|
+
return;
|
|
547
|
+
}
|
|
548
|
+
this._renderColumnChooser();
|
|
549
|
+
tmpWrapElements[0].classList.add('is-open');
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
/** Close the column chooser (backdrop outside-click). */
|
|
553
|
+
closeColumnChooser()
|
|
554
|
+
{
|
|
555
|
+
const tmpWrapElements = this.pict.ContentAssignment.getElement(`#${this.safeKey}_Chooser_Wrap`);
|
|
556
|
+
if (tmpWrapElements && tmpWrapElements.length > 0)
|
|
557
|
+
{
|
|
558
|
+
tmpWrapElements[0].classList.remove('is-open');
|
|
559
|
+
}
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
/** Render the chooser rows. */
|
|
563
|
+
_renderColumnChooser()
|
|
564
|
+
{
|
|
565
|
+
const tmpRows = this._otherSide.TableColumns.map((pCol) => ({ Key: pCol.Key, DisplayName: pCol.DisplayName, Visible: this._isColumnVisible(pCol) }));
|
|
566
|
+
const tmpHTML = this.pict.parseTemplateByHash('PRSP-AssociateUnlink-Chooser', { Rows: tmpRows });
|
|
567
|
+
this.pict.ContentAssignment.assignContent(`#${this.safeKey}_Chooser`, tmpHTML);
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
/** Toggle one column's visibility (persisted; refuses to hide the last visible column). @param {string} pKey */
|
|
571
|
+
toggleColumn(pKey)
|
|
572
|
+
{
|
|
573
|
+
if (!this.columnProvider) { return; }
|
|
574
|
+
const tmpCol = this._otherSide.TableColumns.find((pCol) => String(pCol.Key) === String(pKey));
|
|
575
|
+
if (!tmpCol) { return; }
|
|
576
|
+
const tmpVisible = this._isColumnVisible(tmpCol);
|
|
577
|
+
if (tmpVisible && this._visibleColumns().length <= 1) { return; }
|
|
578
|
+
this.columnProvider.setColumnVisibilityOverride(this._columnScope(), 'Unlink', pKey, !tmpVisible);
|
|
579
|
+
this._renderTable();
|
|
580
|
+
this._renderColumnChooser();
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
/** Clear column overrides — back to developer defaults. */
|
|
584
|
+
resetColumns()
|
|
585
|
+
{
|
|
586
|
+
if (this.columnProvider) { this.columnProvider.clearColumnVisibilityOverrides(this._columnScope(), 'Unlink'); }
|
|
587
|
+
this._renderTable();
|
|
588
|
+
this._renderColumnChooser();
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
/** Crude singularizer ("Books" -> "Book"). */
|
|
592
|
+
_singular(pLabel)
|
|
593
|
+
{
|
|
594
|
+
return (typeof pLabel === 'string' && pLabel.length > 1 && pLabel.slice(-1).toLowerCase() === 's') ? pLabel.slice(0, -1) : pLabel;
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
/** Non-blocking notification via the host modal's toast, when available. */
|
|
598
|
+
_toast(pMessage, pType)
|
|
599
|
+
{
|
|
600
|
+
const tmpModal = this.pict.views['Pict-Section-Modal'];
|
|
601
|
+
if (tmpModal && typeof tmpModal.toast === 'function')
|
|
602
|
+
{
|
|
603
|
+
tmpModal.toast(pMessage, { type: pType || 'info' });
|
|
604
|
+
}
|
|
605
|
+
}
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
module.exports = viewRecordSetAssociateUnlink;
|
|
609
|
+
|
|
610
|
+
module.exports.default_configuration = _DEFAULT_CONFIGURATION_AssociateUnlink;
|