pict-section-picker 1.0.0 → 1.2.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 CHANGED
@@ -117,6 +117,37 @@ Entity-source configuration:
117
117
 
118
118
  The lower-level builders are also exposed: `createEntityDataProvider(cfg)` and `createEntityResolveValue(cfg)` return the raw functions if you want to wire them yourself.
119
119
 
120
+ ## Joined display (parent-entity context)
121
+
122
+ Sometimes a searched entity is ambiguous on its own — a `LineItem` only makes sense next to its `Project`, a `Review` next to its `Book`. Set `JoinEntity` and the picker renders a **compound** label by joining each searched row to a parent entity through a foreign key the row carries:
123
+
124
+ ```javascript
125
+ tmpPicker.createEntityPicker('ReviewPicker',
126
+ {
127
+ Entity: 'Review',
128
+ SearchFields: [ 'Summary' ],
129
+ JoinEntity: 'Book', // the parent entity to join
130
+ JoinField: 'IDBook', // the FK on the Review row -> Book
131
+ JoinEntityDisplayField: 'Title', // the Book field to show
132
+ DestinationAddress: '#ReviewPicker',
133
+ ValueAddress: 'AppData.Form.IDReview',
134
+ });
135
+ // options render as "Neuromancer - Loved it"; the Value is still IDReview.
136
+ ```
137
+
138
+ Meadow can't join in a single read, so this is **fetch-then-merge**: after each search page the picker collects the rows' unique FK ids and issues **one** `FBL~ID{JoinEntity}~INN~<ids>` request, then stitches the joined display onto every row (also exposed as `Record.JoinName` / `Record.JoinRecord` for `MapRecord` / templates). The same join resolves a pre-bound value's label on first render.
139
+
140
+ | Option | Default | Purpose |
141
+ |---|---|---|
142
+ | `JoinEntity` | — | Parent entity to join for the compound display. Setting it enables the feature. |
143
+ | `JoinField` | `ID<JoinEntity>` | The FK column **on the searched row** pointing at `JoinEntity`. |
144
+ | `JoinEntityValueField` | `ID<JoinEntity>` | The PK column on `JoinEntity` to match (the `INN` column). |
145
+ | `JoinEntityDisplayField` | `'Name'` | The `JoinEntity` field shown in the compound label. |
146
+ | `JoinEntityFirst` | `true` | `true` → `Parent - Row`; `false` → `Row - Parent`. |
147
+ | `JoinSeparator` | `' - '` | Separator between the two parts. |
148
+
149
+ The same options ride through the form-input adapter (`PictForm.JoinEntity`, …) and the pict-section-recordset entity filters (set `JoinEntity` on the clause) — so an entity filter can show parent context for its options with no host code, layered on top of either the 1:1 (direct-FK / `InternalJoin`) or 1:many (junction / `ExternalJoin`) filter relationship.
150
+
120
151
  ## Categories
121
152
 
122
153
  Give option rows an optional `Group` field and the list renders headered sections (preserving order; rows without a `Group` fall into a leading unlabeled section):
@@ -162,6 +193,24 @@ OnCreate: (pTerm) =>
162
193
  | `OnCreate` | — | `(term) => {Value, Text}` to enable creatable entries. |
163
194
  | `OnChange` | — | Called after a selection: single → `(value, record)`, multi → `(values, records)`. |
164
195
 
196
+ ## View methods
197
+
198
+ Call these on the picker view instance — `this.pict.views['<hash>']`:
199
+
200
+ | Method | Description |
201
+ |--------|-------------|
202
+ | `render()` | Paint (or repaint) the control into its destination. |
203
+ | `getValue()` | The current selection — a scalar in single mode, an array of values in multi mode. |
204
+ | `setValue(pValue)` | Set the selection programmatically — the supported counterpart to `getValue()`. Single mode takes a scalar; multi mode takes an array (or a csv string). Writes through to the bound address(es), resolves the display label of any unknown value (from the loaded options, else via `ResolveValue` in async mode), and repaints. Does **not** fire `OnChange` — it is a programmatic set (e.g. a host marshaling a form value into the control), not a user pick. Returns the view for chaining. |
205
+ | `getSelectedRecords()` | (multi) The full `{Value, Text}` record list for the current selection. |
206
+
207
+ ```javascript
208
+ const tmpPicker = this.pict.views['AuthorPicker'];
209
+ tmpPicker.setValue(141); // single: select author 141 (label resolves via ResolveValue if async)
210
+ tmpPicker.setValue([ 2, 10, 141 ]); // multi: select these values (array or "2,10,141" csv both accepted)
211
+ const tmpSelected = tmpPicker.getValue();
212
+ ```
213
+
165
214
  ## Theming
166
215
 
167
216
  The widget paints from `--theme-color-*` tokens with sensible hex fallbacks, so it inherits the host app's theme. Relevant tokens: `--theme-color-brand-primary`, `--theme-color-text-primary`, `--theme-color-text-muted`, `--theme-color-border-default`, `--theme-color-border-light`, `--theme-color-border-strong`, `--theme-color-background-primary`, `--theme-color-background-panel`, `--theme-color-background-tertiary`.
