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,680 @@
|
|
|
1
|
+
const libPictView = require('pict-view');
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The Matrix Associate screen — a dual-TABLE bulk-link tool for one association. Unlike the chip-picker
|
|
5
|
+
* screens, each side is a full record table with configurable columns (per-side `TableColumns`) and a
|
|
6
|
+
* checkbox per row — built for connecting complex records (Materials ↔ Pay Items, with several codes
|
|
7
|
+
* and data points on both sides) where a single label can't disambiguate. Multi-select rows on BOTH
|
|
8
|
+
* sides; a live stats line shows the pending count; "Link selected" creates the CROSS-PRODUCT of joins
|
|
9
|
+
* (every left record × every right record), skipping pairs that already exist.
|
|
10
|
+
*
|
|
11
|
+
* Registered ONCE by the metacontroller as `RSP-RecordSet-AssociateMatrix` and parameterized by route:
|
|
12
|
+
* /PSRS/AssociateMatrix/:Association (left = the association's SideA)
|
|
13
|
+
* /PSRS/AssociateMatrix/:Association/:LeftRecordSet (left = the named side)
|
|
14
|
+
*
|
|
15
|
+
* Records + columns flow through the shared `RecordSetAssociationManager` (fetchSidePage + TableColumns).
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
/** @type {Record<string, any>} */
|
|
19
|
+
const _DEFAULT_CONFIGURATION_AssociateMatrix = (
|
|
20
|
+
{
|
|
21
|
+
ViewIdentifier: 'PRSP-AssociateMatrix',
|
|
22
|
+
|
|
23
|
+
DefaultRenderable: 'PRSP_Renderable_AssociateMatrix',
|
|
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
|
+
MatrixPageSize: 25,
|
|
35
|
+
|
|
36
|
+
CSS: /*css*/`
|
|
37
|
+
.prsp-matrix { display: flex; flex-direction: column; gap: 1rem; padding: 0.25rem 0 1rem; }
|
|
38
|
+
.prsp-matrix-header h2 { margin: 0 0 0.2rem; font-size: 1.25rem; color: var(--theme-color-text-primary, #1f2733); }
|
|
39
|
+
.prsp-matrix-sub { margin: 0; color: var(--theme-color-text-muted, #6b7686); font-size: 0.92rem; }
|
|
40
|
+
.prsp-matrix-bar { display: flex; align-items: center; justify-content: space-between; gap: 1rem; flex-wrap: wrap; position: sticky; top: 0; z-index: 2;
|
|
41
|
+
border: 1px solid var(--theme-color-border-light, #e8ebf0); border-radius: 12px; padding: 0.7rem 1rem; background: var(--theme-color-background-secondary, #f7f8fa); }
|
|
42
|
+
.prsp-matrix-stats { font-size: 0.95rem; color: var(--theme-color-text-secondary, #45505f); }
|
|
43
|
+
.prsp-matrix-stats strong { color: var(--theme-color-brand-primary, #156dd1); font-size: 1.05rem; }
|
|
44
|
+
.prsp-matrix-stats .prsp-matrix-eq { color: var(--theme-color-text-muted, #6b7686); margin: 0 0.15rem; }
|
|
45
|
+
.prsp-matrix-link { 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-brand-primary, #156dd1);
|
|
47
|
+
background: var(--theme-color-brand-primary, #156dd1); color: #fff; }
|
|
48
|
+
.prsp-matrix-link:hover { background: var(--theme-color-brand-primary-hover, #1259ad); }
|
|
49
|
+
.prsp-matrix-link[disabled] { opacity: 0.5; cursor: not-allowed; }
|
|
50
|
+
.prsp-matrix-cols { display: flex; gap: 1rem; align-items: flex-start; }
|
|
51
|
+
.prsp-matrix-col { flex: 1 1 0; min-width: 0; display: flex; flex-direction: column; gap: 0.5rem;
|
|
52
|
+
border: 1px solid var(--theme-color-border-light, #e8ebf0); border-radius: 12px; padding: 0.8rem 0.9rem; background: var(--theme-color-background-primary, #fff); }
|
|
53
|
+
.prsp-matrix-col-head { display: flex; align-items: baseline; justify-content: space-between; gap: 0.5rem; }
|
|
54
|
+
.prsp-matrix-col-label { font-size: 0.72rem; font-weight: 650; text-transform: uppercase; letter-spacing: 0.05em; color: var(--theme-color-text-muted, #6b7686); }
|
|
55
|
+
.prsp-matrix-col-count { font-size: 0.78rem; color: var(--theme-color-brand-primary, #156dd1); font-weight: 600; }
|
|
56
|
+
.prsp-matrix-col-head-right { display: flex; align-items: center; gap: 0.6rem; }
|
|
57
|
+
.prsp-matrix-colbtn { display: inline-flex; align-items: center; gap: 0.3rem; cursor: pointer; font: inherit; font-size: 0.78rem; padding: 0.2rem 0.5rem;
|
|
58
|
+
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); }
|
|
59
|
+
.prsp-matrix-colbtn:hover { background: var(--theme-color-background-tertiary, #eceef2); color: var(--theme-color-text-primary, #1f2733); }
|
|
60
|
+
.prsp-matrix-colchooser-wrap { position: relative; }
|
|
61
|
+
/* Transparent full-viewport backdrop catches the outside click to dismiss (no document listener);
|
|
62
|
+
only present while open, with the popover stacked above it. */
|
|
63
|
+
.prsp-matrix-colchooser-backdrop { position: fixed; inset: 0; z-index: 30; display: none; }
|
|
64
|
+
.prsp-matrix-colchooser-wrap.is-open .prsp-matrix-colchooser-backdrop { display: block; }
|
|
65
|
+
.prsp-matrix-colchooser { position: absolute; right: 0; top: 0.3rem; z-index: 40; min-width: 200px; display: none;
|
|
66
|
+
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; }
|
|
67
|
+
.prsp-matrix-colchooser-wrap.is-open .prsp-matrix-colchooser { display: block; }
|
|
68
|
+
.prsp-matrix-colchooser-list { max-height: 50vh; overflow-y: auto; padding: 0.25rem; }
|
|
69
|
+
.prsp-matrix-colrow { display: flex; align-items: center; gap: 0.5rem; width: 100%; text-align: left; cursor: pointer; font: inherit; font-size: 0.86rem;
|
|
70
|
+
padding: 0.4rem 0.55rem; border: none; border-radius: 6px; background: transparent; color: var(--theme-color-text-primary, #1f2733); }
|
|
71
|
+
.prsp-matrix-colrow:hover { background: var(--theme-color-background-tertiary, #eceef2); }
|
|
72
|
+
.prsp-matrix-colrow-check { flex: 0 0 auto; display: inline-flex; width: 1em; color: var(--theme-color-brand-primary, #156dd1); visibility: hidden; }
|
|
73
|
+
.prsp-matrix-colrow.is-on .prsp-matrix-colrow-check { visibility: visible; }
|
|
74
|
+
.prsp-matrix-colchooser-foot { display: flex; justify-content: flex-end; padding: 0.35rem 0.5rem; border-top: 1px solid var(--theme-color-border-light, #e8ebf0); }
|
|
75
|
+
.prsp-matrix-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; }
|
|
76
|
+
.prsp-matrix-colreset:hover { color: var(--theme-color-text-primary, #1f2733); background: var(--theme-color-background-tertiary, #eceef2); }
|
|
77
|
+
.prsp-matrix-search { 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; }
|
|
78
|
+
.prsp-matrix-search-ic { display: inline-flex; color: var(--theme-color-text-muted, #6b7686); font-size: 0.9rem; }
|
|
79
|
+
.prsp-matrix-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); }
|
|
80
|
+
.prsp-matrix-tablewrap { max-height: 56vh; overflow: auto; border: 1px solid var(--theme-color-border-light, #e8ebf0); border-radius: 8px; }
|
|
81
|
+
.prsp-mtbl { width: 100%; border-collapse: collapse; font-size: 0.88rem; }
|
|
82
|
+
.prsp-mtbl thead th { position: sticky; top: 0; z-index: 1; text-align: left; padding: 0.5rem 0.6rem; background: var(--theme-color-background-tertiary, #eceef2);
|
|
83
|
+
color: var(--theme-color-text-secondary, #45505f); font-size: 0.72rem; font-weight: 650; text-transform: uppercase; letter-spacing: 0.03em; white-space: nowrap; }
|
|
84
|
+
.prsp-mtbl-th-check { width: 1.8rem; }
|
|
85
|
+
.prsp-mtbl-row { cursor: pointer; border-top: 1px solid var(--theme-color-border-light, #e8ebf0); }
|
|
86
|
+
.prsp-mtbl-row:hover { background: var(--theme-color-background-tertiary, #eceef2); }
|
|
87
|
+
.prsp-mtbl-row.is-selected { background: var(--theme-color-background-selected, #e3edfb); }
|
|
88
|
+
.prsp-mtbl-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; }
|
|
89
|
+
.prsp-mtbl-td-check { width: 1.8rem; text-align: center; }
|
|
90
|
+
.prsp-mtbl-check { display: inline-flex; color: var(--theme-color-brand-primary, #156dd1); visibility: hidden; }
|
|
91
|
+
.prsp-mtbl-row.is-selected .prsp-mtbl-check { visibility: visible; }
|
|
92
|
+
.prsp-matrix-empty { padding: 0.8rem; color: var(--theme-color-text-muted, #6b7686); font-size: 0.88rem; font-style: italic; text-align: center; }
|
|
93
|
+
.prsp-matrix-more { display: block; width: 100%; padding: 0.45rem; cursor: pointer; font: inherit; font-size: 0.85rem; border: none; border-top: 1px solid var(--theme-color-border-light, #e8ebf0);
|
|
94
|
+
background: transparent; color: var(--theme-color-brand-primary, #156dd1); }
|
|
95
|
+
.prsp-matrix-more:hover { background: var(--theme-color-background-tertiary, #eceef2); }
|
|
96
|
+
.prsp-matrix-cross { flex: 0 0 auto; display: flex; align-items: center; align-self: center; color: var(--theme-color-text-muted, #6b7686); font-size: 1.4rem; }
|
|
97
|
+
.prsp-matrix-note { color: var(--theme-color-status-error, #b62828); font-size: 0.86rem; }
|
|
98
|
+
@media (max-width: 820px) { .prsp-matrix-cols { flex-direction: column; } .prsp-matrix-cross { align-self: center; transform: rotate(90deg); } }
|
|
99
|
+
`,
|
|
100
|
+
CSSPriority: 500,
|
|
101
|
+
|
|
102
|
+
Templates:
|
|
103
|
+
[
|
|
104
|
+
{
|
|
105
|
+
Hash: 'PRSP-AssociateMatrix-Template',
|
|
106
|
+
Template: /*html*/`
|
|
107
|
+
<!-- DefaultPackage pict view template: [PRSP-AssociateMatrix-Template] -->
|
|
108
|
+
<div class="prsp-matrix">
|
|
109
|
+
<div class="prsp-matrix-header">
|
|
110
|
+
<h2>{~D:Record.Title~}</h2>
|
|
111
|
+
<p class="prsp-matrix-sub">{~D:Record.Subtitle~}</p>
|
|
112
|
+
</div>
|
|
113
|
+
<div class="prsp-matrix-bar">
|
|
114
|
+
<div class="prsp-matrix-stats" id="{~D:Record.StatsID~}">{~T:PRSP-AssociateMatrix-Stats:Record~}</div>
|
|
115
|
+
<button type="button" class="prsp-matrix-link" id="{~D:Record.LinkButtonID~}" onclick="_Pict.views['RSP-RecordSet-AssociateMatrix'].associateMatrix()">{~I:Plus~} Link selected</button>
|
|
116
|
+
</div>
|
|
117
|
+
{~NE:Record.PickerMissing^<div class="prsp-matrix-note">The entity provider is not available, so this screen cannot run.</div>~}
|
|
118
|
+
<div class="prsp-matrix-cols">
|
|
119
|
+
{~T:PRSP-AssociateMatrix-Panel:Record.Left~}
|
|
120
|
+
<div class="prsp-matrix-cross">{~I:Plus~}</div>
|
|
121
|
+
{~T:PRSP-AssociateMatrix-Panel:Record.Right~}
|
|
122
|
+
</div>
|
|
123
|
+
</div>
|
|
124
|
+
<!-- DefaultPackage end view template: [PRSP-AssociateMatrix-Template] -->
|
|
125
|
+
`
|
|
126
|
+
},
|
|
127
|
+
{
|
|
128
|
+
// One side's panel shell: head + search (rendered once) + an empty table container the
|
|
129
|
+
// table sub-render targets, so searching/paging never disturbs the search input's focus.
|
|
130
|
+
Hash: 'PRSP-AssociateMatrix-Panel',
|
|
131
|
+
Template: /*html*/`
|
|
132
|
+
<div class="prsp-matrix-col">
|
|
133
|
+
<div class="prsp-matrix-col-head">
|
|
134
|
+
<span class="prsp-matrix-col-label">{~D:Record.Label~}</span>
|
|
135
|
+
<div class="prsp-matrix-col-head-right">
|
|
136
|
+
<span class="prsp-matrix-col-count" id="{~D:Record.CountID~}"></span>
|
|
137
|
+
<div class="prsp-matrix-colchooser-wrap" id="{~D:Record.ChooserID~}_Wrap">
|
|
138
|
+
<button type="button" class="prsp-matrix-colbtn" title="Choose columns" onclick="_Pict.views['RSP-RecordSet-AssociateMatrix'].toggleColumnChooser('{~D:Record.Column~}')">{~I:Settings~} Columns</button>
|
|
139
|
+
<div class="prsp-matrix-colchooser-backdrop" onclick="_Pict.views['RSP-RecordSet-AssociateMatrix'].closeColumnChooser('{~D:Record.Column~}')"></div>
|
|
140
|
+
<div class="prsp-matrix-colchooser" id="{~D:Record.ChooserID~}"></div>
|
|
141
|
+
</div>
|
|
142
|
+
</div>
|
|
143
|
+
</div>
|
|
144
|
+
<div class="prsp-matrix-search">
|
|
145
|
+
<span class="prsp-matrix-search-ic">{~I:Search~}</span>
|
|
146
|
+
<input type="text" placeholder="Search {~D:Record.Label~}…" autocomplete="off" oninput="_Pict.views['RSP-RecordSet-AssociateMatrix'].searchSide('{~D:Record.Column~}', this.value)">
|
|
147
|
+
</div>
|
|
148
|
+
<div class="prsp-matrix-tablewrap"><div id="{~D:Record.TableID~}"></div></div>
|
|
149
|
+
</div>
|
|
150
|
+
`
|
|
151
|
+
},
|
|
152
|
+
{
|
|
153
|
+
Hash: 'PRSP-AssociateMatrix-Chooser',
|
|
154
|
+
Template: /*html*/`
|
|
155
|
+
<div class="prsp-matrix-colchooser-list">{~TS:PRSP-AssociateMatrix-ColRow:Record.Rows~}</div>
|
|
156
|
+
<div class="prsp-matrix-colchooser-foot"><button type="button" class="prsp-matrix-colreset" onclick="_Pict.views['RSP-RecordSet-AssociateMatrix'].resetColumns('{~D:Record.Column~}')">Reset to defaults</button></div>
|
|
157
|
+
`
|
|
158
|
+
},
|
|
159
|
+
{
|
|
160
|
+
Hash: 'PRSP-AssociateMatrix-ColRow',
|
|
161
|
+
Template: /*html*/`<button type="button" class="prsp-matrix-colrow{~NE:Record.Visible^ is-on~}" onclick="_Pict.views['RSP-RecordSet-AssociateMatrix'].toggleColumn('{~D:Record.Column~}','{~D:Record.Key~}')"><span class="prsp-matrix-colrow-check">{~I:Check~}</span><span>{~D:Record.DisplayName~}</span></button>`
|
|
162
|
+
},
|
|
163
|
+
{
|
|
164
|
+
Hash: 'PRSP-AssociateMatrix-Table',
|
|
165
|
+
Template: /*html*/`
|
|
166
|
+
<table class="prsp-mtbl">
|
|
167
|
+
<thead><tr><th class="prsp-mtbl-th-check"></th>{~TS:PRSP-AssociateMatrix-HeaderCell:Record.Columns~}</tr></thead>
|
|
168
|
+
<tbody>{~TS:PRSP-AssociateMatrix-Row:Record.Rows~}</tbody>
|
|
169
|
+
</table>
|
|
170
|
+
{~TS:PRSP-AssociateMatrix-Empty:Record.EmptySlot~}
|
|
171
|
+
{~TS:PRSP-AssociateMatrix-More:Record.MoreSlot~}
|
|
172
|
+
`
|
|
173
|
+
},
|
|
174
|
+
{
|
|
175
|
+
Hash: 'PRSP-AssociateMatrix-Empty',
|
|
176
|
+
Template: /*html*/`<div class="prsp-matrix-empty">{~D:Record.EmptyText~}</div>`
|
|
177
|
+
},
|
|
178
|
+
{
|
|
179
|
+
Hash: 'PRSP-AssociateMatrix-HeaderCell',
|
|
180
|
+
Template: /*html*/`<th>{~D:Record.DisplayName~}</th>`
|
|
181
|
+
},
|
|
182
|
+
{
|
|
183
|
+
Hash: 'PRSP-AssociateMatrix-Row',
|
|
184
|
+
Template: /*html*/`<tr id="{~D:Record.RowID~}" class="prsp-mtbl-row{~NE:Record.Selected^ is-selected~}" onclick="_Pict.views['RSP-RecordSet-AssociateMatrix'].toggleRow('{~D:Record.Column~}','{~D:Record.Value~}')"><td class="prsp-mtbl-td-check"><span class="prsp-mtbl-check">{~I:Check~}</span></td>{~TS:PRSP-AssociateMatrix-Cell:Record.Cells~}</tr>`
|
|
185
|
+
},
|
|
186
|
+
{
|
|
187
|
+
Hash: 'PRSP-AssociateMatrix-Cell',
|
|
188
|
+
Template: /*html*/`<td title="{~D:Record.Value~}">{~D:Record.Value~}</td>`
|
|
189
|
+
},
|
|
190
|
+
{
|
|
191
|
+
Hash: 'PRSP-AssociateMatrix-More',
|
|
192
|
+
Template: /*html*/`<button type="button" class="prsp-matrix-more" onclick="_Pict.views['RSP-RecordSet-AssociateMatrix'].loadMoreSide('{~D:Record.Column~}')">Load more</button>`
|
|
193
|
+
},
|
|
194
|
+
{
|
|
195
|
+
Hash: 'PRSP-AssociateMatrix-Stats',
|
|
196
|
+
Template: /*html*/`<strong>{~D:Record.LeftCount~}</strong> {~D:Record.LeftLabel~} <span class="prsp-matrix-eq">×</span> <strong>{~D:Record.RightCount~}</strong> {~D:Record.RightLabel~} <span class="prsp-matrix-eq">=</span> <strong>{~D:Record.PairCount~}</strong> link{~D:Record.PairPlural~}`
|
|
197
|
+
}
|
|
198
|
+
],
|
|
199
|
+
|
|
200
|
+
Renderables:
|
|
201
|
+
[
|
|
202
|
+
{
|
|
203
|
+
RenderableHash: 'PRSP_Renderable_AssociateMatrix',
|
|
204
|
+
TemplateHash: 'PRSP-AssociateMatrix-Template',
|
|
205
|
+
ContentDestinationAddress: '#PRSP_Container',
|
|
206
|
+
RenderMethod: 'replace'
|
|
207
|
+
}
|
|
208
|
+
],
|
|
209
|
+
|
|
210
|
+
Manifests: {}
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
class viewRecordSetAssociateMatrix extends libPictView
|
|
214
|
+
{
|
|
215
|
+
constructor(pFable, pOptions, pServiceHash)
|
|
216
|
+
{
|
|
217
|
+
let tmpOptions = Object.assign({}, _DEFAULT_CONFIGURATION_AssociateMatrix, pOptions);
|
|
218
|
+
super(pFable, tmpOptions, pServiceHash);
|
|
219
|
+
|
|
220
|
+
/** @type {import('pict') & { PictSectionRecordSet: any }} */
|
|
221
|
+
this.pict;
|
|
222
|
+
|
|
223
|
+
this._associationHash = null;
|
|
224
|
+
// Per-column state (Left / Right): the resolved side, loaded records, paging, search, selection.
|
|
225
|
+
this._side = { Left: this._blankSide('Left'), Right: this._blankSide('Right') };
|
|
226
|
+
this._searchTimers = { Left: null, Right: null };
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/** @param {'Left'|'Right'} pColumn @return {Record<string, any>} A blank per-side state. */
|
|
230
|
+
_blankSide(pColumn)
|
|
231
|
+
{
|
|
232
|
+
return { Column: pColumn, side: null, records: [], cursor: 0, hasMore: false, search: '', selected: {} };
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/** @return {any} The association manager provider. */
|
|
236
|
+
get manager()
|
|
237
|
+
{
|
|
238
|
+
return this.pict.providers.RecordSetAssociationManager;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/** A DOM/address-safe key for this screen. */
|
|
242
|
+
get safeKey()
|
|
243
|
+
{
|
|
244
|
+
return `${String(this._associationHash)}`.replace(/[^A-Za-z0-9]/g, '_');
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
addRoutes(pPictRouter)
|
|
248
|
+
{
|
|
249
|
+
pPictRouter.router.on('/PSRS/AssociateMatrix/:Association/:LeftRecordSet', this.handleMatrixRoute.bind(this));
|
|
250
|
+
pPictRouter.router.on('/PSRS/AssociateMatrix/:Association', this.handleMatrixRoute.bind(this));
|
|
251
|
+
return true;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/**
|
|
255
|
+
* Route handler — resolve the association + which side is on the left, then paint.
|
|
256
|
+
* @param {Record<string, any>} pRoutePayload
|
|
257
|
+
*/
|
|
258
|
+
handleMatrixRoute(pRoutePayload)
|
|
259
|
+
{
|
|
260
|
+
if (typeof(pRoutePayload) != 'object')
|
|
261
|
+
{
|
|
262
|
+
throw new Error(`Pict RecordSet AssociateMatrix route handler called with invalid route payload.`);
|
|
263
|
+
}
|
|
264
|
+
this._associationHash = pRoutePayload.data.Association;
|
|
265
|
+
this._side = { Left: this._blankSide('Left'), Right: this._blankSide('Right') };
|
|
266
|
+
const tmpAssociation = this.manager ? this.manager.getAssociation(this._associationHash) : null;
|
|
267
|
+
if (!tmpAssociation)
|
|
268
|
+
{
|
|
269
|
+
this.pict.log.warn(`AssociateMatrix: association [${this._associationHash}] is not registered.`);
|
|
270
|
+
return this.renderMatrix();
|
|
271
|
+
}
|
|
272
|
+
// Left side: the named :LeftRecordSet, else the association's SideA. The other side goes right.
|
|
273
|
+
const tmpLeftRecordSet = pRoutePayload.data.LeftRecordSet || tmpAssociation.SideA.RecordSet;
|
|
274
|
+
const tmpSides = this.manager.resolveSides(this._associationHash, tmpLeftRecordSet);
|
|
275
|
+
this._side.Left.side = tmpSides ? tmpSides.thisSide : tmpAssociation.SideA;
|
|
276
|
+
this._side.Right.side = tmpSides ? tmpSides.otherSide : tmpAssociation.SideB;
|
|
277
|
+
return this.renderMatrix();
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
/**
|
|
281
|
+
* Paint the screen shell, then fetch + render both tables.
|
|
282
|
+
* @return {Promise<boolean>}
|
|
283
|
+
*/
|
|
284
|
+
renderMatrix()
|
|
285
|
+
{
|
|
286
|
+
if (!this._side.Left.side || !this._side.Right.side)
|
|
287
|
+
{
|
|
288
|
+
this.pict.log.warn(`AssociateMatrix: could not resolve sides for [${this._associationHash}].`);
|
|
289
|
+
return Promise.resolve(false);
|
|
290
|
+
}
|
|
291
|
+
const tmpLeftLabel = this._sideLabel('Left');
|
|
292
|
+
const tmpRightLabel = this._sideLabel('Right');
|
|
293
|
+
|
|
294
|
+
const tmpRecord =
|
|
295
|
+
{
|
|
296
|
+
Title: this.options.ScreenTitle || `Bulk-link ${tmpLeftLabel} & ${tmpRightLabel}`,
|
|
297
|
+
Subtitle: `Check ${tmpLeftLabel} on the left and ${tmpRightLabel} on the right, then link every selected ${this._singular(tmpLeftLabel)} to every selected ${this._singular(tmpRightLabel)}.`,
|
|
298
|
+
StatsID: `${this.safeKey}_Stats`,
|
|
299
|
+
LinkButtonID: `${this.safeKey}_Link`,
|
|
300
|
+
PickerMissing: !this.pict.EntityProvider,
|
|
301
|
+
Left: this._panelRecord('Left'),
|
|
302
|
+
Right: this._panelRecord('Right'),
|
|
303
|
+
// Initial stats (zero selected).
|
|
304
|
+
LeftCount: 0, RightCount: 0, PairCount: 0, PairPlural: 's', LeftLabel: tmpLeftLabel, RightLabel: tmpRightLabel,
|
|
305
|
+
};
|
|
306
|
+
|
|
307
|
+
return new Promise((resolve) =>
|
|
308
|
+
{
|
|
309
|
+
this.renderAsync(this.options.DefaultRenderable, this.options.DefaultDestinationAddress, tmpRecord,
|
|
310
|
+
(pError) =>
|
|
311
|
+
{
|
|
312
|
+
if (pError)
|
|
313
|
+
{
|
|
314
|
+
this.pict.log.error(`AssociateMatrix: render error.`, pError);
|
|
315
|
+
return resolve(false);
|
|
316
|
+
}
|
|
317
|
+
this.pict.CSSMap.injectCSS();
|
|
318
|
+
this.updateStats();
|
|
319
|
+
Promise.all([ this._fetchSide('Left'), this._fetchSide('Right') ]).then(() => resolve(true));
|
|
320
|
+
});
|
|
321
|
+
});
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
/** @param {'Left'|'Right'} pColumn @return {Record<string, any>} The panel shell record. */
|
|
325
|
+
_panelRecord(pColumn)
|
|
326
|
+
{
|
|
327
|
+
return {
|
|
328
|
+
Column: pColumn,
|
|
329
|
+
Label: this._sideLabel(pColumn),
|
|
330
|
+
CountID: `${this.safeKey}_${pColumn}_Count`,
|
|
331
|
+
TableID: `${this.safeKey}_${pColumn}_Table`,
|
|
332
|
+
ChooserID: `${this.safeKey}_${pColumn}_Chooser`,
|
|
333
|
+
};
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
/** The ColumnDataProvider persistence scope for a side (distinct from the List view's per-entity scope). */
|
|
337
|
+
_columnScope(pColumn)
|
|
338
|
+
{
|
|
339
|
+
return `Matrix_${this._associationHash}_${pColumn}`;
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
/** @return {any} The column-visibility persistence provider (localStorage; host-overridable). */
|
|
343
|
+
get columnProvider()
|
|
344
|
+
{
|
|
345
|
+
return this.pict.providers.ColumnDataProvider;
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
/**
|
|
349
|
+
* Effective visibility for a column: a stored user override wins, else the developer default
|
|
350
|
+
* (visible unless `DefaultHidden`).
|
|
351
|
+
* @param {'Left'|'Right'} pColumn @param {Record<string, any>} pCol
|
|
352
|
+
* @return {boolean}
|
|
353
|
+
*/
|
|
354
|
+
_isColumnVisible(pColumn, pCol)
|
|
355
|
+
{
|
|
356
|
+
const tmpOverrides = this.columnProvider ? this.columnProvider.getColumnVisibilityOverrides(this._columnScope(pColumn), 'Matrix') : {};
|
|
357
|
+
if (tmpOverrides && Object.prototype.hasOwnProperty.call(tmpOverrides, pCol.Key))
|
|
358
|
+
{
|
|
359
|
+
return tmpOverrides[pCol.Key] === true;
|
|
360
|
+
}
|
|
361
|
+
return !pCol.DefaultHidden;
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
/** @param {'Left'|'Right'} pColumn @return {Array<Record<string, any>>} The currently-visible columns. */
|
|
365
|
+
_visibleColumns(pColumn)
|
|
366
|
+
{
|
|
367
|
+
return this._side[pColumn].side.TableColumns.filter((pCol) => this._isColumnVisible(pColumn, pCol));
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
/** @param {'Left'|'Right'} pColumn @return {string} */
|
|
371
|
+
_sideLabel(pColumn)
|
|
372
|
+
{
|
|
373
|
+
const tmpSide = this._side[pColumn].side;
|
|
374
|
+
return tmpSide ? (tmpSide.Title || tmpSide.RecordSet || tmpSide.Entity) : '';
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
/**
|
|
378
|
+
* Fetch page 0 for a side (fresh search / first load), store the records, and render its table.
|
|
379
|
+
* @param {'Left'|'Right'} pColumn @return {Promise<void>}
|
|
380
|
+
*/
|
|
381
|
+
async _fetchSide(pColumn)
|
|
382
|
+
{
|
|
383
|
+
const tmpState = this._side[pColumn];
|
|
384
|
+
tmpState.cursor = 0;
|
|
385
|
+
const tmpResult = await this.manager.fetchSidePage(this._associationHash, tmpState.side.RecordSet, tmpState.search, 0, this.options.MatrixPageSize);
|
|
386
|
+
tmpState.records = tmpResult.records;
|
|
387
|
+
tmpState.hasMore = tmpResult.hasMore;
|
|
388
|
+
this._renderSideTable(pColumn);
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
/**
|
|
392
|
+
* Fetch + append the next page for a side ("Load more").
|
|
393
|
+
* @param {'Left'|'Right'} pColumn @return {Promise<void>}
|
|
394
|
+
*/
|
|
395
|
+
async loadMoreSide(pColumn)
|
|
396
|
+
{
|
|
397
|
+
const tmpState = this._side[pColumn];
|
|
398
|
+
tmpState.cursor += this.options.MatrixPageSize;
|
|
399
|
+
const tmpResult = await this.manager.fetchSidePage(this._associationHash, tmpState.side.RecordSet, tmpState.search, tmpState.cursor, this.options.MatrixPageSize);
|
|
400
|
+
tmpState.records = tmpState.records.concat(tmpResult.records);
|
|
401
|
+
tmpState.hasMore = tmpResult.hasMore;
|
|
402
|
+
this._renderSideTable(pColumn);
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
/**
|
|
406
|
+
* Debounced search for a side — re-fetches page 0. Only the table container repaints, so the search
|
|
407
|
+
* input keeps focus.
|
|
408
|
+
* @param {'Left'|'Right'} pColumn @param {string} pTerm
|
|
409
|
+
*/
|
|
410
|
+
searchSide(pColumn, pTerm)
|
|
411
|
+
{
|
|
412
|
+
this._side[pColumn].search = pTerm;
|
|
413
|
+
if (this._searchTimers[pColumn])
|
|
414
|
+
{
|
|
415
|
+
clearTimeout(this._searchTimers[pColumn]);
|
|
416
|
+
}
|
|
417
|
+
this._searchTimers[pColumn] = setTimeout(() => { this._fetchSide(pColumn); }, 250);
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
/**
|
|
421
|
+
* Build the row models for a side and render its table into the panel's table container.
|
|
422
|
+
* @param {'Left'|'Right'} pColumn
|
|
423
|
+
*/
|
|
424
|
+
_renderSideTable(pColumn)
|
|
425
|
+
{
|
|
426
|
+
const tmpState = this._side[pColumn];
|
|
427
|
+
const tmpColumns = this._visibleColumns(pColumn);
|
|
428
|
+
const tmpIDField = tmpState.side.IDField;
|
|
429
|
+
const tmpRows = tmpState.records.map((pRecord) =>
|
|
430
|
+
{
|
|
431
|
+
const tmpID = pRecord[tmpIDField];
|
|
432
|
+
return {
|
|
433
|
+
Column: pColumn,
|
|
434
|
+
Value: tmpID,
|
|
435
|
+
RowID: `${this.safeKey}_${pColumn}_row_${tmpID}`,
|
|
436
|
+
Selected: !!tmpState.selected[String(tmpID)],
|
|
437
|
+
Cells: tmpColumns.map((pCol) => ({ Value: pCol.Template ? this.pict.parseTemplate(pCol.Template, pRecord) : pRecord[pCol.Key] })),
|
|
438
|
+
};
|
|
439
|
+
});
|
|
440
|
+
const tmpTableRecord = {
|
|
441
|
+
Columns: tmpColumns.map((pCol) => ({ DisplayName: pCol.DisplayName })),
|
|
442
|
+
Rows: tmpRows,
|
|
443
|
+
// One-or-zero-element slot drives the empty-state line (TS parses inner tags; NE would not).
|
|
444
|
+
EmptySlot: (tmpRows.length === 0) ? [ { EmptyText: `No ${this._sideLabel(pColumn)} found.` } ] : [],
|
|
445
|
+
MoreSlot: tmpState.hasMore ? [ { Column: pColumn } ] : [],
|
|
446
|
+
};
|
|
447
|
+
const tmpHTML = this.pict.parseTemplateByHash('PRSP-AssociateMatrix-Table', tmpTableRecord);
|
|
448
|
+
this.pict.ContentAssignment.assignContent(`#${this.safeKey}_${pColumn}_Table`, tmpHTML);
|
|
449
|
+
this.pict.ContentAssignment.assignContent(`#${this.safeKey}_${pColumn}_Count`, this._selectedCount(pColumn) ? `${this._selectedCount(pColumn)} selected` : '');
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
/**
|
|
453
|
+
* Open/close a side's column chooser popover (the "Columns" button). Re-renders its rows on open.
|
|
454
|
+
* @param {'Left'|'Right'} pColumn
|
|
455
|
+
*/
|
|
456
|
+
toggleColumnChooser(pColumn)
|
|
457
|
+
{
|
|
458
|
+
const tmpWrapElements = this.pict.ContentAssignment.getElement(`#${this.safeKey}_${pColumn}_Chooser_Wrap`);
|
|
459
|
+
if (!tmpWrapElements || tmpWrapElements.length < 1)
|
|
460
|
+
{
|
|
461
|
+
return;
|
|
462
|
+
}
|
|
463
|
+
if (tmpWrapElements[0].classList.contains('is-open'))
|
|
464
|
+
{
|
|
465
|
+
tmpWrapElements[0].classList.remove('is-open');
|
|
466
|
+
return;
|
|
467
|
+
}
|
|
468
|
+
this._renderColumnChooser(pColumn);
|
|
469
|
+
tmpWrapElements[0].classList.add('is-open');
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
/** Close a side's column chooser (the backdrop's outside-click handler). @param {'Left'|'Right'} pColumn */
|
|
473
|
+
closeColumnChooser(pColumn)
|
|
474
|
+
{
|
|
475
|
+
const tmpWrapElements = this.pict.ContentAssignment.getElement(`#${this.safeKey}_${pColumn}_Chooser_Wrap`);
|
|
476
|
+
if (tmpWrapElements && tmpWrapElements.length > 0)
|
|
477
|
+
{
|
|
478
|
+
tmpWrapElements[0].classList.remove('is-open');
|
|
479
|
+
}
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
/** Render a side's column-chooser rows (every declared column, checked when effectively visible). */
|
|
483
|
+
_renderColumnChooser(pColumn)
|
|
484
|
+
{
|
|
485
|
+
const tmpRows = this._side[pColumn].side.TableColumns.map((pCol) => (
|
|
486
|
+
{
|
|
487
|
+
Column: pColumn,
|
|
488
|
+
Key: pCol.Key,
|
|
489
|
+
DisplayName: pCol.DisplayName,
|
|
490
|
+
Visible: this._isColumnVisible(pColumn, pCol),
|
|
491
|
+
}));
|
|
492
|
+
const tmpHTML = this.pict.parseTemplateByHash('PRSP-AssociateMatrix-Chooser', { Column: pColumn, Rows: tmpRows });
|
|
493
|
+
this.pict.ContentAssignment.assignContent(`#${this.safeKey}_${pColumn}_Chooser`, tmpHTML);
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
/**
|
|
497
|
+
* Toggle one column's visibility — persisted through the ColumnDataProvider (localStorage; a host can
|
|
498
|
+
* register its own provider for server-side prefs). Refuses to hide the last visible column.
|
|
499
|
+
* @param {'Left'|'Right'} pColumn @param {string} pKey
|
|
500
|
+
*/
|
|
501
|
+
toggleColumn(pColumn, pKey)
|
|
502
|
+
{
|
|
503
|
+
if (!this.columnProvider)
|
|
504
|
+
{
|
|
505
|
+
return;
|
|
506
|
+
}
|
|
507
|
+
const tmpCol = this._side[pColumn].side.TableColumns.find((pCol) => String(pCol.Key) === String(pKey));
|
|
508
|
+
if (!tmpCol)
|
|
509
|
+
{
|
|
510
|
+
return;
|
|
511
|
+
}
|
|
512
|
+
const tmpCurrentlyVisible = this._isColumnVisible(pColumn, tmpCol);
|
|
513
|
+
if (tmpCurrentlyVisible && this._visibleColumns(pColumn).length <= 1)
|
|
514
|
+
{
|
|
515
|
+
return;
|
|
516
|
+
}
|
|
517
|
+
this.columnProvider.setColumnVisibilityOverride(this._columnScope(pColumn), 'Matrix', pKey, !tmpCurrentlyVisible);
|
|
518
|
+
this._renderSideTable(pColumn);
|
|
519
|
+
this._renderColumnChooser(pColumn);
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
/** Clear a side's column overrides — back to the developer defaults. */
|
|
523
|
+
resetColumns(pColumn)
|
|
524
|
+
{
|
|
525
|
+
if (this.columnProvider)
|
|
526
|
+
{
|
|
527
|
+
this.columnProvider.clearColumnVisibilityOverrides(this._columnScope(pColumn), 'Matrix');
|
|
528
|
+
}
|
|
529
|
+
this._renderSideTable(pColumn);
|
|
530
|
+
this._renderColumnChooser(pColumn);
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
/**
|
|
534
|
+
* Toggle a row's selection (the row click). Updates the selection set + the row's visual state + the
|
|
535
|
+
* stats, without re-rendering the whole table (so scroll + focus are preserved).
|
|
536
|
+
* @param {'Left'|'Right'} pColumn @param {string|number} pValue
|
|
537
|
+
*/
|
|
538
|
+
toggleRow(pColumn, pValue)
|
|
539
|
+
{
|
|
540
|
+
const tmpState = this._side[pColumn];
|
|
541
|
+
const tmpKey = String(pValue);
|
|
542
|
+
if (tmpState.selected[tmpKey])
|
|
543
|
+
{
|
|
544
|
+
delete tmpState.selected[tmpKey];
|
|
545
|
+
}
|
|
546
|
+
else
|
|
547
|
+
{
|
|
548
|
+
tmpState.selected[tmpKey] = true;
|
|
549
|
+
}
|
|
550
|
+
const tmpRowElements = this.pict.ContentAssignment.getElement(`#${this.safeKey}_${pColumn}_row_${pValue}`);
|
|
551
|
+
if (tmpRowElements && tmpRowElements.length > 0)
|
|
552
|
+
{
|
|
553
|
+
tmpRowElements[0].classList.toggle('is-selected', !!tmpState.selected[tmpKey]);
|
|
554
|
+
}
|
|
555
|
+
this.pict.ContentAssignment.assignContent(`#${this.safeKey}_${pColumn}_Count`, this._selectedCount(pColumn) ? `${this._selectedCount(pColumn)} selected` : '');
|
|
556
|
+
this.updateStats();
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
/** @param {'Left'|'Right'} pColumn @return {number} */
|
|
560
|
+
_selectedCount(pColumn)
|
|
561
|
+
{
|
|
562
|
+
return Object.keys(this._side[pColumn].selected).length;
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
/** Recompute the live stats line + the link button's enabled state. */
|
|
566
|
+
updateStats()
|
|
567
|
+
{
|
|
568
|
+
const tmpLeft = this._selectedCount('Left');
|
|
569
|
+
const tmpRight = this._selectedCount('Right');
|
|
570
|
+
const tmpPairs = tmpLeft * tmpRight;
|
|
571
|
+
const tmpStatsHTML = this.pict.parseTemplateByHash('PRSP-AssociateMatrix-Stats',
|
|
572
|
+
{ LeftCount: tmpLeft, RightCount: tmpRight, PairCount: tmpPairs, PairPlural: (tmpPairs === 1) ? '' : 's', LeftLabel: this._sideLabel('Left'), RightLabel: this._sideLabel('Right') });
|
|
573
|
+
this.pict.ContentAssignment.assignContent(`#${this.safeKey}_Stats`, tmpStatsHTML);
|
|
574
|
+
|
|
575
|
+
const tmpButtonElements = this.pict.ContentAssignment.getElement(`#${this.safeKey}_Link`);
|
|
576
|
+
if (tmpButtonElements && tmpButtonElements.length > 0)
|
|
577
|
+
{
|
|
578
|
+
tmpButtonElements[0].disabled = (tmpPairs < 1);
|
|
579
|
+
}
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
/**
|
|
583
|
+
* Create the cross-product of joins for the checked rows — every left record linked to every right
|
|
584
|
+
* record — skipping pairs that already exist (one INN lookup). Confirms first for large sets.
|
|
585
|
+
* @return {Promise<void>}
|
|
586
|
+
*/
|
|
587
|
+
async associateMatrix()
|
|
588
|
+
{
|
|
589
|
+
const tmpLeftIDs = Object.keys(this._side.Left.selected);
|
|
590
|
+
const tmpRightIDs = Object.keys(this._side.Right.selected);
|
|
591
|
+
if (tmpLeftIDs.length < 1 || tmpRightIDs.length < 1)
|
|
592
|
+
{
|
|
593
|
+
this._toast('Check records on both sides first.', 'info');
|
|
594
|
+
return;
|
|
595
|
+
}
|
|
596
|
+
const tmpTotal = tmpLeftIDs.length * tmpRightIDs.length;
|
|
597
|
+
const tmpLeftSide = this._side.Left.side;
|
|
598
|
+
const tmpRightSide = this._side.Right.side;
|
|
599
|
+
|
|
600
|
+
const tmpModal = this.pict.views['Pict-Section-Modal'];
|
|
601
|
+
if (tmpTotal > 100 && tmpModal && typeof tmpModal.confirm === 'function')
|
|
602
|
+
{
|
|
603
|
+
const tmpOk = await tmpModal.confirm(`This will create up to ${tmpTotal} links. Continue?`,
|
|
604
|
+
{ title: 'Bulk link', confirmLabel: `Link ${tmpTotal}`, cancelLabel: 'Cancel' });
|
|
605
|
+
if (!tmpOk)
|
|
606
|
+
{
|
|
607
|
+
return;
|
|
608
|
+
}
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
// Existing pairs (one INN lookup over the checked left ids) so we never duplicate a join.
|
|
612
|
+
const tmpExisting = await this.manager.listJoinRecordsForIDs(this._associationHash, tmpLeftSide.RecordSet, tmpLeftIDs);
|
|
613
|
+
const tmpExistingSet = {};
|
|
614
|
+
for (let i = 0; i < tmpExisting.length; i++)
|
|
615
|
+
{
|
|
616
|
+
tmpExistingSet[`${tmpExisting[i][tmpLeftSide.IDField]}|${tmpExisting[i][tmpRightSide.IDField]}`] = true;
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
let tmpCreated = 0;
|
|
620
|
+
let tmpSkipped = 0;
|
|
621
|
+
let tmpFailed = 0;
|
|
622
|
+
for (let i = 0; i < tmpLeftIDs.length; i++)
|
|
623
|
+
{
|
|
624
|
+
for (let j = 0; j < tmpRightIDs.length; j++)
|
|
625
|
+
{
|
|
626
|
+
if (tmpExistingSet[`${tmpLeftIDs[i]}|${tmpRightIDs[j]}`])
|
|
627
|
+
{
|
|
628
|
+
tmpSkipped++;
|
|
629
|
+
continue;
|
|
630
|
+
}
|
|
631
|
+
try
|
|
632
|
+
{
|
|
633
|
+
await this.manager.createJoin(this._associationHash, tmpLeftSide.RecordSet, tmpLeftIDs[i], tmpRightIDs[j]);
|
|
634
|
+
tmpCreated++;
|
|
635
|
+
}
|
|
636
|
+
catch (pError)
|
|
637
|
+
{
|
|
638
|
+
tmpFailed++;
|
|
639
|
+
this.pict.log.error(`AssociateMatrix: failed to link ${tmpLeftIDs[i]} <-> ${tmpRightIDs[j]}.`, pError);
|
|
640
|
+
}
|
|
641
|
+
}
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
const tmpParts = [];
|
|
645
|
+
if (tmpCreated > 0) { tmpParts.push(`${tmpCreated} link${tmpCreated === 1 ? '' : 's'} created`); }
|
|
646
|
+
if (tmpSkipped > 0) { tmpParts.push(`${tmpSkipped} already existed`); }
|
|
647
|
+
if (tmpFailed > 0) { tmpParts.push(`${tmpFailed} failed`); }
|
|
648
|
+
this._toast(tmpParts.length ? tmpParts.join(', ') + '.' : 'Nothing to link.', tmpFailed > 0 ? 'error' : 'success');
|
|
649
|
+
|
|
650
|
+
// Clear both selections and repaint the tables so the next batch starts clean.
|
|
651
|
+
this._side.Left.selected = {};
|
|
652
|
+
this._side.Right.selected = {};
|
|
653
|
+
this._renderSideTable('Left');
|
|
654
|
+
this._renderSideTable('Right');
|
|
655
|
+
this.updateStats();
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
/** Crude singularizer for the subtitle copy ("Books" -> "Book"). */
|
|
659
|
+
_singular(pLabel)
|
|
660
|
+
{
|
|
661
|
+
return (typeof pLabel === 'string' && pLabel.length > 1 && pLabel.slice(-1).toLowerCase() === 's') ? pLabel.slice(0, -1) : pLabel;
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
/**
|
|
665
|
+
* Non-blocking notification via the host modal's toast, when available.
|
|
666
|
+
* @param {string} pMessage @param {string} pType
|
|
667
|
+
*/
|
|
668
|
+
_toast(pMessage, pType)
|
|
669
|
+
{
|
|
670
|
+
const tmpModal = this.pict.views['Pict-Section-Modal'];
|
|
671
|
+
if (tmpModal && typeof tmpModal.toast === 'function')
|
|
672
|
+
{
|
|
673
|
+
tmpModal.toast(pMessage, { type: pType || 'info' });
|
|
674
|
+
}
|
|
675
|
+
}
|
|
676
|
+
}
|
|
677
|
+
|
|
678
|
+
module.exports = viewRecordSetAssociateMatrix;
|
|
679
|
+
|
|
680
|
+
module.exports.default_configuration = _DEFAULT_CONFIGURATION_AssociateMatrix;
|