pict-section-picker 1.0.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ The MIT License (MIT)
2
+
3
+ Copyright (c) 2015 - 2026 Steven Velozo
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,175 @@
1
+ # pict-section-picker
2
+
3
+ A Pict-native, themeable searchable **select / combobox** — a jQuery/`select2`-free replacement for entity pickers and tag inputs in [Pict](https://github.com/fable-retold/pict) applications.
4
+
5
+ - **Single & multi select** — scalar value, or an array of values rendered as removable chips.
6
+ - **Search** with keyboard navigation (↑/↓ + Enter), click-outside to close.
7
+ - **Server pagination** — drive options from an async `DataProvider(searchTerm, page)`; "Load more" / infinite scroll.
8
+ - **Categorized options** — group rows under headers.
9
+ - **Creatable** — let users mint new entries from the search term via `OnCreate`.
10
+ - **Built-in Meadow adapter** — point it at a Meadow entity and it builds the server `DataProvider` (FoxHound `LIKE` search + paging) and the pre-bound-value resolver for you.
11
+ - **Themeable** via `--theme-color-*` tokens. No jQuery, no `select2`, no `addEventListener` — pure Pict conventions.
12
+
13
+ ## Install
14
+
15
+ ```bash
16
+ npm install pict-section-picker
17
+ ```
18
+
19
+ ## Quick start
20
+
21
+ Register the provider, then create pickers through it. Each picker renders into a host DOM element and reads/writes its selection from an AppData address.
22
+
23
+ ```javascript
24
+ const libPictSectionPicker = require('pict-section-picker');
25
+
26
+ // In your application's onAfterInitializeAsync:
27
+ this.pict.addProvider('Pict-Section-Picker', libPictSectionPicker.default_configuration, libPictSectionPicker);
28
+ const tmpPicker = this.pict.providers['Pict-Section-Picker'];
29
+
30
+ // A simple static single-select.
31
+ tmpPicker.createPicker('CountryPicker',
32
+ {
33
+ DestinationAddress: '#CountryPicker', // where to render
34
+ ValueAddress: 'AppData.Form.Country', // selection is read from / written to here
35
+ Placeholder: 'Select a country…',
36
+ Options: [ { Value: 'us', Text: 'United States' }, { Value: 'ca', Text: 'Canada' } ],
37
+ OnChange: (pValue, pRecord) => { /* … */ },
38
+ });
39
+ this.pict.views['CountryPicker'].render();
40
+ ```
41
+
42
+ The control renders into `#CountryPicker`; `AppData.Form.Country` holds the selected value.
43
+
44
+ ## Picker modes
45
+
46
+ ### Single (default)
47
+
48
+ `Mode: 'single'` — `ValueAddress` holds the scalar value. Selecting closes the dropdown.
49
+
50
+ ### Multi
51
+
52
+ `Mode: 'multi'` — `ValueAddress` holds an **array** of values, rendered as chips with × buttons. Selecting toggles membership and keeps the dropdown open for rapid multi-pick. Two optional mirror bindings (the `EntitySelectorMultiple` contract):
53
+
54
+ | Option | Holds |
55
+ |---|---|
56
+ | `ValueAddress` | the array of values, e.g. `[2, 10, 141]` |
57
+ | `StringArrayValueAddress` | a csv string, e.g. `"2,10,141"` |
58
+ | `SelectedValuesAddress` | the full record list, e.g. `[{Value, Text}, …]` |
59
+
60
+ ```javascript
61
+ tmpPicker.createPicker('TagsPicker',
62
+ {
63
+ Mode: 'multi',
64
+ DestinationAddress: '#TagsPicker',
65
+ ValueAddress: 'AppData.Form.Tags',
66
+ Placeholder: 'Add tags…',
67
+ Options: [ { Value: 'urgent', Text: 'urgent' }, { Value: 'review', Text: 'review' } ],
68
+ });
69
+ ```
70
+
71
+ ## Async data (server search + pagination)
72
+
73
+ Pass a `DataProvider` instead of (or in addition to) static `Options`. It is called with the current search term and a zero-based page index, and resolves a page of results plus whether more remain:
74
+
75
+ ```javascript
76
+ DataProvider: (pSearchTerm, pPage) => Promise.resolve(
77
+ {
78
+ results: [ { Value: 1, Text: 'First' }, /* … up to PageSize … */ ],
79
+ hasMore: true, // show a "Load more" button
80
+ })
81
+ ```
82
+
83
+ Searches are debounced; "Load more" appends the next page. For a value that is already bound when the picker mounts (e.g. an ID with no text yet), supply `ResolveValue(value) => Promise<{Value, Text}>` so the control can show its label.
84
+
85
+ ## Meadow entity pickers
86
+
87
+ For the common case — picking a [Meadow](https://github.com/fable-retold/meadow) entity over the REST API — use `createEntityPicker`. It builds the server `DataProvider` (FoxHound `LIKE` search across your fields, offset/limit paging) and the `ResolveValue` resolver from `pict.EntityProvider` automatically.
88
+
89
+ ```javascript
90
+ tmpPicker.createEntityPicker('AuthorPicker',
91
+ {
92
+ Entity: 'Author', // the Meadow entity
93
+ SearchFields: [ 'Name' ], // fields to LIKE-search (default ['Name'])
94
+ ValueField: 'IDAuthor', // option Value (default 'ID<Entity>')
95
+ TextField: 'Name', // option Text (default 'Name')
96
+ PageSize: 20,
97
+ DestinationAddress: '#AuthorPicker',
98
+ ValueAddress: 'AppData.Form.IDAuthor',
99
+ Placeholder: 'Search authors…',
100
+ // Works in multi mode too — add Mode: 'multi'.
101
+ });
102
+ this.pict.views['AuthorPicker'].render();
103
+ ```
104
+
105
+ Entity-source configuration:
106
+
107
+ | Option | Default | Purpose |
108
+ |---|---|---|
109
+ | `Entity` | — (required) | The Meadow entity name. |
110
+ | `SearchFields` | `['Name']` | Fields OR'd together in the `LIKE` search. |
111
+ | `ValueField` | `ID<Entity>` | Record field used as the option `Value`. |
112
+ | `TextField` | `'Name'` | Record field used as the option `Text`. |
113
+ | `PageSize` | `20` | Records per page. |
114
+ | `Sort` | — | Field to sort ascending (`FSF~<field>~ASC~0`). |
115
+ | `BaseFilter` | — | An always-applied FoxHound filter (AND), e.g. `FBV~IDCustomer~EQ~1`. |
116
+ | `MapRecord` | — | `(record) => {Value, Text}` mapper, overriding `Value`/`TextField`. |
117
+
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
+
120
+ ## Categories
121
+
122
+ 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):
123
+
124
+ ```javascript
125
+ Options:
126
+ [
127
+ { Value: 'us', Text: 'United States', Group: 'Americas' },
128
+ { Value: 'gb', Text: 'United Kingdom', Group: 'Europe' },
129
+ { Value: 'jp', Text: 'Japan', Group: 'Asia' },
130
+ ]
131
+ ```
132
+
133
+ Async sources can return `Group` on each result row too.
134
+
135
+ ## Creatable
136
+
137
+ Set `OnCreate(searchTerm) => {Value, Text} | Promise<{Value, Text}>`. When the search term is non-empty and doesn't exactly match an existing row, a **"Create …"** row appears (also triggerable with Enter). The returned record is selected (single) or added as a chip (multi):
138
+
139
+ ```javascript
140
+ OnCreate: (pTerm) =>
141
+ {
142
+ // A real app would POST the new entity and return the saved row (sync or async).
143
+ return { Value: slugify(pTerm), Text: pTerm };
144
+ }
145
+ ```
146
+
147
+ ## Configuration reference
148
+
149
+ | Option | Default | Purpose |
150
+ |---|---|---|
151
+ | `DestinationAddress` | `#<hash>` | CSS selector to render the control into. |
152
+ | `ValueAddress` | — | AppData address the selection is read from / written to. |
153
+ | `Mode` | `'single'` | `'single'` (scalar) or `'multi'` (array + chips). |
154
+ | `Placeholder` | `'Select…'` | Text shown when nothing is selected. |
155
+ | `Searchable` | `true` | Show the search box. |
156
+ | `Options` | `[]` | Static option list (`{Value, Text, Group?}`). |
157
+ | `DataProvider` | — | Async source `(term, page) => Promise<{results, hasMore}>`. |
158
+ | `PageSize` | `20` | Page size for async sources. |
159
+ | `ResolveValue` | — | `(value) => Promise<{Value, Text}>` to label a pre-bound value. |
160
+ | `StringArrayValueAddress` | — | (multi) mirror the value array as a csv string. |
161
+ | `SelectedValuesAddress` | — | (multi) mirror the selection as a record array. |
162
+ | `OnCreate` | — | `(term) => {Value, Text}` to enable creatable entries. |
163
+ | `OnChange` | — | Called after a selection: single → `(value, record)`, multi → `(values, records)`. |
164
+
165
+ ## Theming
166
+
167
+ 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`.
168
+
169
+ ## Example application
170
+
171
+ `example_applications/picker_demo` exercises every mode (static / async / entity, single / multi, categorized, creatable). Build it with `npm run build` in that folder and open `dist/index.html`. The entity pickers in the demo talk to a live Meadow harness.
172
+
173
+ ## License
174
+
175
+ MIT
package/package.json ADDED
@@ -0,0 +1,59 @@
1
+ {
2
+ "name": "pict-section-picker",
3
+ "version": "1.0.0",
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
+ "main": "source/Pict-Section-Picker.js",
6
+ "types": "types/Pict-Section-Picker.d.ts",
7
+ "files": [
8
+ "source",
9
+ "types"
10
+ ],
11
+ "scripts": {
12
+ "test": "npx mocha -u tdd -R spec",
13
+ "tests": "npx mocha -u tdd -R spec -g",
14
+ "start": "node source/Pict-Section-Picker.js",
15
+ "coverage": "./node_modules/.bin/nyc --reporter=lcov --reporter=text-lcov ./node_modules/mocha/bin/_mocha -- -u tdd -R spec",
16
+ "build": "npx quack build",
17
+ "types": "tsc -p tsconfig.build.json"
18
+ },
19
+ "repository": {
20
+ "type": "git",
21
+ "url": "git+https://github.com/fable-retold/pict-section-picker.git"
22
+ },
23
+ "author": "steven velozo <steven@velozo.com>",
24
+ "license": "MIT",
25
+ "bugs": {
26
+ "url": "https://github.com/fable-retold/pict-section-picker/issues"
27
+ },
28
+ "homepage": "https://github.com/fable-retold/pict-section-picker#readme",
29
+ "mocha": {
30
+ "diff": true,
31
+ "extension": [
32
+ "js"
33
+ ],
34
+ "package": "./package.json",
35
+ "reporter": "spec",
36
+ "slow": "75",
37
+ "timeout": "5000",
38
+ "ui": "tdd",
39
+ "watch-files": [
40
+ "source/**/*.js",
41
+ "test/**/*.js"
42
+ ],
43
+ "watch-ignore": [
44
+ "lib/vendor"
45
+ ]
46
+ },
47
+ "dependencies": {
48
+ "pict-provider": "^1.0.13",
49
+ "pict-view": "^1.0.68"
50
+ },
51
+ "devDependencies": {
52
+ "@types/mocha": "^10.0.10",
53
+ "@types/node": "^16.18.126",
54
+ "browser-env": "^3.3.0",
55
+ "pict": "^1.0.372",
56
+ "quackage": "^1.3.0",
57
+ "typescript": "^5.9.3"
58
+ }
59
+ }
@@ -0,0 +1,20 @@
1
+ // The container for all the Pict-Section-Picker related code.
2
+ //
3
+ // pict-section-picker is a themeable, pict-native searchable select / combobox: a jQuery- and
4
+ // select2-free widget supporting single & multi select, search, server pagination, categorized
5
+ // groups and creatable entries. It is driven by a host-agnostic async DataProvider, and ships a
6
+ // built-in adapter for the pict EntityProvider (Meadow entities) for the common case.
7
+
8
+ // The picker provider (primary API surface — registers the widget view + CSS, and exposes
9
+ // createPicker() / data-source adapters).
10
+ const PictProviderPicker = require('./providers/Pict-Provider-Picker.js');
11
+
12
+ // The widget view (also auto-registered by the provider).
13
+ const PictViewPicker = require('./views/PictView-Picker.js');
14
+
15
+ module.exports = PictProviderPicker;
16
+
17
+ module.exports.PictProviderPicker = PictProviderPicker;
18
+ module.exports.PictViewPicker = PictViewPicker;
19
+
20
+ module.exports.default_configuration = PictProviderPicker.default_configuration;
@@ -0,0 +1,255 @@
1
+ const libPictProvider = require('pict-provider');
2
+
3
+ const libPictViewPicker = require('../views/PictView-Picker.js');
4
+
5
+ // Themeable widget CSS. Registered once (by hash) regardless of how many picker instances exist.
6
+ // Host apps brand by defining the --theme-color-* tokens; the hardcoded values are fallbacks.
7
+ const _PickerCSS = /*css*/`
8
+ .pps { position: relative; width: 100%; box-sizing: border-box; }
9
+ .pps *, .pps *::before, .pps *::after { box-sizing: border-box; }
10
+ .pps-control { display: flex; align-items: center; gap: 0.5rem; width: 100%; cursor: pointer; font: inherit; font-size: 0.92rem;
11
+ padding: 0.45rem 0.7rem; border-radius: 8px; border: 1px solid var(--theme-color-border-default, #d7dce3);
12
+ background: var(--theme-color-background-primary, #fff); color: var(--theme-color-text-primary, #1f2733); text-align: left; }
13
+ .pps-control:hover { border-color: var(--theme-color-border-strong, #c2c9d2); }
14
+ .pps.pps-open .pps-control { border-color: var(--theme-color-brand-primary, #156dd1);
15
+ box-shadow: 0 0 0 3px color-mix(in srgb, var(--theme-color-brand-primary, #156dd1) 16%, transparent); }
16
+ .pps-valuearea { flex: 1 1 auto; min-width: 0; }
17
+ .pps-value { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
18
+ .pps-value.pps-placeholder { color: var(--theme-color-text-muted, #6b7686); }
19
+ .pps-chevron { flex: 0 0 auto; display: inline-flex; color: var(--theme-color-text-muted, #6b7686); font-size: 0.8rem; transition: transform 0.15s ease; }
20
+ .pps.pps-open .pps-chevron { transform: rotate(180deg); }
21
+
22
+ /* Multi-select chips. The control hosts a wrapping row of removable tags + a muted placeholder. */
23
+ .pps-multi .pps-control { align-items: flex-start; }
24
+ .pps-chips { display: flex; flex-wrap: wrap; align-items: center; gap: 0.3rem; min-width: 0; }
25
+ .pps-chips-ph { color: var(--theme-color-text-muted, #6b7686); }
26
+ .pps-chip { display: inline-flex; align-items: center; gap: 0.3rem; max-width: 100%; font-size: 0.82rem; line-height: 1.4;
27
+ padding: 0.1rem 0.2rem 0.1rem 0.5rem; border-radius: 6px;
28
+ background: color-mix(in srgb, var(--theme-color-brand-primary, #156dd1) 12%, transparent);
29
+ color: var(--theme-color-brand-primary, #156dd1); }
30
+ .pps-chip-text { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
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
+ .pps-chip-x:hover { opacity: 1; background: color-mix(in srgb, var(--theme-color-brand-primary, #156dd1) 22%, transparent); }
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; }
37
+ .pps.pps-open .pps-pop { display: block; }
38
+ .pps-panel { position: relative; z-index: 1; display: flex; flex-direction: column; max-height: min(60vh, 360px);
39
+ background: var(--theme-color-background-panel, #fff); border: 1px solid var(--theme-color-border-default, #d7dce3);
40
+ border-radius: 10px; box-shadow: 0 10px 28px rgba(17, 24, 39, 0.14); overflow: hidden; }
41
+ .pps-search { flex: 0 0 auto; display: flex; align-items: center; gap: 0.4rem; padding: 0.5rem 0.7rem; border-bottom: 1px solid var(--theme-color-border-light, #e8ebf0); }
42
+ .pps-search-ic { display: inline-flex; color: var(--theme-color-text-muted, #6b7686); font-size: 0.9rem; }
43
+ .pps-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); }
44
+ .pps-list { flex: 1 1 auto; overflow-y: auto; padding: 0.25rem; }
45
+ .pps-option { display: flex; align-items: center; gap: 0.5rem; width: 100%; text-align: left; cursor: pointer; font: inherit; font-size: 0.9rem;
46
+ padding: 0.45rem 0.6rem; border: none; border-radius: 6px; background: transparent; color: var(--theme-color-text-primary, #1f2733); }
47
+ .pps-option:hover, .pps-option.pps-highlight { background: var(--theme-color-background-tertiary, #eceef2); }
48
+ .pps-option.pps-selected { color: var(--theme-color-brand-primary, #156dd1); font-weight: 600; }
49
+ .pps-option-check { flex: 0 0 auto; display: inline-flex; width: 1em; color: var(--theme-color-brand-primary, #156dd1); }
50
+ .pps-option-check.pps-hidden { visibility: hidden; }
51
+ .pps-empty { padding: 0.7rem 0.6rem; color: var(--theme-color-text-muted, #6b7686); font-size: 0.86rem; text-align: center; }
52
+ .pps-loading { padding: 0.6rem; text-align: center; color: var(--theme-color-text-muted, #6b7686); font-size: 0.85rem; }
53
+ .pps-more { display: block; width: calc(100% - 0.5rem); margin: 0.25rem; padding: 0.4rem; cursor: pointer; font: inherit; font-size: 0.85rem;
54
+ border: 1px solid var(--theme-color-border-light, #e8ebf0); border-radius: 6px; background: transparent; color: var(--theme-color-brand-primary, #156dd1); }
55
+ .pps-more:hover { background: var(--theme-color-background-tertiary, #eceef2); }
56
+
57
+ /* Category header (groups) + creatable "Create …" row. */
58
+ .pps-group { padding: 0.45rem 0.6rem 0.2rem; font-size: 0.72rem; font-weight: 600; letter-spacing: 0.04em; text-transform: uppercase; color: var(--theme-color-text-muted, #6b7686); }
59
+ .pps-create { display: flex; align-items: center; gap: 0.5rem; width: 100%; text-align: left; cursor: pointer; font: inherit; font-size: 0.9rem;
60
+ padding: 0.45rem 0.6rem; border: none; border-radius: 6px; background: transparent; color: var(--theme-color-brand-primary, #156dd1); font-weight: 600; }
61
+ .pps-create:hover { background: var(--theme-color-background-tertiary, #eceef2); }
62
+ .pps-create-ic { flex: 0 0 auto; display: inline-flex; }
63
+ `;
64
+
65
+ /** @type {Record<string, any>} */
66
+ const _DEFAULT_CONFIGURATION =
67
+ {
68
+ ProviderIdentifier: 'Pict-Section-Picker',
69
+
70
+ AutoInitialize: true,
71
+ AutoInitializeOrdinal: 0,
72
+ };
73
+
74
+ /**
75
+ * The pict-section-picker provider — the primary API surface. Registers the widget CSS once and
76
+ * creates/manages picker view instances.
77
+ */
78
+ class PictProviderPicker extends libPictProvider
79
+ {
80
+ constructor(pFable, pOptions, pServiceHash)
81
+ {
82
+ let tmpOptions = Object.assign({}, _DEFAULT_CONFIGURATION, pOptions);
83
+ super(pFable, tmpOptions, pServiceHash);
84
+
85
+ if (this.pict && this.pict.CSSMap && typeof this.pict.CSSMap.addCSS === 'function')
86
+ {
87
+ this.pict.CSSMap.addCSS('Pict-Section-Picker-CSS', _PickerCSS, 500);
88
+ }
89
+ }
90
+
91
+ /**
92
+ * Create (or reconfigure + reuse) a picker widget instance.
93
+ *
94
+ * @param {string} pPickerHash - A unique hash/id for this picker; the widget renders its control
95
+ * into the DOM element `#<pPickerHash>` unless a DestinationAddress is given.
96
+ * @param {Record<string, any>} pConfig - Picker configuration:
97
+ * - DestinationAddress {string} - CSS selector to render into (default `#<pPickerHash>`).
98
+ * - ValueAddress {string} - AppData address the selection is read from / written to.
99
+ * - Mode {'single'} - selection mode (multi/categories/creatable land in later phases).
100
+ * - Placeholder {string} - text shown when nothing is selected.
101
+ * - Searchable {boolean} - show the search box (default true).
102
+ * - Options {Array<{Value:any, Text:string}>} - static option list.
103
+ * - OnChange {function} - optional callback invoked with the new value after a selection.
104
+ * @return {any} The picker view instance.
105
+ */
106
+ createPicker(pPickerHash, pConfig)
107
+ {
108
+ const tmpConfig = Object.assign(
109
+ {
110
+ DestinationAddress: `#${pPickerHash}`,
111
+ Mode: 'single',
112
+ Searchable: true,
113
+ Placeholder: 'Select…',
114
+ Options: [],
115
+ },
116
+ pConfig || {},
117
+ { PickerHash: pPickerHash });
118
+
119
+ if (this.pict.views[pPickerHash])
120
+ {
121
+ Object.assign(this.pict.views[pPickerHash].options, tmpConfig);
122
+ return this.pict.views[pPickerHash];
123
+ }
124
+ return this.pict.addView(pPickerHash, tmpConfig, libPictViewPicker);
125
+ }
126
+
127
+ /**
128
+ * Build the Meadow FoxHound filter for a search term across one or more fields.
129
+ *
130
+ * Single field → a clean AND-connected `FBV~Field~LK~%term%`. Multiple fields → the LIKEs are
131
+ * OR'd together inside a paren group (`FOP…FCP`) so the OR can't bleed into a sibling AND base
132
+ * filter. The term is `encodeURIComponent`-wrapped exactly as pict-section-recordset does, so the
133
+ * structural `~` separators stay literal in the URL path while the value is escaped.
134
+ *
135
+ * @param {Array<string>} pSearchFields - The entity fields to LIKE-match.
136
+ * @param {string} pTerm - The (raw) search term.
137
+ * @return {string} The FoxHound filter stanza(s).
138
+ */
139
+ buildSearchFilter(pSearchFields, pTerm)
140
+ {
141
+ const tmpEncoded = encodeURIComponent(`%${pTerm}%`);
142
+ if (pSearchFields.length === 1)
143
+ {
144
+ return `FBV~${pSearchFields[0]}~LK~${tmpEncoded}`;
145
+ }
146
+ const tmpInner = pSearchFields.map((pField, pIndex) => `${pIndex === 0 ? 'FBV' : 'FBVOR'}~${pField}~LK~${tmpEncoded}`).join('~');
147
+ return `FOP~0~(~0~${tmpInner}~FCP~0~)~0`;
148
+ }
149
+
150
+ /**
151
+ * Build an async picker DataProvider backed by a Meadow entity (via `pict.EntityProvider`).
152
+ * Returns a `(searchTerm, page) => Promise<{ results:[{Value,Text,Record}], hasMore }>` function —
153
+ * the exact contract PictViewPicker consumes for server search + pagination.
154
+ *
155
+ * @param {Record<string, any>} pConfig - Entity source configuration:
156
+ * - Entity {string} (required) - the Meadow entity name (e.g. `Author`).
157
+ * - SearchFields {Array<string>} - fields to LIKE-search (default `['Name']`).
158
+ * - ValueField {string} - record field used as the option Value (default `ID<Entity>`).
159
+ * - TextField {string} - record field used as the option Text (default `Name`).
160
+ * - PageSize {number} - records per page (default 20).
161
+ * - 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`.
163
+ * - MapRecord {function} - optional `(record) => {Value, Text}` mapper (overrides Value/TextField).
164
+ * @return {(pSearchTerm: string, pPage: number) => Promise<{results: Array<any>, hasMore: boolean}>}
165
+ */
166
+ createEntityDataProvider(pConfig)
167
+ {
168
+ const tmpEntity = pConfig.Entity;
169
+ const tmpSearchFields = (Array.isArray(pConfig.SearchFields) && pConfig.SearchFields.length > 0) ? pConfig.SearchFields : [ 'Name' ];
170
+ const tmpValueField = pConfig.ValueField || `ID${tmpEntity}`;
171
+ const tmpTextField = pConfig.TextField || 'Name';
172
+ const tmpPageSize = pConfig.PageSize || 20;
173
+ const tmpSort = pConfig.Sort || false;
174
+ const tmpBaseFilter = pConfig.BaseFilter || '';
175
+ const tmpMapRecord = (typeof pConfig.MapRecord === 'function') ? pConfig.MapRecord : false;
176
+
177
+ return (pSearchTerm, pPage) => new Promise((resolve, reject) =>
178
+ {
179
+ if (!this.pict.EntityProvider || typeof this.pict.EntityProvider.getEntitySetPage !== 'function')
180
+ {
181
+ return reject(new Error('Pict-Section-Picker: pict.EntityProvider is not available for entity-backed pickers.'));
182
+ }
183
+
184
+ const tmpStanzas = [];
185
+ if (tmpBaseFilter) { tmpStanzas.push(tmpBaseFilter); }
186
+ if (pSearchTerm) { tmpStanzas.push(this.buildSearchFilter(tmpSearchFields, pSearchTerm)); }
187
+ if (tmpSort) { tmpStanzas.push(`FSF~${tmpSort}~ASC~0`); }
188
+ const tmpFilter = tmpStanzas.filter(Boolean).join('~');
189
+
190
+ const tmpCursor = (pPage || 0) * tmpPageSize;
191
+ this.pict.EntityProvider.getEntitySetPage(tmpEntity, tmpFilter, tmpCursor, tmpPageSize,
192
+ (pError, pRecords) =>
193
+ {
194
+ if (pError) { return reject(pError); }
195
+ 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) });
201
+ });
202
+ });
203
+ }
204
+
205
+ /**
206
+ * Build a `ResolveValue(value) => Promise<{Value,Text}>` for an entity-backed picker, so a
207
+ * pre-bound ID resolves to its display text on first render (fetched + cached by `getEntity`).
208
+ *
209
+ * @param {Record<string, any>} pConfig - Same shape as {@link createEntityDataProvider}.
210
+ * @return {(pValue: any) => Promise<any>}
211
+ */
212
+ createEntityResolveValue(pConfig)
213
+ {
214
+ const tmpEntity = pConfig.Entity;
215
+ const tmpValueField = pConfig.ValueField || `ID${tmpEntity}`;
216
+ const tmpTextField = pConfig.TextField || 'Name';
217
+ const tmpMapRecord = (typeof pConfig.MapRecord === 'function') ? pConfig.MapRecord : false;
218
+
219
+ return (pValue) => new Promise((resolve) =>
220
+ {
221
+ if (pValue === undefined || pValue === null || pValue === '' || !this.pict.EntityProvider)
222
+ {
223
+ return resolve(null);
224
+ }
225
+ this.pict.EntityProvider.getEntity(tmpEntity, pValue,
226
+ (pError, pRecord) =>
227
+ {
228
+ if (pError || !pRecord) { return resolve(null); }
229
+ return resolve(tmpMapRecord ? tmpMapRecord(pRecord) : { Value: pRecord[tmpValueField], Text: pRecord[tmpTextField], Record: pRecord });
230
+ });
231
+ });
232
+ }
233
+
234
+ /**
235
+ * Create a picker backed by a Meadow entity — the high-level entry point for the real
236
+ * lims/config/bridge entity pickers. Wires a server DataProvider + ResolveValue from `pConfig`
237
+ * and delegates to {@link createPicker}. Any picker option (DestinationAddress, ValueAddress,
238
+ * Placeholder, OnChange, …) may also be supplied and is passed through.
239
+ *
240
+ * @param {string} pPickerHash - Unique hash/id for this picker.
241
+ * @param {Record<string, any>} pConfig - {@link createEntityDataProvider} config + picker options.
242
+ * @return {any} The picker view instance.
243
+ */
244
+ createEntityPicker(pPickerHash, pConfig)
245
+ {
246
+ const tmpConfig = Object.assign({}, pConfig);
247
+ tmpConfig.DataProvider = this.createEntityDataProvider(pConfig);
248
+ if (!tmpConfig.ResolveValue) { tmpConfig.ResolveValue = this.createEntityResolveValue(pConfig); }
249
+ return this.createPicker(pPickerHash, tmpConfig);
250
+ }
251
+ }
252
+
253
+ module.exports = PictProviderPicker;
254
+
255
+ module.exports.default_configuration = _DEFAULT_CONFIGURATION;