package/form.js ADDED
@@ -0,0 +1,5 @@
1
+ // Sub-path entry: `require('pict-section-picker/form')`.
2
+ //
3
+ // The pict-section-form input-type adapter — kept off the package's main entry so the picker core
4
+ // stays usable without pict-section-form (which is an OPTIONAL peer dependency, required only here).
5
+ module.exports = require('./source/form/Pict-Section-Picker-FormInput.js');
package/package.json CHANGED
@@ -1,12 +1,13 @@
1
1
  {
2
2
  "name": "pict-section-picker",
3
- "version": "1.0.0",
3
+ "version": "1.2.0",
4
4
  "description": "Pict-native themeable searchable select / combobox — single & multi select, server pagination, categorized groups and creatable entries, driven by a host-agnostic async DataProvider (with a built-in Meadow EntityProvider adapter). A jQuery/select2-free replacement.",
5
5
  "main": "source/Pict-Section-Picker.js",
6
6
  "types": "types/Pict-Section-Picker.d.ts",
7
7
  "files": [
8
8
  "source",
9
- "types"
9
+ "types",
10
+ "form.js"
10
11
  ],
11
12
  "scripts": {
12
13
  "test": "npx mocha -u tdd -R spec",
@@ -48,11 +49,20 @@
48
49
  "pict-provider": "^1.0.13",
49
50
  "pict-view": "^1.0.68"
50
51
  },
52
+ "peerDependencies": {
53
+ "pict-section-form": ">=1.0.0"
54
+ },
55
+ "peerDependenciesMeta": {
56
+ "pict-section-form": {
57
+ "optional": true
58
+ }
59
+ },
51
60
  "devDependencies": {
52
61
  "@types/mocha": "^10.0.10",
53
62
  "@types/node": "^16.18.126",
54
63
  "browser-env": "^3.3.0",
55
64
  "pict": "^1.0.372",
65
+ "pict-section-form": "^1.0.0",
56
66
  "quackage": "^1.3.0",
57
67
  "typescript": "^5.9.3"
58
68
  }
@@ -0,0 +1,307 @@
1
+ /**
2
+ * pict-section-form input-type adapter for pict-section-picker.
3
+ *
4
+ * Renders a picker widget into a dynamic-form cell — the host-agnostic replacement for a select2
5
+ * entity input. Registered as a pict-section-form InputType (default name `Picker`); a host opts a
6
+ * field in with `PictForm.InputType: 'Picker'` (+ Entity / SearchFields / Multiple / …). Used by the
7
+ * pict-section-recordset entity filters and by document forms alike.
8
+ *
9
+ * Contextual scoping (project / spec-year / tenant / …) stays host configuration: the descriptor may
10
+ * carry a `PictForm.GetContextScopeFilter()` hook, OR a host subclass overrides
11
+ * `getContextualSearchFilters(pInput)`. Either way the module never learns what the context means.
12
+ *
13
+ * Requires `pict-section-form` (an OPTIONAL peer dependency — only loaded when you require this file)
14
+ * and the `Pict-Section-Picker` provider registered on the pict instance.
15
+ */
16
+ const libPictInputExtension = require('pict-section-form').PictInputExtensionProvider;
17
+
18
+ /** @type {Record<string, any>} */
19
+ const _DEFAULT_CONFIGURATION =
20
+ {
21
+ ProviderIdentifier: 'Pict-Input-Picker',
22
+ AutoInitialize: true,
23
+ AutoInitializeOrdinal: 0,
24
+ AutoSolveWithApp: false,
25
+ };
26
+
27
+ /**
28
+ * Build the InputType metatemplate entries (a hidden informary input + a host element the picker
29
+ * renders into) for a given InputType name + provider hash. Injected via injectTemplateSet.
30
+ *
31
+ * @param {string} pInputTypeName - e.g. 'Picker'.
32
+ * @param {string} pProviderHash - the input-extension provider service hash to auto-attach.
33
+ * @return {Array<Record<string, any>>}
34
+ */
35
+ const buildPickerInputTemplates = (pInputTypeName, pProviderHash) =>
36
+ [
37
+ {
38
+ // Mirror the host's DEFAULT input metatemplate structure exactly — a label span + the control
39
+ // where the <input>/<select> would be — so the picker inherits the host's filter/form chrome
40
+ // (label style, spacing, and the row's flex-end trash alignment) instead of inventing its own.
41
+ HashPostfix: `-Template-Input-InputType-${pInputTypeName}`,
42
+ DefaultInputExtensions: [ pProviderHash ],
43
+ Template: /*html*/`
44
+ <!-- InputType ${pInputTypeName} {~D:Record.Hash~} {~D:Record.DataType~} -->
45
+ <input type="hidden" id="{~D:Record.Macro.RawHTMLID~}" tabindex="-1" {~D:Record.Macro.InputFullProperties~} {~D:Record.Macro.InputChangeHandler~} value="" />
46
+ <span>{~D:Record.Name~}:</span> <div class="pps-form-host" id="PICKER-FOR-{~D:Record.Macro.RawHTMLID~}"></div>`,
47
+ },
48
+ {
49
+ HashPostfix: `-VerticalTemplate-Input-InputType-${pInputTypeName}`,
50
+ DefaultInputExtensions: [ pProviderHash ],
51
+ Template: /*html*/`
52
+ <!-- InputType ${pInputTypeName} {~D:Record.Hash~} {~D:Record.DataType~} -->
53
+ <input type="hidden" id="{~D:Record.Macro.RawHTMLID~}" tabindex="-1" {~D:Record.Macro.InputFullProperties~} {~D:Record.Macro.InputChangeHandler~} value="" />
54
+ <span>{~D:Record.Name~}:</span> <div class="pps-form-host" id="PICKER-FOR-{~D:Record.Macro.RawHTMLID~}"></div>`,
55
+ },
56
+ {
57
+ HashPostfix: `-TabularTemplate-Begin-Input-InputType-${pInputTypeName}`,
58
+ DefaultInputExtensions: [ pProviderHash ],
59
+ Template: /*html*/`
60
+ <input type="hidden" id="PICKER-TABULAR-DATA-{~D:Record.Macro.RawHTMLID~}-{~D:Context[2].Key~}" tabindex="-1" {~D:Record.Macro.InformaryTabular~} `,
61
+ },
62
+ {
63
+ HashPostfix: `-TabularTemplate-End-Input-InputType-${pInputTypeName}`,
64
+ DefaultInputExtensions: [ pProviderHash ],
65
+ Template: /*html*/`
66
+ value="" />
67
+ <div class="pps-form-host" id="PICKER-TABULAR-{~D:Record.Macro.RawHTMLID~}-{~D:Context[2].Key~}"></div>`,
68
+ },
69
+ ];
70
+
71
+ class PictInputTypePicker extends libPictInputExtension
72
+ {
73
+ constructor(pFable, pOptions, pServiceHash)
74
+ {
75
+ super(pFable, Object.assign({}, _DEFAULT_CONFIGURATION, pOptions), pServiceHash);
76
+ /** @type {any} */
77
+ this.pict;
78
+ }
79
+
80
+ // Visible host + picker-view-hash ids. Must match the metatemplate element ids above.
81
+ getPickerHostID(pRawHTMLID) { return `#PICKER-FOR-${pRawHTMLID}`; }
82
+ getPickerHash(pRawHTMLID) { return `Picker-${pRawHTMLID}`; }
83
+ getTabularPickerHostID(pRawHTMLID, pRowIndex) { return `#PICKER-TABULAR-${pRawHTMLID}-${pRowIndex}`; }
84
+ getTabularPickerHash(pRawHTMLID, pRowIndex) { return `Picker-${pRawHTMLID}-${pRowIndex}`; }
85
+ getTabularHiddenID(pRawHTMLID, pRowIndex) { return `#PICKER-TABULAR-DATA-${pRawHTMLID}-${pRowIndex}`; }
86
+
87
+ /**
88
+ * Overridable: extra FoxHound scope stanza(s) AND-applied to the entity search. Default reads the
89
+ * descriptor's `GetContextScopeFilter()` hook (set by the host / recordset filter base), else its
90
+ * static `BaseFilter`. Host subclasses override this to read app state (project / spec-year / …).
91
+ *
92
+ * @param {Record<string, any>} pInput @return {string|Array<string>}
93
+ */
94
+ getContextualSearchFilters(pInput)
95
+ {
96
+ const tmpHook = pInput && pInput.PictForm && pInput.PictForm.GetContextScopeFilter;
97
+ if (typeof tmpHook === 'function')
98
+ {
99
+ try { return tmpHook() || ''; }
100
+ catch (pError) { this.pict.log.warn(`Pict-Input-Picker: GetContextScopeFilter threw.`, pError); return ''; }
101
+ }
102
+ return (pInput && pInput.PictForm && pInput.PictForm.BaseFilter) || '';
103
+ }
104
+
105
+ /** Build the picker config from a form input descriptor. */
106
+ _buildPickerConfig(pInput, pHostSelector, fOnChange)
107
+ {
108
+ const tmpPF = pInput.PictForm || {};
109
+ return {
110
+ DestinationAddress: pHostSelector,
111
+ Mode: tmpPF.Multiple ? 'multi' : 'single',
112
+ Placeholder: tmpPF.Placeholder || (tmpPF.Entity ? `Select ${tmpPF.Entity}…` : 'Select…'),
113
+ Searchable: (tmpPF.Searchable !== false),
114
+ Entity: tmpPF.Entity,
115
+ SearchFields: tmpPF.SearchFields,
116
+ ValueField: tmpPF.ValueField,
117
+ TextField: tmpPF.TextField,
118
+ PageSize: tmpPF.PageSize || 20,
119
+ Options: tmpPF.Options || [],
120
+ // JoinEntity compound display (1:1 / 1:many parent-entity context) — passed straight through
121
+ // to the picker's entity adapter, which fetch-then-merges the join. No-op when JoinEntity unset.
122
+ JoinEntity: tmpPF.JoinEntity,
123
+ JoinField: tmpPF.JoinField,
124
+ JoinEntityValueField: tmpPF.JoinEntityValueField,
125
+ JoinEntityDisplayField: tmpPF.JoinEntityDisplayField,
126
+ JoinEntityFirst: tmpPF.JoinEntityFirst,
127
+ JoinSeparator: tmpPF.JoinSeparator,
128
+ // Per-search contextual scope — the generic hook the host fills.
129
+ BaseFilter: () => this.getContextualSearchFilters(pInput),
130
+ OnChange: fOnChange,
131
+ };
132
+ }
133
+
134
+ /** Instantiate (or reuse) the picker view for a config — entity-backed when Entity is set. */
135
+ _instantiatePicker(pPickerHash, pConfig)
136
+ {
137
+ const tmpProvider = this.pict.providers['Pict-Section-Picker'];
138
+ if (!tmpProvider)
139
+ {
140
+ this.pict.log.warn('Pict-Input-Picker: the Pict-Section-Picker provider is not registered.');
141
+ return null;
142
+ }
143
+ return pConfig.Entity
144
+ ? tmpProvider.createEntityPicker(pPickerHash, pConfig)
145
+ : tmpProvider.createPicker(pPickerHash, pConfig);
146
+ }
147
+
148
+ /**
149
+ * Write a picker value into the form: csv to the hidden informary input (+ dataChanged), plus the
150
+ * raw array to `PictForm.ValueArrayAddress` when set (the recordset filter reads Values as an
151
+ * array). The csv-vs-array bridge lives HERE (generic) instead of in each host.
152
+ */
153
+ _commit(pView, pInput, pValue, pHTMLSelector)
154
+ {
155
+ const tmpCSV = Array.isArray(pValue) ? pValue.join(',') : (pValue === undefined || pValue === null ? '' : pValue);
156
+ this.pict.ContentAssignment.assignContent(pHTMLSelector, tmpCSV);
157
+ if (pInput.PictForm && pInput.PictForm.ValueArrayAddress && pView.Bundle)
158
+ {
159
+ const tmpArray = Array.isArray(pValue) ? pValue : (tmpCSV === '' ? [] : String(tmpCSV).split(','));
160
+ this.pict.manifest.setValueAtAddress(pView.Bundle, pInput.PictForm.ValueArrayAddress, tmpArray);
161
+ }
162
+ pView.dataChanged(pInput.Hash);
163
+ }
164
+
165
+ _commitTabular(pView, pInput, pValue, pHiddenID, pRowIndex)
166
+ {
167
+ const tmpCSV = Array.isArray(pValue) ? pValue.join(',') : (pValue === undefined || pValue === null ? '' : pValue);
168
+ this.pict.ContentAssignment.assignContent(pHiddenID, tmpCSV);
169
+ pView.dataChangedTabular(pInput.PictForm.GroupIndex, pInput.PictForm.InputIndex, pRowIndex);
170
+ }
171
+
172
+ /**
173
+ * Idempotently mount (or reuse) the picker into its host element + seed its value. Called from both
174
+ * onInputInitialize and onDataMarshalToForm because, in the async-virtual filter render, the host
175
+ * element only exists in the real DOM by the marshal pass — whichever hook fires post-DOM wins, and
176
+ * re-calls are harmless (the picker view is reused by hash).
177
+ * @return {boolean} true if the picker is mounted.
178
+ */
179
+ _mountPicker(pView, pInput, pValue, pHostSelector, pPickerHash, fOnChange)
180
+ {
181
+ if (!this.pict.ContentAssignment.getElement(pHostSelector)?.[0]) { return false; }
182
+ const tmpView = this._instantiatePicker(pPickerHash, this._buildPickerConfig(pInput, pHostSelector, fOnChange));
183
+ if (!tmpView) { return false; }
184
+ tmpView.render();
185
+ tmpView.setValue(pValue);
186
+ return true;
187
+ }
188
+
189
+ // --- non-tabular lifecycle ---
190
+
191
+ onInputInitialize(pView, pGroup, pRow, pInput, pValue, pHTMLSelector, pTransactionGUID)
192
+ {
193
+ const tmpRaw = pInput.Macro.RawHTMLID;
194
+ this._mountPicker(pView, pInput, pValue, this.getPickerHostID(tmpRaw), this.getPickerHash(tmpRaw),
195
+ (pNewValue) => this._commit(pView, pInput, pNewValue, pInput.Macro.HTMLSelector));
196
+ return super.onInputInitialize(pView, pGroup, pRow, pInput, pValue, pHTMLSelector, pTransactionGUID);
197
+ }
198
+
199
+ onDataMarshalToForm(pView, pGroup, pRow, pInput, pValue, pHTMLSelector, pTransactionGUID)
200
+ {
201
+ const tmpRaw = pInput.Macro.RawHTMLID;
202
+ const tmpPickerHash = this.getPickerHash(tmpRaw);
203
+ // Mount if it isn't already (post-DOM hook), else just re-seed the value.
204
+ if (!this._mountPicker(pView, pInput, pValue, this.getPickerHostID(tmpRaw), tmpPickerHash,
205
+ (pNewValue) => this._commit(pView, pInput, pNewValue, pInput.Macro.HTMLSelector)))
206
+ {
207
+ const tmpView = this.pict.views[tmpPickerHash];
208
+ if (tmpView) { tmpView.setValue(pValue); }
209
+ }
210
+ return super.onDataMarshalToForm(pView, pGroup, pRow, pInput, pValue, pHTMLSelector, pTransactionGUID);
211
+ }
212
+
213
+ onDataRequest(pView, pInput, pValue, pHTMLSelector)
214
+ {
215
+ const tmpView = this.pict.views[this.getPickerHash(pInput.Macro.RawHTMLID)];
216
+ const tmpVal = tmpView ? tmpView.getValue() : pValue;
217
+ this._commit(pView, pInput, tmpVal, pHTMLSelector);
218
+ return super.onDataRequest(pView, pInput, tmpVal, pHTMLSelector);
219
+ }
220
+
221
+ // --- tabular lifecycle (one picker view instance per (input, row)) ---
222
+
223
+ /** Idempotent tabular mount (see _mountPicker). @return {boolean} */
224
+ _mountPickerTabular(pView, pInput, pValue, pRowIndex)
225
+ {
226
+ const tmpRaw = pInput.Macro.RawHTMLID;
227
+ const tmpHostSelector = this.getTabularPickerHostID(tmpRaw, pRowIndex);
228
+ if (!this.pict.ContentAssignment.getElement(tmpHostSelector)?.[0]) { return false; }
229
+ const tmpHiddenID = this.getTabularHiddenID(tmpRaw, pRowIndex);
230
+ const tmpView = this._instantiatePicker(this.getTabularPickerHash(tmpRaw, pRowIndex),
231
+ this._buildPickerConfig(pInput, tmpHostSelector, (pNewValue) => this._commitTabular(pView, pInput, pNewValue, tmpHiddenID, pRowIndex)));
232
+ if (!tmpView) { return false; }
233
+ tmpView.render();
234
+ tmpView.setValue(pValue);
235
+ return true;
236
+ }
237
+
238
+ onInputInitializeTabular(pView, pGroup, pInput, pValue, pHTMLSelector, pRowIndex, pTransactionGUID)
239
+ {
240
+ this._mountPickerTabular(pView, pInput, pValue, pRowIndex);
241
+ return super.onInputInitializeTabular(pView, pGroup, pInput, pValue, pHTMLSelector, pRowIndex, pTransactionGUID);
242
+ }
243
+
244
+ onDataMarshalToFormTabular(pView, pGroup, pInput, pValue, pHTMLSelector, pRowIndex, pTransactionGUID)
245
+ {
246
+ if (!this._mountPickerTabular(pView, pInput, pValue, pRowIndex))
247
+ {
248
+ const tmpView = this.pict.views[this.getTabularPickerHash(pInput.Macro.RawHTMLID, pRowIndex)];
249
+ if (tmpView) { tmpView.setValue(pValue); }
250
+ }
251
+ return super.onDataMarshalToFormTabular(pView, pGroup, pInput, pValue, pHTMLSelector, pRowIndex, pTransactionGUID);
252
+ }
253
+
254
+ onDataRequestTabular(pView, pInput, pValue, pHTMLSelector, pRowIndex)
255
+ {
256
+ const tmpView = this.pict.views[this.getTabularPickerHash(pInput.Macro.RawHTMLID, pRowIndex)];
257
+ const tmpVal = tmpView ? tmpView.getValue() : pValue;
258
+ this._commitTabular(pView, pInput, tmpVal, this.getTabularHiddenID(pInput.Macro.RawHTMLID, pRowIndex), pRowIndex);
259
+ return super.onDataRequestTabular(pView, pInput, tmpVal, pHTMLSelector, pRowIndex);
260
+ }
261
+ }
262
+
263
+ /**
264
+ * Register the Picker InputType on a pict instance: the input-extension provider + its metatemplate(s).
265
+ * Idempotent. Requires `pict-section-form` loaded (PictFormSectionDefaultTemplateProvider present) and
266
+ * the `Pict-Section-Picker` provider registered.
267
+ *
268
+ * @param {any} pPict - the pict instance.
269
+ * @param {Record<string, any>} [pOptions]
270
+ * - InputTypeName {string} - the InputType string (default 'Picker').
271
+ * - ProviderHash {string} - the input-extension provider service hash (default 'Pict-Input-Picker').
272
+ * - ProviderClass {Function} - provider class to register (default PictInputTypePicker; a host
273
+ * passes a subclass that overrides getContextualSearchFilters for its scoping).
274
+ * - TemplatePrefix {string|Array<string>} - the form template prefix(es) to inject the metatemplate
275
+ * under (default 'Pict-MT-Base'; Headlight uses its theme prefixes).
276
+ * @return {boolean} true if registered.
277
+ */
278
+ const registerPickerInputType = (pPict, pOptions) =>
279
+ {
280
+ const tmpOptions = pOptions || {};
281
+ const tmpInputTypeName = tmpOptions.InputTypeName || 'Picker';
282
+ const tmpProviderHash = tmpOptions.ProviderHash || 'Pict-Input-Picker';
283
+ const tmpProviderClass = tmpOptions.ProviderClass || PictInputTypePicker;
284
+ const tmpPrefixes = Array.isArray(tmpOptions.TemplatePrefix) ? tmpOptions.TemplatePrefix : [ tmpOptions.TemplatePrefix || 'Pict-MT-Base' ];
285
+
286
+ if (!pPict.providers[tmpProviderHash])
287
+ {
288
+ pPict.addProvider(tmpProviderHash, Object.assign({}, _DEFAULT_CONFIGURATION, { ProviderIdentifier: tmpProviderHash }), tmpProviderClass);
289
+ }
290
+
291
+ const tmpTemplateProvider = pPict.providers.PictFormSectionDefaultTemplateProvider;
292
+ if (!tmpTemplateProvider || typeof tmpTemplateProvider.injectTemplateSet !== 'function')
293
+ {
294
+ pPict.log.warn('Pict-Input-Picker: PictFormSectionDefaultTemplateProvider not available; cannot register the Picker metatemplate (is pict-section-form loaded?).');
295
+ return false;
296
+ }
297
+ const tmpTemplates = buildPickerInputTemplates(tmpInputTypeName, tmpProviderHash);
298
+ tmpPrefixes.forEach((pPrefix) => tmpTemplateProvider.injectTemplateSet({ TemplatePrefix: pPrefix, Templates: tmpTemplates }));
299
+ return true;
300
+ };
301
+
302
+ module.exports = PictInputTypePicker;
303
+
304
+ module.exports.PictInputTypePicker = PictInputTypePicker;
305
+ module.exports.registerPickerInputType = registerPickerInputType;
306
+ module.exports.buildPickerInputTemplates = buildPickerInputTemplates;
307
+ module.exports.default_configuration = _DEFAULT_CONFIGURATION;
@@ -31,9 +31,15 @@ const _PickerCSS = /*css*/`
31
31
  .pps-chip-x { flex: 0 0 auto; display: inline-flex; align-items: center; cursor: pointer; font-size: 0.78rem; border-radius: 4px; padding: 0.1rem; opacity: 0.7; }
32
32
  .pps-chip-x:hover { opacity: 1; background: color-mix(in srgb, var(--theme-color-brand-primary, #156dd1) 22%, transparent); }
33
33
 
34
- /* Transparent full-viewport backdrop: closes on outside click (no document listener). */
35
- .pps-backdrop { position: fixed; inset: 0; z-index: 0; }
36
- .pps-pop { position: absolute; z-index: 40; top: calc(100% + 0.3rem); left: 0; right: 0; min-width: 200px; display: none; }
34
+ /* Transparent full-viewport backdrop: closes on outside click (no document listener). Only present
35
+ while OPEN otherwise a fixed full-viewport layer would swallow every click on the page. When open,
36
+ the control is raised above it so its chips/× stay clickable; the dropdown sits above both. */
37
+ .pps-backdrop { position: fixed; inset: 0; z-index: 0; display: none; }
38
+ .pps.pps-open .pps-backdrop { display: block; }
39
+ .pps.pps-open .pps-control { position: relative; z-index: 1; }
40
+ /* Fixed (viewport-anchored) + JS-positioned in open(), so no ancestor's overflow:hidden — a card, a
41
+ slide-out drawer, a scroll pane — can ever clip the dropdown, whatever the host's layout. */
42
+ .pps-pop { position: fixed; z-index: 40; min-width: 200px; display: none; }
37
43
  .pps.pps-open .pps-pop { display: block; }
38
44
  .pps-panel { position: relative; z-index: 1; display: flex; flex-direction: column; max-height: min(60vh, 360px);
39
45
  background: var(--theme-color-background-panel, #fff); border: 1px solid var(--theme-color-border-default, #d7dce3);
@@ -60,6 +66,10 @@ const _PickerCSS = /*css*/`
60
66
  padding: 0.45rem 0.6rem; border: none; border-radius: 6px; background: transparent; color: var(--theme-color-brand-primary, #156dd1); font-weight: 600; }
61
67
  .pps-create:hover { background: var(--theme-color-background-tertiary, #eceef2); }
62
68
  .pps-create-ic { flex: 0 0 auto; display: inline-flex; }
69
+
70
+ /* Form-input adapter (pict-section-picker/form): the picker host fills its row like the host's
71
+ native inputs (width:100% forces it to wrap below the label span + fill, matching a scalar input). */
72
+ .pps-form-host { flex: 1 1 100%; min-width: 0; width: 100%; }
63
73
  `;
64
74
 
65
75
  /** @type {Record<string, any>} */
@@ -159,8 +169,22 @@ class PictProviderPicker extends libPictProvider
159
169
  * - TextField {string} - record field used as the option Text (default `Name`).
160
170
  * - PageSize {number} - records per page (default 20).
161
171
  * - Sort {string} - optional field to sort ascending (adds `FSF~<field>~ASC~0`).
162
- * - BaseFilter {string} - optional always-applied FoxHound filter (AND), e.g. `FBV~IDCustomer~EQ~1`.
172
+ * - BaseFilter {string|Array<string>|function} - optional always-applied FoxHound filter (AND),
173
+ * e.g. `FBV~IDCustomer~EQ~1`. May be a **function** `(searchTerm, page) => string|string[]`
174
+ * evaluated on every search — the generic hook for host-injected CONTEXTUAL scoping (project,
175
+ * tenant, spec-year, …). The module stays agnostic; the host supplies the closure.
163
176
  * - MapRecord {function} - optional `(record) => {Value, Text}` mapper (overrides Value/TextField).
177
+ * - JoinEntity {string} - optional second entity to JOIN for a compound display (e.g. a `LineItem`
178
+ * shown with its `Project`). Each searched row must carry the FK (`JoinField`). Because Meadow
179
+ * can't join in one read, this is fetch-then-merge: after the primary page resolves, the unique
180
+ * FK ids drive ONE `FBL~ID{JoinEntity}~INN~<ids>` request, and the joined display field is
181
+ * stitched onto each row (as `Record.JoinName` / `Record.JoinRecord`) + composed into the Text.
182
+ * - JoinField {string} - the FK column ON THE SEARCHED ROW pointing at JoinEntity (default `ID{JoinEntity}`).
183
+ * - JoinEntityValueField {string} - the PK column on JoinEntity to match (default `ID{JoinEntity}`).
184
+ * - JoinEntityDisplayField {string} - the JoinEntity field to display (default `Name`).
185
+ * - JoinEntityFirst {boolean} - put the joined value first in the compound (default `true`):
186
+ * `JoinName - baseText`; when `false`, `baseText - JoinName`.
187
+ * - JoinSeparator {string} - the compound separator (default `' - '`).
164
188
  * @return {(pSearchTerm: string, pPage: number) => Promise<{results: Array<any>, hasMore: boolean}>}
165
189
  */
166
190
  createEntityDataProvider(pConfig)
@@ -171,8 +195,9 @@ class PictProviderPicker extends libPictProvider
171
195
  const tmpTextField = pConfig.TextField || 'Name';
172
196
  const tmpPageSize = pConfig.PageSize || 20;
173
197
  const tmpSort = pConfig.Sort || false;
174
- const tmpBaseFilter = pConfig.BaseFilter || '';
198
+ const tmpBaseFilterConfig = pConfig.BaseFilter || '';
175
199
  const tmpMapRecord = (typeof pConfig.MapRecord === 'function') ? pConfig.MapRecord : false;
200
+ const tmpJoinConfig = this._resolveJoinConfig(pConfig);
176
201
 
177
202
  return (pSearchTerm, pPage) => new Promise((resolve, reject) =>
178
203
  {
@@ -181,6 +206,17 @@ class PictProviderPicker extends libPictProvider
181
206
  return reject(new Error('Pict-Section-Picker: pict.EntityProvider is not available for entity-backed pickers.'));
182
207
  }
183
208
 
209
+ // Resolve the base filter at SEARCH time. A function form lets the host inject contextual
210
+ // scoping (e.g. "only this project's line items") without the module knowing the context;
211
+ // it can return a single stanza, an array of stanzas, or nothing.
212
+ let tmpBaseFilter = tmpBaseFilterConfig;
213
+ if (typeof tmpBaseFilterConfig === 'function')
214
+ {
215
+ try { tmpBaseFilter = tmpBaseFilterConfig(pSearchTerm, pPage); }
216
+ catch (pScopeError) { this.pict.log.warn(`Pict-Section-Picker [${tmpEntity}] BaseFilter() threw; ignoring contextual scope.`, pScopeError); tmpBaseFilter = ''; }
217
+ }
218
+ if (Array.isArray(tmpBaseFilter)) { tmpBaseFilter = tmpBaseFilter.filter(Boolean).join('~'); }
219
+
184
220
  const tmpStanzas = [];
185
221
  if (tmpBaseFilter) { tmpStanzas.push(tmpBaseFilter); }
186
222
  if (pSearchTerm) { tmpStanzas.push(this.buildSearchFilter(tmpSearchFields, pSearchTerm)); }
@@ -193,11 +229,103 @@ class PictProviderPicker extends libPictProvider
193
229
  {
194
230
  if (pError) { return reject(pError); }
195
231
  const tmpList = Array.isArray(pRecords) ? pRecords : [];
196
- const tmpResults = tmpList.map((pRecord) => tmpMapRecord
197
- ? tmpMapRecord(pRecord)
198
- : { Value: pRecord[tmpValueField], Text: pRecord[tmpTextField], Record: pRecord });
199
- // hasMore: a full page came back, so there is (probably) another. Avoids a Count round-trip.
200
- return resolve({ results: tmpResults, hasMore: (tmpList.length >= tmpPageSize) });
232
+ // JoinEntity (when configured): one INN fetch for the joined rows, stitched onto each
233
+ // searched row, before mapping — so the option Text can show the compound display.
234
+ this._decorateRecordsWithJoin(tmpList, tmpJoinConfig).then((pDecorated) =>
235
+ {
236
+ const tmpResults = pDecorated.map((pRecord) =>
237
+ {
238
+ if (tmpMapRecord) { return tmpMapRecord(pRecord); }
239
+ const tmpText = tmpJoinConfig
240
+ ? this._composeJoinedText(pRecord[tmpTextField], pRecord.JoinName, tmpJoinConfig.First, tmpJoinConfig.Separator)
241
+ : pRecord[tmpTextField];
242
+ return { Value: pRecord[tmpValueField], Text: tmpText, Record: pRecord };
243
+ });
244
+ // hasMore: a full page came back, so there is (probably) another. Avoids a Count round-trip.
245
+ return resolve({ results: tmpResults, hasMore: (tmpList.length >= tmpPageSize) });
246
+ });
247
+ });
248
+ });
249
+ }
250
+
251
+ /**
252
+ * Resolve the JoinEntity options off an entity-source config into a normalized internal shape, or
253
+ * `false` when no JoinEntity is configured. Centralizes the defaults so the DataProvider and the
254
+ * ResolveValue builders agree.
255
+ *
256
+ * @param {Record<string, any>} pConfig
257
+ * @return {false | {Entity:string, FKColumn:string, PKColumn:string, DisplayField:string, First:boolean, Separator:string}}
258
+ */
259
+ _resolveJoinConfig(pConfig)
260
+ {
261
+ if (!pConfig || !pConfig.JoinEntity) { return false; }
262
+ return {
263
+ Entity: pConfig.JoinEntity,
264
+ // The FK on the SEARCHED row, and the PK it points at on the join entity (the INN column).
265
+ FKColumn: pConfig.JoinField || `ID${pConfig.JoinEntity}`,
266
+ PKColumn: pConfig.JoinEntityValueField || `ID${pConfig.JoinEntity}`,
267
+ DisplayField: pConfig.JoinEntityDisplayField || 'Name',
268
+ // Default join-first (mirrors the documented select2 EntitySelector default).
269
+ First: (pConfig.JoinEntityFirst !== false),
270
+ Separator: (typeof pConfig.JoinSeparator === 'string') ? pConfig.JoinSeparator : ' - ',
271
+ };
272
+ }
273
+
274
+ /**
275
+ * Compose a compound display from a base text + a joined value, honoring ordering + separator.
276
+ * Falls back to just the base text when there is no joined value.
277
+ *
278
+ * @param {any} pBaseText @param {any} pJoinText @param {boolean} pFirst @param {string} pSeparator
279
+ * @return {any}
280
+ */
281
+ _composeJoinedText(pBaseText, pJoinText, pFirst, pSeparator)
282
+ {
283
+ if (pJoinText === undefined || pJoinText === null || pJoinText === '') { return pBaseText; }
284
+ const tmpBase = (pBaseText === undefined || pBaseText === null) ? '' : pBaseText;
285
+ return pFirst ? `${pJoinText}${pSeparator}${tmpBase}` : `${tmpBase}${pSeparator}${pJoinText}`;
286
+ }
287
+
288
+ /**
289
+ * Fetch-then-merge the join entity for a page of searched records. Collects the unique FK ids the
290
+ * rows carry (`JoinConfig.FKColumn`), issues ONE `FBL~{PKColumn}~INN~<ids>` request against the join
291
+ * entity, and stitches `JoinRecord` + `JoinName` onto each searched row. Resolves the (mutated) same
292
+ * array; on any error or when there's nothing to join, resolves the records un-decorated (the Text
293
+ * gracefully degrades to the base field).
294
+ *
295
+ * @param {Array<any>} pRecords @param {false | Record<string, any>} pJoinConfig
296
+ * @return {Promise<Array<any>>}
297
+ */
298
+ _decorateRecordsWithJoin(pRecords, pJoinConfig)
299
+ {
300
+ return new Promise((resolve) =>
301
+ {
302
+ if (!pJoinConfig || !Array.isArray(pRecords) || pRecords.length < 1 || !this.pict.EntityProvider) { return resolve(pRecords); }
303
+ const tmpIDs = [];
304
+ const tmpSeen = {};
305
+ for (let i = 0; i < pRecords.length; i++)
306
+ {
307
+ const tmpID = pRecords[i][pJoinConfig.FKColumn];
308
+ if (tmpID !== undefined && tmpID !== null && tmpID !== '' && !tmpSeen[tmpID]) { tmpSeen[tmpID] = true; tmpIDs.push(tmpID); }
309
+ }
310
+ if (tmpIDs.length < 1) { return resolve(pRecords); }
311
+ const tmpFilter = `FBL~${pJoinConfig.PKColumn}~INN~${tmpIDs.join(',')}`;
312
+ this.pict.EntityProvider.getEntitySetPage(pJoinConfig.Entity, tmpFilter, 0, tmpIDs.length,
313
+ (pError, pJoinRecords) =>
314
+ {
315
+ if (pError)
316
+ {
317
+ this.pict.log.warn(`Pict-Section-Picker [${pJoinConfig.Entity}] join fetch failed; showing un-joined text.`, pError);
318
+ return resolve(pRecords);
319
+ }
320
+ const tmpMap = {};
321
+ const tmpJoinList = Array.isArray(pJoinRecords) ? pJoinRecords : [];
322
+ for (let i = 0; i < tmpJoinList.length; i++) { tmpMap[tmpJoinList[i][pJoinConfig.PKColumn]] = tmpJoinList[i]; }
323
+ for (let i = 0; i < pRecords.length; i++)
324
+ {
325
+ const tmpJoinRecord = tmpMap[pRecords[i][pJoinConfig.FKColumn]];
326
+ if (tmpJoinRecord) { pRecords[i].JoinRecord = tmpJoinRecord; pRecords[i].JoinName = tmpJoinRecord[pJoinConfig.DisplayField]; }
327
+ }
328
+ return resolve(pRecords);
201
329
  });
202
330
  });
203
331
  }
@@ -215,6 +343,7 @@ class PictProviderPicker extends libPictProvider
215
343
  const tmpValueField = pConfig.ValueField || `ID${tmpEntity}`;
216
344
  const tmpTextField = pConfig.TextField || 'Name';
217
345
  const tmpMapRecord = (typeof pConfig.MapRecord === 'function') ? pConfig.MapRecord : false;
346
+ const tmpJoinConfig = this._resolveJoinConfig(pConfig);
218
347
 
219
348
  return (pValue) => new Promise((resolve) =>
220
349
  {
@@ -226,7 +355,23 @@ class PictProviderPicker extends libPictProvider
226
355
  (pError, pRecord) =>
227
356
  {
228
357
  if (pError || !pRecord) { return resolve(null); }
229
- return resolve(tmpMapRecord ? tmpMapRecord(pRecord) : { Value: pRecord[tmpValueField], Text: pRecord[tmpTextField], Record: pRecord });
358
+ const fFinish = () =>
359
+ {
360
+ if (tmpMapRecord) { return resolve(tmpMapRecord(pRecord)); }
361
+ const tmpText = tmpJoinConfig
362
+ ? this._composeJoinedText(pRecord[tmpTextField], pRecord.JoinName, tmpJoinConfig.First, tmpJoinConfig.Separator)
363
+ : pRecord[tmpTextField];
364
+ return resolve({ Value: pRecord[tmpValueField], Text: tmpText, Record: pRecord });
365
+ };
366
+ // JoinEntity: resolve the single joined record (cached getEntity) for the compound label.
367
+ const tmpFK = tmpJoinConfig ? pRecord[tmpJoinConfig.FKColumn] : null;
368
+ if (!tmpJoinConfig || tmpFK === undefined || tmpFK === null || tmpFK === '') { return fFinish(); }
369
+ this.pict.EntityProvider.getEntity(tmpJoinConfig.Entity, tmpFK,
370
+ (pJoinError, pJoinRecord) =>
371
+ {
372
+ if (!pJoinError && pJoinRecord) { pRecord.JoinRecord = pJoinRecord; pRecord.JoinName = pJoinRecord[tmpJoinConfig.DisplayField]; }
373
+ return fFinish();
374
+ });
230
375
  });
231
376
  });
232
377
  }
@@ -324,6 +324,65 @@ class PictViewPicker extends libPictView
324
324
  }
325
325
  }
326
326
 
327
+ /**
328
+ * Public: set the picker's value programmatically (e.g. when a host form marshals data into it).
329
+ * Accepts a scalar (single mode) or an array / csv string (multi mode), seeds display text for any
330
+ * unknown values (from the source rows, else async ResolveValue), then repaints.
331
+ * @param {any} pValue
332
+ * @return {PictViewPicker} this
333
+ */
334
+ setValue(pValue)
335
+ {
336
+ if (this._isMulti())
337
+ {
338
+ let tmpArray = pValue;
339
+ if (tmpArray === undefined || tmpArray === null || tmpArray === '') { tmpArray = []; }
340
+ else if (typeof tmpArray === 'string') { tmpArray = tmpArray.split(',').filter((pPart) => pPart !== ''); }
341
+ else if (!Array.isArray(tmpArray)) { tmpArray = [ tmpArray ]; }
342
+ this._setValue(tmpArray);
343
+ this._seedSelectedRecords(tmpArray);
344
+ }
345
+ else
346
+ {
347
+ this._setValue(pValue);
348
+ this._selectedText = null;
349
+ this._seedSelectedRecords((pValue === undefined || pValue === null || pValue === '') ? [] : [ pValue ]);
350
+ }
351
+ this.render();
352
+ return this;
353
+ }
354
+
355
+ /**
356
+ * Ensure each value has a {Value,Text} in _selectedRecords — from the current source rows when
357
+ * present, else (async mode) fetched via ResolveValue and painted in when it resolves.
358
+ * @param {Array<any>} pValues
359
+ */
360
+ _seedSelectedRecords(pValues)
361
+ {
362
+ pValues.forEach((pVal) =>
363
+ {
364
+ if (pVal === undefined || pVal === null || pVal === '' || this._selectedRecords[String(pVal)]) { return; }
365
+ const tmpRow = this._sourceRows().find((pRow) => String(pRow.Value) === String(pVal));
366
+ if (tmpRow)
367
+ {
368
+ this._selectedRecords[String(pVal)] = { Value: tmpRow.Value, Text: tmpRow.Text };
369
+ return;
370
+ }
371
+ if (this._isAsync() && typeof this.options.ResolveValue === 'function')
372
+ {
373
+ Promise.resolve(this.options.ResolveValue(pVal)).then((pResolved) =>
374
+ {
375
+ if (pResolved && pResolved.Text)
376
+ {
377
+ this._selectedRecords[String(pVal)] = { Value: pResolved.Value !== undefined ? pResolved.Value : pVal, Text: pResolved.Text };
378
+ if (!this._isMulti()) { this._selectedText = pResolved.Text; }
379
+ this._renderValue();
380
+ }
381
+ }).catch(() => { /* leave the raw value showing */ });
382
+ }
383
+ });
384
+ }
385
+
327
386
  /** @return {Array<{Value:any, Text:string}>} The current option source rows (async results or static Options). */
328
387
  _sourceRows()
329
388
  {
@@ -516,11 +575,51 @@ class PictViewPicker extends libPictView
516
575
  this._open = true;
517
576
  this._highlight = -1;
518
577
  this._paintOpen();
578
+ this._positionPop();
519
579
  if (this._isAsync() && !this._loaded) { this._loadPage(0, false); }
520
580
  const tmpSearch = /** @type {HTMLInputElement} */ (document.getElementById(`PPS_Search_${this.options.PickerHash}`));
521
581
  if (tmpSearch) { tmpSearch.focus(); tmpSearch.select(); }
522
582
  }
523
583
 
584
+ /**
585
+ * Position the (fixed) dropdown against the control, flipping above when there's more room there.
586
+ * Because the popover is position:fixed (viewport-anchored), no ancestor overflow can clip it; the
587
+ * trade-off is we set its top/left/width ourselves from the control's rect on open.
588
+ */
589
+ _positionPop()
590
+ {
591
+ const tmpRoot = document.getElementById(`PPS_${this.options.PickerHash}`);
592
+ if (!tmpRoot) { return; }
593
+ const tmpControl = tmpRoot.querySelector('.pps-control');
594
+ const tmpPop = /** @type {HTMLElement} */ (tmpRoot.querySelector('.pps-pop'));
595
+ const tmpPanel = /** @type {HTMLElement} */ (tmpRoot.querySelector('.pps-panel'));
596
+ if (!tmpControl || !tmpPop) { return; }
597
+ const tmpRect = tmpControl.getBoundingClientRect();
598
+ const tmpGap = 5;
599
+ const tmpMargin = 8;
600
+ const tmpVH = window.innerHeight;
601
+ const tmpVW = window.innerWidth;
602
+ const tmpWidth = Math.max(200, Math.round(tmpRect.width));
603
+ tmpPop.style.width = `${tmpWidth}px`;
604
+ tmpPop.style.left = `${Math.round(Math.max(tmpMargin, Math.min(tmpRect.left, tmpVW - tmpWidth - tmpMargin)))}px`;
605
+ tmpPop.style.right = 'auto';
606
+ const tmpSpaceBelow = tmpVH - tmpRect.bottom - tmpGap - tmpMargin;
607
+ const tmpSpaceAbove = tmpRect.top - tmpGap - tmpMargin;
608
+ // Prefer the natural downward direction; only flip above when the room below is genuinely cramped.
609
+ if (tmpSpaceBelow >= 200 || tmpSpaceBelow >= tmpSpaceAbove)
610
+ {
611
+ tmpPop.style.top = `${Math.round(tmpRect.bottom + tmpGap)}px`;
612
+ tmpPop.style.bottom = 'auto';
613
+ if (tmpPanel) { tmpPanel.style.maxHeight = `${Math.max(140, Math.min(tmpSpaceBelow, 360))}px`; }
614
+ }
615
+ else
616
+ {
617
+ tmpPop.style.top = 'auto';
618
+ tmpPop.style.bottom = `${Math.round(tmpVH - tmpRect.top + tmpGap)}px`;
619
+ if (tmpPanel) { tmpPanel.style.maxHeight = `${Math.max(140, Math.min(tmpSpaceAbove, 360))}px`; }
620
+ }
621
+ }
622
+
524
623
  /** Async mode: load + append the next page of results. */
525
624
  loadMore()
526
625
  {
@@ -0,0 +1,95 @@
1
+ export = PictInputTypePicker;
2
+ declare const PictInputTypePicker_base: typeof import("pict-section-form/types/source/providers/Pict-Provider-InputExtension");
3
+ declare class PictInputTypePicker extends PictInputTypePicker_base {
4
+ constructor(pFable: any, pOptions: any, pServiceHash: any);
5
+ getPickerHostID(pRawHTMLID: any): string;
6
+ getPickerHash(pRawHTMLID: any): string;
7
+ getTabularPickerHostID(pRawHTMLID: any, pRowIndex: any): string;
8
+ getTabularPickerHash(pRawHTMLID: any, pRowIndex: any): string;
9
+ getTabularHiddenID(pRawHTMLID: any, pRowIndex: any): string;
10
+ /**
11
+ * Overridable: extra FoxHound scope stanza(s) AND-applied to the entity search. Default reads the
12
+ * descriptor's `GetContextScopeFilter()` hook (set by the host / recordset filter base), else its
13
+ * static `BaseFilter`. Host subclasses override this to read app state (project / spec-year / …).
14
+ *
15
+ * @param {Record<string, any>} pInput @return {string|Array<string>}
16
+ */
17
+ getContextualSearchFilters(pInput: Record<string, any>): string | Array<string>;
18
+ /** Build the picker config from a form input descriptor. */
19
+ _buildPickerConfig(pInput: any, pHostSelector: any, fOnChange: any): {
20
+ DestinationAddress: any;
21
+ Mode: string;
22
+ Placeholder: any;
23
+ Searchable: boolean;
24
+ Entity: any;
25
+ SearchFields: any;
26
+ ValueField: any;
27
+ TextField: any;
28
+ PageSize: any;
29
+ Options: any;
30
+ JoinEntity: any;
31
+ JoinField: any;
32
+ JoinEntityValueField: any;
33
+ JoinEntityDisplayField: any;
34
+ JoinEntityFirst: any;
35
+ JoinSeparator: any;
36
+ BaseFilter: () => string | string[];
37
+ OnChange: any;
38
+ };
39
+ /** Instantiate (or reuse) the picker view for a config — entity-backed when Entity is set. */
40
+ _instantiatePicker(pPickerHash: any, pConfig: any): any;
41
+ /**
42
+ * Write a picker value into the form: csv to the hidden informary input (+ dataChanged), plus the
43
+ * raw array to `PictForm.ValueArrayAddress` when set (the recordset filter reads Values as an
44
+ * array). The csv-vs-array bridge lives HERE (generic) instead of in each host.
45
+ */
46
+ _commit(pView: any, pInput: any, pValue: any, pHTMLSelector: any): void;
47
+ _commitTabular(pView: any, pInput: any, pValue: any, pHiddenID: any, pRowIndex: any): void;
48
+ /**
49
+ * Idempotently mount (or reuse) the picker into its host element + seed its value. Called from both
50
+ * onInputInitialize and onDataMarshalToForm because, in the async-virtual filter render, the host
51
+ * element only exists in the real DOM by the marshal pass — whichever hook fires post-DOM wins, and
52
+ * re-calls are harmless (the picker view is reused by hash).
53
+ * @return {boolean} true if the picker is mounted.
54
+ */
55
+ _mountPicker(pView: any, pInput: any, pValue: any, pHostSelector: any, pPickerHash: any, fOnChange: any): boolean;
56
+ onInputInitialize(pView: any, pGroup: any, pRow: any, pInput: any, pValue: any, pHTMLSelector: any, pTransactionGUID: any): boolean;
57
+ onDataMarshalToForm(pView: any, pGroup: any, pRow: any, pInput: any, pValue: any, pHTMLSelector: any, pTransactionGUID: any): boolean;
58
+ onDataRequest(pView: any, pInput: any, pValue: any, pHTMLSelector: any): boolean;
59
+ /** Idempotent tabular mount (see _mountPicker). @return {boolean} */
60
+ _mountPickerTabular(pView: any, pInput: any, pValue: any, pRowIndex: any): boolean;
61
+ onInputInitializeTabular(pView: any, pGroup: any, pInput: any, pValue: any, pHTMLSelector: any, pRowIndex: any, pTransactionGUID: any): boolean;
62
+ onDataMarshalToFormTabular(pView: any, pGroup: any, pInput: any, pValue: any, pHTMLSelector: any, pRowIndex: any, pTransactionGUID: any): boolean;
63
+ onDataRequestTabular(pView: any, pInput: any, pValue: any, pHTMLSelector: any, pRowIndex: any): boolean;
64
+ }
65
+ declare namespace PictInputTypePicker {
66
+ export { PictInputTypePicker, registerPickerInputType, buildPickerInputTemplates, _DEFAULT_CONFIGURATION as default_configuration };
67
+ }
68
+ /**
69
+ * Register the Picker InputType on a pict instance: the input-extension provider + its metatemplate(s).
70
+ * Idempotent. Requires `pict-section-form` loaded (PictFormSectionDefaultTemplateProvider present) and
71
+ * the `Pict-Section-Picker` provider registered.
72
+ *
73
+ * @param {any} pPict - the pict instance.
74
+ * @param {Record<string, any>} [pOptions]
75
+ * - InputTypeName {string} - the InputType string (default 'Picker').
76
+ * - ProviderHash {string} - the input-extension provider service hash (default 'Pict-Input-Picker').
77
+ * - ProviderClass {Function} - provider class to register (default PictInputTypePicker; a host
78
+ * passes a subclass that overrides getContextualSearchFilters for its scoping).
79
+ * - TemplatePrefix {string|Array<string>} - the form template prefix(es) to inject the metatemplate
80
+ * under (default 'Pict-MT-Base'; Headlight uses its theme prefixes).
81
+ * @return {boolean} true if registered.
82
+ */
83
+ declare function registerPickerInputType(pPict: any, pOptions?: Record<string, any>): boolean;
84
+ /**
85
+ * Build the InputType metatemplate entries (a hidden informary input + a host element the picker
86
+ * renders into) for a given InputType name + provider hash. Injected via injectTemplateSet.
87
+ *
88
+ * @param {string} pInputTypeName - e.g. 'Picker'.
89
+ * @param {string} pProviderHash - the input-extension provider service hash to auto-attach.
90
+ * @return {Array<Record<string, any>>}
91
+ */
92
+ declare function buildPickerInputTemplates(pInputTypeName: string, pProviderHash: string): Array<Record<string, any>>;
93
+ /** @type {Record<string, any>} */
94
+ declare const _DEFAULT_CONFIGURATION: Record<string, any>;
95
+ //# sourceMappingURL=Pict-Section-Picker-FormInput.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"Pict-Section-Picker-FormInput.d.ts","sourceRoot":"","sources":["../../source/form/Pict-Section-Picker-FormInput.js"],"names":[],"mappings":";;AAsEA;IAEC,2DAKC;IAGD,yCAAmE;IACnE,uCAA4D;IAC5D,gEAAsG;IACtG,8DAA2F;IAC3F,4DAAuG;IAEvG;;;;;;OAMG;IACH,mCAFW,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,GAAkB,MAAM,GAAC,KAAK,CAAC,MAAM,CAAC,CAWnE;IAED,4DAA4D;IAC5D;;;;;;;;;;;;;;;;;;;MA0BC;IAED,8FAA8F;IAC9F,wDAWC;IAED;;;;OAIG;IACH,wEAUC;IAED,2FAKC;IAED;;;;;;OAMG;IACH,0GAFY,OAAO,CAUlB;IAID,oIAMC;IAED,sIAYC;IAED,iFAMC;IAID,qEAAqE;IACrE,2EAD0D,OAAO,CAahE;IAED,gJAIC;IAED,kJAQC;IAED,wGAMC;CACD;;;;AAED;;;;;;;;;;;;;;GAcG;AACH,gDAVW,GAAG,aACH,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,GAOlB,OAAO,CAwBlB;AAjRD;;;;;;;GAOG;AACH,2DAJW,MAAM,iBACN,MAAM,GACL,KAAK,CAAC,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,CAAC,CAoCrC;AAnDD,kCAAkC;AAClC,sCADW,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,CAO5B"}
@@ -46,14 +46,63 @@ declare class PictProviderPicker extends libPictProvider {
46
46
  * - TextField {string} - record field used as the option Text (default `Name`).
47
47
  * - PageSize {number} - records per page (default 20).
48
48
  * - Sort {string} - optional field to sort ascending (adds `FSF~<field>~ASC~0`).
49
- * - BaseFilter {string} - optional always-applied FoxHound filter (AND), e.g. `FBV~IDCustomer~EQ~1`.
49
+ * - BaseFilter {string|Array<string>|function} - optional always-applied FoxHound filter (AND),
50
+ * e.g. `FBV~IDCustomer~EQ~1`. May be a **function** `(searchTerm, page) => string|string[]`
51
+ * evaluated on every search — the generic hook for host-injected CONTEXTUAL scoping (project,
52
+ * tenant, spec-year, …). The module stays agnostic; the host supplies the closure.
50
53
  * - MapRecord {function} - optional `(record) => {Value, Text}` mapper (overrides Value/TextField).
54
+ * - JoinEntity {string} - optional second entity to JOIN for a compound display (e.g. a `LineItem`
55
+ * shown with its `Project`). Each searched row must carry the FK (`JoinField`). Because Meadow
56
+ * can't join in one read, this is fetch-then-merge: after the primary page resolves, the unique
57
+ * FK ids drive ONE `FBL~ID{JoinEntity}~INN~<ids>` request, and the joined display field is
58
+ * stitched onto each row (as `Record.JoinName` / `Record.JoinRecord`) + composed into the Text.
59
+ * - JoinField {string} - the FK column ON THE SEARCHED ROW pointing at JoinEntity (default `ID{JoinEntity}`).
60
+ * - JoinEntityValueField {string} - the PK column on JoinEntity to match (default `ID{JoinEntity}`).
61
+ * - JoinEntityDisplayField {string} - the JoinEntity field to display (default `Name`).
62
+ * - JoinEntityFirst {boolean} - put the joined value first in the compound (default `true`):
63
+ * `JoinName - baseText`; when `false`, `baseText - JoinName`.
64
+ * - JoinSeparator {string} - the compound separator (default `' - '`).
51
65
  * @return {(pSearchTerm: string, pPage: number) => Promise<{results: Array<any>, hasMore: boolean}>}
52
66
  */
53
67
  createEntityDataProvider(pConfig: Record<string, any>): (pSearchTerm: string, pPage: number) => Promise<{
54
68
  results: Array<any>;
55
69
  hasMore: boolean;
56
70
  }>;
71
+ /**
72
+ * Resolve the JoinEntity options off an entity-source config into a normalized internal shape, or
73
+ * `false` when no JoinEntity is configured. Centralizes the defaults so the DataProvider and the
74
+ * ResolveValue builders agree.
75
+ *
76
+ * @param {Record<string, any>} pConfig
77
+ * @return {false | {Entity:string, FKColumn:string, PKColumn:string, DisplayField:string, First:boolean, Separator:string}}
78
+ */
79
+ _resolveJoinConfig(pConfig: Record<string, any>): false | {
80
+ Entity: string;
81
+ FKColumn: string;
82
+ PKColumn: string;
83
+ DisplayField: string;
84
+ First: boolean;
85
+ Separator: string;
86
+ };
87
+ /**
88
+ * Compose a compound display from a base text + a joined value, honoring ordering + separator.
89
+ * Falls back to just the base text when there is no joined value.
90
+ *
91
+ * @param {any} pBaseText @param {any} pJoinText @param {boolean} pFirst @param {string} pSeparator
92
+ * @return {any}
93
+ */
94
+ _composeJoinedText(pBaseText: any, pJoinText: any, pFirst: boolean, pSeparator: string): any;
95
+ /**
96
+ * Fetch-then-merge the join entity for a page of searched records. Collects the unique FK ids the
97
+ * rows carry (`JoinConfig.FKColumn`), issues ONE `FBL~{PKColumn}~INN~<ids>` request against the join
98
+ * entity, and stitches `JoinRecord` + `JoinName` onto each searched row. Resolves the (mutated) same
99
+ * array; on any error or when there's nothing to join, resolves the records un-decorated (the Text
100
+ * gracefully degrades to the base field).
101
+ *
102
+ * @param {Array<any>} pRecords @param {false | Record<string, any>} pJoinConfig
103
+ * @return {Promise<Array<any>>}
104
+ */
105
+ _decorateRecordsWithJoin(pRecords: Array<any>, pJoinConfig: false | Record<string, any>): Promise<Array<any>>;
57
106
  /**
58
107
  * Build a `ResolveValue(value) => Promise<{Value,Text}>` for an entity-backed picker, so a
59
108
  * pre-bound ID resolves to its display text on first render (fetched + cached by `getEntity`).
@@ -1 +1 @@
1
- {"version":3,"file":"Pict-Provider-Picker.d.ts","sourceRoot":"","sources":["../../source/providers/Pict-Provider-Picker.js"],"names":[],"mappings":";AAyEA;;;GAGG;AACH;IAEC,2DASC;IAED;;;;;;;;;;;;;;OAcG;IACH,0BAZW,MAAM,WAEN,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,GAQlB,GAAG,CAqBd;IAED;;;;;;;;;;;OAWG;IACH,iCAJW,KAAK,CAAC,MAAM,CAAC,SACb,MAAM,GACL,MAAM,CAWjB;IAED;;;;;;;;;;;;;;;OAeG;IACH,kCAXW,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,GASlB,CAAC,WAAW,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,KAAK,OAAO,CAAC;QAAC,OAAO,EAAE,KAAK,CAAC,GAAG,CAAC,CAAC;QAAC,OAAO,EAAE,OAAO,CAAA;KAAC,CAAC,CAuCnG;IAED;;;;;;OAMG;IACH,kCAHW,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,GAClB,CAAC,MAAM,EAAE,GAAG,KAAK,OAAO,CAAC,GAAG,CAAC,CAsBxC;IAED;;;;;;;;;OASG;IACH,gCAJW,MAAM,WACN,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,GAClB,GAAG,CAQd;CACD;;;;;AA1LD,kCAAkC;AAClC,sCADW,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,CAO5B"}
1
+ {"version":3,"file":"Pict-Provider-Picker.d.ts","sourceRoot":"","sources":["../../source/providers/Pict-Provider-Picker.js"],"names":[],"mappings":";AAmFA;;;GAGG;AACH;IAEC,2DASC;IAED;;;;;;;;;;;;;;OAcG;IACH,0BAZW,MAAM,WAEN,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,GAQlB,GAAG,CAqBd;IAED;;;;;;;;;;;OAWG;IACH,iCAJW,KAAK,CAAC,MAAM,CAAC,SACb,MAAM,GACL,MAAM,CAWjB;IAED;;;;;;;;;;;;;;;;;;;;;;;;;;;;;OA6BG;IACH,kCAzBW,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,GAuBlB,CAAC,WAAW,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,KAAK,OAAO,CAAC;QAAC,OAAO,EAAE,KAAK,CAAC,GAAG,CAAC,CAAC;QAAC,OAAO,EAAE,OAAO,CAAA;KAAC,CAAC,CA6DnG;IAED;;;;;;;OAOG;IACH,4BAHW,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,GAClB,KAAK,GAAG;QAAC,MAAM,EAAC,MAAM,CAAC;QAAC,QAAQ,EAAC,MAAM,CAAC;QAAC,QAAQ,EAAC,MAAM,CAAC;QAAC,YAAY,EAAC,MAAM,CAAC;QAAC,KAAK,EAAC,OAAO,CAAC;QAAC,SAAS,EAAC,MAAM,CAAA;KAAC,CAe1H;IAED;;;;;;OAMG;IACH,8BAHW,GAAG,aAAoB,GAAG,UAAoB,OAAO,cAAiB,MAAM,GAC3E,GAAG,CAOd;IAED;;;;;;;;;OASG;IACH,mCAHW,KAAK,CAAC,GAAG,CAAC,eAAmB,KAAK,GAAG,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,GACvD,OAAO,CAAC,KAAK,CAAC,GAAG,CAAC,CAAC,CAmC9B;IAED;;;;;;OAMG;IACH,kCAHW,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,GAClB,CAAC,MAAM,EAAE,GAAG,KAAK,OAAO,CAAC,GAAG,CAAC,CAuCxC;IAED;;;;;;;;;OASG;IACH,gCAJW,MAAM,WACN,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,GAClB,GAAG,CAQd;CACD;;;;;AAjUD,kCAAkC;AAClC,sCADW,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,CAO5B"}
@@ -34,6 +34,20 @@ declare class PictViewPicker extends libPictView {
34
34
  */
35
35
  _setValue(pValue: any): void;
36
36
  _value: any;
37
+ /**
38
+ * Public: set the picker's value programmatically (e.g. when a host form marshals data into it).
39
+ * Accepts a scalar (single mode) or an array / csv string (multi mode), seeds display text for any
40
+ * unknown values (from the source rows, else async ResolveValue), then repaints.
41
+ * @param {any} pValue
42
+ * @return {PictViewPicker} this
43
+ */
44
+ setValue(pValue: any): PictViewPicker;
45
+ /**
46
+ * Ensure each value has a {Value,Text} in _selectedRecords — from the current source rows when
47
+ * present, else (async mode) fetched via ResolveValue and painted in when it resolves.
48
+ * @param {Array<any>} pValues
49
+ */
50
+ _seedSelectedRecords(pValues: Array<any>): void;
37
51
  /** @return {Array<{Value:any, Text:string}>} The current option source rows (async results or static Options). */
38
52
  _sourceRows(): Array<{
39
53
  Value: any;
@@ -66,6 +80,12 @@ declare class PictViewPicker extends libPictView {
66
80
  onControlKey(pEvent: any): void;
67
81
  /** Open the dropdown and focus the search box. */
68
82
  open(): void;
83
+ /**
84
+ * Position the (fixed) dropdown against the control, flipping above when there's more room there.
85
+ * Because the popover is position:fixed (viewport-anchored), no ancestor overflow can clip it; the
86
+ * trade-off is we set its top/left/width ourselves from the control's rect on open.
87
+ */
88
+ _positionPop(): void;
69
89
  /** Async mode: load + append the next page of results. */
70
90
  loadMore(): void;
71
91
  /** Close the dropdown. */
@@ -1 +1 @@
1
- {"version":3,"file":"PictView-Picker.d.ts","sourceRoot":"","sources":["../../source/views/PictView-Picker.js"],"names":[],"mappings":";AA6KA;IAEC,2DA0CC;IAlCA,sBAA2E;IAQ3E,eAAkB;IAClB,gBAAiB;IACjB,mBAAoB;IAEpB,sBAAwB;IACxB,cAAc;IACd,kBAAqB;IACrB,kBAAqB;IACrB,iBAAoB;IACpB,6BAAwB;IACxB,mBAAyB;IAGzB,eAAiB;IACjB,qBAA0B;IAc3B,6FAA6F;IAC7F,YADa,OAAO,CAInB;IAED,8EAA8E;IAC9E,YADa,OAAO,CAInB;IAED,4EAA4E;IAC5E,UADa,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,CAM/B;IAED,qGAAqG;IACrG,8BA+BC;IAED;;;OAGG;IACH,YAHY,GAAG,CAgBd;IAED;;;;OAIG;IACH,kBAFW,GAAG,QA6Bb;IAvBC,YAAoB;IAyBtB,kHAAkH;IAClH,eADa,KAAK,CAAC;QAAC,KAAK,EAAC,GAAG,CAAC;QAAC,IAAI,EAAC,MAAM,CAAA;KAAC,CAAC,CAK3C;IAED;;;OAGG;IACH,mCAgGC;IAED;;;;;OAKG;IACH,sBAHW,GAAG,GACF;QAAC,KAAK,EAAC,GAAG,CAAC;QAAC,IAAI,EAAC,MAAM,CAAA;KAAC,GAAC,IAAI,CAQxC;IAED;;;;OAIG;IACH,iBAHW,MAAM,WACN,OAAO,QA4BjB;IAWD,uCAAuC;IACvC,0BAIC;IAED,+EAA+E;IAC/E,gCAWC;IAED,kDAAkD;IAClD,aAQC;IAED,0DAA0D;IAC1D,iBAMC;IAED,0BAA0B;IAC1B,cAKC;IAED,6DAA6D;IAC7D,mBAIC;IAED,kFAAkF;IAClF,oBAKC;IAED;sGACkG;IAClG,qBAKC;IAED,2EAA2E;IAC3E,eADY,MAAM,QAejB;IAED,iGAAiG;IACjG,+BAgCC;IAED;;;;OAIG;IACH,kBAFW,MAAM,QA4ChB;IAED,sGAAsG;IACtG,sBADa,KAAK,CAAC;QAAC,KAAK,EAAC,GAAG,CAAC;QAAC,IAAI,EAAC,MAAM,CAAA;KAAC,CAAC,CAI3C;IAED;;;;OAIG;IACH,yBA6CC;IAED,oFAAoF;IACpF,iCAWC;CAWD;;;;;AAxuBD,kCAAkC;AAClC,sCADW,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,CAyK5B"}
1
+ {"version":3,"file":"PictView-Picker.d.ts","sourceRoot":"","sources":["../../source/views/PictView-Picker.js"],"names":[],"mappings":";AA6KA;IAEC,2DA0CC;IAlCA,sBAA2E;IAQ3E,eAAkB;IAClB,gBAAiB;IACjB,mBAAoB;IAEpB,sBAAwB;IACxB,cAAc;IACd,kBAAqB;IACrB,kBAAqB;IACrB,iBAAoB;IACpB,6BAAwB;IACxB,mBAAyB;IAGzB,eAAiB;IACjB,qBAA0B;IAc3B,6FAA6F;IAC7F,YADa,OAAO,CAInB;IAED,8EAA8E;IAC9E,YADa,OAAO,CAInB;IAED,4EAA4E;IAC5E,UADa,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,CAM/B;IAED,qGAAqG;IACrG,8BA+BC;IAED;;;OAGG;IACH,YAHY,GAAG,CAgBd;IAED;;;;OAIG;IACH,kBAFW,GAAG,QA6Bb;IAvBC,YAAoB;IAyBtB;;;;;;OAMG;IACH,iBAHW,GAAG,GACF,cAAc,CAqBzB;IAED;;;;OAIG;IACH,8BAFW,KAAK,CAAC,GAAG,CAAC,QA0BpB;IAED,kHAAkH;IAClH,eADa,KAAK,CAAC;QAAC,KAAK,EAAC,GAAG,CAAC;QAAC,IAAI,EAAC,MAAM,CAAA;KAAC,CAAC,CAK3C;IAED;;;OAGG;IACH,mCAgGC;IAED;;;;;OAKG;IACH,sBAHW,GAAG,GACF;QAAC,KAAK,EAAC,GAAG,CAAC;QAAC,IAAI,EAAC,MAAM,CAAA;KAAC,GAAC,IAAI,CAQxC;IAED;;;;OAIG;IACH,iBAHW,MAAM,WACN,OAAO,QA4BjB;IAWD,uCAAuC;IACvC,0BAIC;IAED,+EAA+E;IAC/E,gCAWC;IAED,kDAAkD;IAClD,aASC;IAED;;;;OAIG;IACH,qBAgCC;IAED,0DAA0D;IAC1D,iBAMC;IAED,0BAA0B;IAC1B,cAKC;IAED,6DAA6D;IAC7D,mBAIC;IAED,kFAAkF;IAClF,oBAKC;IAED;sGACkG;IAClG,qBAKC;IAED,2EAA2E;IAC3E,eADY,MAAM,QAejB;IAED,iGAAiG;IACjG,+BAgCC;IAED;;;;OAIG;IACH,kBAFW,MAAM,QA4ChB;IAED,sGAAsG;IACtG,sBADa,KAAK,CAAC;QAAC,KAAK,EAAC,GAAG,CAAC;QAAC,IAAI,EAAC,MAAM,CAAA;KAAC,CAAC,CAI3C;IAED;;;;OAIG;IACH,yBA6CC;IAED,oFAAoF;IACpF,iCAWC;CAWD;;;;;AA30BD,kCAAkC;AAClC,sCADW,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,CAyK5B"}