@ukhomeoffice/formio-gds-template 2.0.2 → 3.0.0-bravo
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/.github/workflows/build-and-test.yml +3 -3
- package/LICENCE.md +21 -0
- package/README.md +8 -0
- package/eslint.config.mjs +59 -0
- package/lib/components/datamap/GDSDataMap.d.ts +5 -14
- package/lib/components/datamap/GDSDataMap.d.ts.map +1 -0
- package/lib/components/datamap/GDSDataMap.js +9 -29
- package/lib/components/datetime/GDSDatetimeComponent.d.ts +43 -22
- package/lib/components/datetime/GDSDatetimeComponent.d.ts.map +1 -0
- package/lib/components/datetime/GDSDatetimeComponent.js +167 -178
- package/lib/components/index.d.ts +3 -0
- package/lib/components/index.d.ts.map +1 -0
- package/lib/components/index.js +6 -4
- package/lib/components/select/GDSSelect.d.ts +143 -0
- package/lib/components/select/GDSSelect.d.ts.map +1 -0
- package/lib/components/select/GDSSelect.js +340 -0
- package/lib/components/selectboxes/GDSCheckBoxes.d.ts +5 -2
- package/lib/components/selectboxes/GDSCheckBoxes.d.ts.map +1 -0
- package/lib/components/selectboxes/GDSCheckBoxes.js +7 -33
- package/lib/components/time/GDSTimeComponent.d.ts +13 -10
- package/lib/components/time/GDSTimeComponent.d.ts.map +1 -0
- package/lib/components/time/GDSTimeComponent.js +59 -84
- package/lib/components/util/TimeHelper.d.ts +6 -4
- package/lib/components/util/TimeHelper.d.ts.map +1 -0
- package/lib/components/util/TimeHelper.js +13 -16
- package/lib/index.d.ts +4 -2
- package/lib/index.d.ts.map +1 -0
- package/lib/index.js +2 -2
- package/lib/templates/gds/button/index.d.ts +1 -0
- package/lib/templates/gds/button/index.d.ts.map +1 -0
- package/lib/templates/gds/button/index.js +1 -1
- package/lib/templates/gds/checkbox/index.d.ts +1 -0
- package/lib/templates/gds/checkbox/index.d.ts.map +1 -0
- package/lib/templates/gds/checkbox/index.js +1 -1
- package/lib/templates/gds/columns/index.d.ts +1 -0
- package/lib/templates/gds/columns/index.d.ts.map +1 -0
- package/lib/templates/gds/columns/index.js +1 -1
- package/lib/templates/gds/component/index.d.ts +1 -0
- package/lib/templates/gds/component/index.d.ts.map +1 -0
- package/lib/templates/gds/component/index.js +1 -1
- package/lib/templates/gds/container/index.d.ts +1 -0
- package/lib/templates/gds/container/index.d.ts.map +1 -0
- package/lib/templates/gds/container/index.js +1 -1
- package/lib/templates/gds/cssClasses.d.ts +1 -0
- package/lib/templates/gds/cssClasses.d.ts.map +1 -0
- package/lib/templates/gds/datagrid/form.ejs.js +2 -2
- package/lib/templates/gds/datagrid/index.d.ts +1 -0
- package/lib/templates/gds/datagrid/index.d.ts.map +1 -0
- package/lib/templates/gds/datagrid/index.js +1 -1
- package/lib/templates/gds/datamap/index.d.ts +1 -0
- package/lib/templates/gds/datamap/index.d.ts.map +1 -0
- package/lib/templates/gds/datamap/index.js +1 -1
- package/lib/templates/gds/datetime/index.d.ts +1 -0
- package/lib/templates/gds/datetime/index.d.ts.map +1 -0
- package/lib/templates/gds/datetime/index.js +1 -1
- package/lib/templates/gds/day/index.d.ts +1 -0
- package/lib/templates/gds/day/index.d.ts.map +1 -0
- package/lib/templates/gds/day/index.js +1 -1
- package/lib/templates/gds/editgrid/index.d.ts +1 -0
- package/lib/templates/gds/editgrid/index.d.ts.map +1 -0
- package/lib/templates/gds/editgrid/index.js +1 -1
- package/lib/templates/gds/field/index.d.ts +1 -0
- package/lib/templates/gds/field/index.d.ts.map +1 -0
- package/lib/templates/gds/field/index.js +1 -1
- package/lib/templates/gds/fieldset/index.d.ts +1 -0
- package/lib/templates/gds/fieldset/index.d.ts.map +1 -0
- package/lib/templates/gds/fieldset/index.js +1 -1
- package/lib/templates/gds/file/index.d.ts +1 -0
- package/lib/templates/gds/file/index.d.ts.map +1 -0
- package/lib/templates/gds/file/index.js +1 -1
- package/lib/templates/gds/iconClass.d.ts +2 -1
- package/lib/templates/gds/iconClass.d.ts.map +1 -0
- package/lib/templates/gds/iconClass.js +3 -3
- package/lib/templates/gds/index.d.ts +3 -2
- package/lib/templates/gds/index.d.ts.map +1 -0
- package/lib/templates/gds/index.js +72 -64
- package/lib/templates/gds/input/index.d.ts +1 -0
- package/lib/templates/gds/input/index.d.ts.map +1 -0
- package/lib/templates/gds/input/index.js +1 -1
- package/lib/templates/gds/label/index.d.ts +1 -0
- package/lib/templates/gds/label/index.d.ts.map +1 -0
- package/lib/templates/gds/label/index.js +1 -1
- package/lib/templates/gds/message/index.d.ts +1 -0
- package/lib/templates/gds/message/index.d.ts.map +1 -0
- package/lib/templates/gds/message/index.js +1 -1
- package/lib/templates/gds/panel/index.d.ts +1 -0
- package/lib/templates/gds/panel/index.d.ts.map +1 -0
- package/lib/templates/gds/panel/index.js +1 -1
- package/lib/templates/gds/radio/index.d.ts +1 -0
- package/lib/templates/gds/radio/index.d.ts.map +1 -0
- package/lib/templates/gds/radio/index.js +1 -1
- package/lib/templates/gds/select/index.d.ts +1 -0
- package/lib/templates/gds/select/index.d.ts.map +1 -0
- package/lib/templates/gds/select/index.js +1 -1
- package/lib/templates/gds/selectOption/index.d.ts +1 -0
- package/lib/templates/gds/selectOption/index.d.ts.map +1 -0
- package/lib/templates/gds/selectOption/index.js +1 -1
- package/lib/templates/gds/selectboxes/index.d.ts +1 -0
- package/lib/templates/gds/selectboxes/index.d.ts.map +1 -0
- package/lib/templates/gds/selectboxes/index.js +1 -1
- package/lib/templates/gds/survey/index.d.ts +1 -0
- package/lib/templates/gds/survey/index.d.ts.map +1 -0
- package/lib/templates/gds/survey/index.js +1 -1
- package/lib/templates/gds/tab/index.d.ts +1 -0
- package/lib/templates/gds/tab/index.d.ts.map +1 -0
- package/lib/templates/gds/tab/index.js +1 -1
- package/lib/templates/gds/table/index.d.ts +1 -0
- package/lib/templates/gds/table/index.d.ts.map +1 -0
- package/lib/templates/gds/table/index.js +1 -1
- package/lib/templates/gds/template.css +27 -0
- package/lib/templates/gds/template.css.map +1 -1
- package/lib/templates/gds/time/index.d.ts +1 -0
- package/lib/templates/gds/time/index.d.ts.map +1 -0
- package/lib/templates/gds/time/index.js +1 -1
- package/lib/templates/gds/warning/index.d.ts +1 -0
- package/lib/templates/gds/warning/index.d.ts.map +1 -0
- package/lib/templates/gds/warning/index.js +1 -1
- package/lib/templates/gds/wizard/index.d.ts +1 -0
- package/lib/templates/gds/wizard/index.d.ts.map +1 -0
- package/lib/templates/gds/wizard/index.js +1 -1
- package/lib/templates/gds/wizardHeader/index.d.ts +1 -0
- package/lib/templates/gds/wizardHeader/index.d.ts.map +1 -0
- package/lib/templates/gds/wizardHeader/index.js +1 -1
- package/lib/templates/gds/wizardNav/index.d.ts +1 -0
- package/lib/templates/gds/wizardNav/index.d.ts.map +1 -0
- package/lib/templates/gds/wizardNav/index.js +1 -1
- package/lib/templates/index.d.ts +3 -2
- package/lib/templates/index.d.ts.map +1 -0
- package/lib/templates/index.js +1 -1
- package/lib/types.d.ts +37 -0
- package/lib/types.d.ts.map +1 -0
- package/lib/types.js +2 -0
- package/package.json +14 -7
- package/src/components/datamap/GDSDataMap.ts +5 -14
- package/src/components/datetime/GDSDatetimeComponent.ts +128 -91
- package/src/components/index.ts +2 -0
- package/src/components/select/GDSSelect.spec.ts +538 -0
- package/src/components/select/GDSSelect.ts +418 -0
- package/src/components/selectboxes/GDSCheckBoxes.ts +7 -8
- package/src/components/time/GDSTimeComponent.ts +37 -27
- package/src/components/util/TimeHelper.ts +6 -5
- package/src/css.d.ts +4 -0
- package/src/templates/gds/datagrid/form.ejs +1 -2
- package/src/templates/gds/iconClass.ts +1 -1
- package/src/templates/gds/index.ts +43 -33
- package/src/templates/gds/template.scss +48 -1
- package/src/types.ts +56 -0
- package/tsconfig.json +21 -5
- package/webpack.config.js +1 -1
- package/tslint.json +0 -22
|
@@ -0,0 +1,538 @@
|
|
|
1
|
+
import assert from 'assert';
|
|
2
|
+
|
|
3
|
+
// Mock minimal browser globals needed by Form.io components in Node test environment
|
|
4
|
+
/* eslint-disable @typescript-eslint/no-explicit-any */
|
|
5
|
+
(global as any).matchMedia = (global as any).matchMedia || (() => ({
|
|
6
|
+
matches: false,
|
|
7
|
+
addListener: () => {},
|
|
8
|
+
removeListener: () => {},
|
|
9
|
+
}));
|
|
10
|
+
const mockDoc = {
|
|
11
|
+
createElement: (): Record<string, any> => ({
|
|
12
|
+
setAttribute: () => {},
|
|
13
|
+
style: {},
|
|
14
|
+
classList: { add: () => {}, remove: () => {} },
|
|
15
|
+
addEventListener: () => {},
|
|
16
|
+
removeEventListener: () => {},
|
|
17
|
+
querySelector: () => null,
|
|
18
|
+
querySelectorAll: () => [],
|
|
19
|
+
}),
|
|
20
|
+
documentElement: {
|
|
21
|
+
style: {},
|
|
22
|
+
},
|
|
23
|
+
head: {
|
|
24
|
+
appendChild: () => {},
|
|
25
|
+
},
|
|
26
|
+
body: {
|
|
27
|
+
appendChild: () => {},
|
|
28
|
+
querySelector: () => null,
|
|
29
|
+
querySelectorAll: () => [],
|
|
30
|
+
},
|
|
31
|
+
getElementsByTagName: (): any[] => [],
|
|
32
|
+
addEventListener: () => {},
|
|
33
|
+
removeEventListener: () => {},
|
|
34
|
+
cookie: '',
|
|
35
|
+
};
|
|
36
|
+
(global as any).document = mockDoc;
|
|
37
|
+
const mockWindow: Record<string, any> = {
|
|
38
|
+
addEventListener: () => {},
|
|
39
|
+
removeEventListener: () => {},
|
|
40
|
+
document: mockDoc,
|
|
41
|
+
navigator: { userAgent: 'node', cookieEnabled: true },
|
|
42
|
+
localStorage: { getItem: () => null, setItem: () => {}, removeItem: () => {} },
|
|
43
|
+
sessionStorage: { getItem: () => null, setItem: () => {}, removeItem: () => {} },
|
|
44
|
+
location: { href: 'http://localhost', origin: 'http://localhost', protocol: 'http:', host: 'localhost', hostname: 'localhost', port: '', pathname: '/', search: '', hash: '' },
|
|
45
|
+
};
|
|
46
|
+
(global as any).window = mockWindow;
|
|
47
|
+
// inputmask accesses `navigator` as a bare global (not via window.navigator).
|
|
48
|
+
// Node 24 exposes navigator as a read-only getter so we must use defineProperty.
|
|
49
|
+
Object.defineProperty(global, 'navigator', {
|
|
50
|
+
value: mockWindow.navigator,
|
|
51
|
+
writable: true,
|
|
52
|
+
configurable: true,
|
|
53
|
+
});
|
|
54
|
+
/* eslint-enable @typescript-eslint/no-explicit-any */
|
|
55
|
+
|
|
56
|
+
import GDSSelect from './GDSSelect';
|
|
57
|
+
|
|
58
|
+
describe('GDSSelect', () => {
|
|
59
|
+
it('forces client-side search by clearing searchField during init', () => {
|
|
60
|
+
const selectComponent = new GDSSelect({
|
|
61
|
+
type: 'select',
|
|
62
|
+
key: 'officer',
|
|
63
|
+
searchField: 'email',
|
|
64
|
+
dataSrc: 'url',
|
|
65
|
+
data: { url: '/users/emails' },
|
|
66
|
+
}, {});
|
|
67
|
+
|
|
68
|
+
selectComponent.init();
|
|
69
|
+
assert.strictEqual(selectComponent.component.searchField, '');
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it('enables Choices search so the patched matcher owns filtering', () => {
|
|
73
|
+
const selectComponent = new GDSSelect({
|
|
74
|
+
type: 'select',
|
|
75
|
+
key: 'officer',
|
|
76
|
+
searchField: 'email',
|
|
77
|
+
searchThreshold: 0,
|
|
78
|
+
selectThreshold: 0.5,
|
|
79
|
+
dataSrc: 'url',
|
|
80
|
+
data: { url: '/users/emails' },
|
|
81
|
+
customOptions: {
|
|
82
|
+
searchFloor: 3,
|
|
83
|
+
},
|
|
84
|
+
}, {});
|
|
85
|
+
|
|
86
|
+
const options = selectComponent.choicesOptions();
|
|
87
|
+
assert.strictEqual(options.searchChoices, true);
|
|
88
|
+
assert.strictEqual(options.searchFloor, 0);
|
|
89
|
+
assert.deepStrictEqual(options.searchFields, ['label', 'value']);
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it('keeps Choices search enabled for reference data', () => {
|
|
93
|
+
const selectComponent = new GDSSelect({
|
|
94
|
+
type: 'select',
|
|
95
|
+
key: 'currency',
|
|
96
|
+
searchThreshold: 0.3,
|
|
97
|
+
dataSrc: 'url',
|
|
98
|
+
data: { url: '/v2/entities/currency' },
|
|
99
|
+
}, {});
|
|
100
|
+
|
|
101
|
+
const options = selectComponent.choicesOptions();
|
|
102
|
+
assert.strictEqual(options.searchChoices, true);
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
it('updateItems re-populates choices from cache after activate() shows spinner', () => {
|
|
106
|
+
// Regression: activate() calls setLoadingItem() (spinner) then triggerUpdate()
|
|
107
|
+
// -> updateItems(null, false). Without re-calling setItems the spinner persists.
|
|
108
|
+
/* eslint-disable @typescript-eslint/no-explicit-any */
|
|
109
|
+
const selectComponent = new GDSSelect({
|
|
110
|
+
type: 'select',
|
|
111
|
+
key: 'terminal',
|
|
112
|
+
dataSrc: 'url',
|
|
113
|
+
filter: '?mode=dataOnly',
|
|
114
|
+
data: { url: '/v2/entities/terminal' },
|
|
115
|
+
}, {});
|
|
116
|
+
|
|
117
|
+
const component = selectComponent as any;
|
|
118
|
+
// Patch GDSSelect.prototype.setItems (our override) — updateItems calls
|
|
119
|
+
// this.setItems which resolves to GDSSelect.prototype first.
|
|
120
|
+
const gdsProto = GDSSelect.prototype as any;
|
|
121
|
+
const original = gdsProto.setItems;
|
|
122
|
+
let setItemsCalled = false;
|
|
123
|
+
let setItemsArg: unknown;
|
|
124
|
+
gdsProto.setItems = (items: unknown) => { setItemsCalled = true; setItemsArg = items; };
|
|
125
|
+
|
|
126
|
+
const cachedItems = [{ id: 1, name: 'Eastern Docks' }, { id: 2, name: 'Western Docks' }];
|
|
127
|
+
component.downloadedResources = cachedItems;
|
|
128
|
+
component.loading = false;
|
|
129
|
+
component.itemsLoadedResolve = () => {};
|
|
130
|
+
// filter has no {{ so _gdsHasDynamicFilter is false and _gdsCurrentFilterKey()
|
|
131
|
+
// returns ''. _gdsFilterKey must match so the guard enters the cache path.
|
|
132
|
+
component._gdsFilterKey = '';
|
|
133
|
+
component.interpolate = () => '';
|
|
134
|
+
|
|
135
|
+
try {
|
|
136
|
+
component.updateItems(null, false);
|
|
137
|
+
assert.strictEqual(setItemsCalled, true, 'setItems must be called to restore choices from cache');
|
|
138
|
+
assert.strictEqual(setItemsArg, cachedItems, 'setItems must receive the cached downloadedResources');
|
|
139
|
+
} finally {
|
|
140
|
+
gdsProto.setItems = original;
|
|
141
|
+
}
|
|
142
|
+
/* eslint-enable @typescript-eslint/no-explicit-any */
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
it('updateItems skips URL re-fetch when items and the dynamic filter are unchanged', () => {
|
|
146
|
+
/* eslint-disable @typescript-eslint/no-explicit-any */
|
|
147
|
+
const selectComponent = new GDSSelect({
|
|
148
|
+
type: 'select',
|
|
149
|
+
key: 'currency',
|
|
150
|
+
dataSrc: 'url',
|
|
151
|
+
filter: '{{row.countryFilter}}',
|
|
152
|
+
data: { url: '/v2/entities/currency' },
|
|
153
|
+
}, {});
|
|
154
|
+
|
|
155
|
+
let superCalled = false;
|
|
156
|
+
// Patch the parent updateItems to detect if it is called
|
|
157
|
+
const proto = Object.getPrototypeOf(GDSSelect.prototype);
|
|
158
|
+
const original = proto.updateItems;
|
|
159
|
+
proto.updateItems = () => { superCalled = true; };
|
|
160
|
+
|
|
161
|
+
(selectComponent as any).downloadedResources = [{ value: 'USD', label: 'USD' }];
|
|
162
|
+
(selectComponent as any).itemsLoadedResolve = () => {};
|
|
163
|
+
(selectComponent as any)._gdsFilterKey = '';
|
|
164
|
+
(selectComponent as any).interpolate = () => '';
|
|
165
|
+
|
|
166
|
+
// Should NOT delegate to parent: items loaded, no search, no force
|
|
167
|
+
(selectComponent as any).updateItems(null, false);
|
|
168
|
+
assert.strictEqual(superCalled, false, 'should skip re-fetch when items are loaded');
|
|
169
|
+
|
|
170
|
+
// Should delegate to parent: force=true overrides the guard
|
|
171
|
+
(selectComponent as any).updateItems(null, true);
|
|
172
|
+
assert.strictEqual(superCalled, true, 'should allow re-fetch when forceUpdate is true');
|
|
173
|
+
|
|
174
|
+
proto.updateItems = original;
|
|
175
|
+
/* eslint-enable @typescript-eslint/no-explicit-any */
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
it('refreshes URL selects when an interpolated filter has changed', () => {
|
|
179
|
+
/* eslint-disable @typescript-eslint/no-explicit-any */
|
|
180
|
+
const selectComponent = new GDSSelect({
|
|
181
|
+
type: 'select',
|
|
182
|
+
key: 'port',
|
|
183
|
+
dataSrc: 'url',
|
|
184
|
+
filter: '{{row.countryFilter}}&mode=dataOnly',
|
|
185
|
+
data: { url: '/v2/entities/port' },
|
|
186
|
+
}, {});
|
|
187
|
+
|
|
188
|
+
let superCalled = false;
|
|
189
|
+
const proto = Object.getPrototypeOf(GDSSelect.prototype);
|
|
190
|
+
const original = proto.updateItems;
|
|
191
|
+
proto.updateItems = () => { superCalled = true; };
|
|
192
|
+
|
|
193
|
+
(selectComponent as any).downloadedResources = [{ value: 'PORT-1', label: 'Port 1' }];
|
|
194
|
+
(selectComponent as any).itemsLoadedResolve = () => {};
|
|
195
|
+
(selectComponent as any)._gdsFilterKey = 'countryid=eq.1';
|
|
196
|
+
(selectComponent as any).interpolate = () => 'countryid=eq.2';
|
|
197
|
+
|
|
198
|
+
(selectComponent as any).updateItems(null, false);
|
|
199
|
+
|
|
200
|
+
assert.strictEqual(superCalled, true, 'dynamic filters must be re-evaluated');
|
|
201
|
+
proto.updateItems = original;
|
|
202
|
+
/* eslint-enable @typescript-eslint/no-explicit-any */
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
// Regression: a select that is already visible is never redrawn by Form.io
|
|
206
|
+
// when its filter dependency changes, so changing the country a second time
|
|
207
|
+
// left the previous country's ports in place.
|
|
208
|
+
it('reloads an already-visible select when its filter dependency changes', () => {
|
|
209
|
+
/* eslint-disable @typescript-eslint/no-explicit-any */
|
|
210
|
+
const selectComponent = new GDSSelect({
|
|
211
|
+
type: 'select',
|
|
212
|
+
key: 'departurePort',
|
|
213
|
+
dataSrc: 'url',
|
|
214
|
+
filter: '{{row.departurePortFilter}}&mode=dataOnly',
|
|
215
|
+
data: { url: '/v2/entities/port' },
|
|
216
|
+
}, {});
|
|
217
|
+
|
|
218
|
+
const component = selectComponent as any;
|
|
219
|
+
const proto = Object.getPrototypeOf(GDSSelect.prototype);
|
|
220
|
+
const originalCheck = proto.checkComponentConditions;
|
|
221
|
+
// The component stays visible across both changes.
|
|
222
|
+
proto.checkComponentConditions = () => true;
|
|
223
|
+
|
|
224
|
+
const updates: unknown[][] = [];
|
|
225
|
+
component.triggerUpdate = (...args: unknown[]) => updates.push(args);
|
|
226
|
+
component.downloadedResources = [{ value: 'PORT-1', label: 'Port 1' }];
|
|
227
|
+
component.itemsLoadedResolve = () => {};
|
|
228
|
+
component._gdsFilterKey = 'filter=countryid=eq.1&mode=dataOnly';
|
|
229
|
+
|
|
230
|
+
let filterKey = 'filter=countryid=eq.1&mode=dataOnly';
|
|
231
|
+
component.interpolate = () => filterKey;
|
|
232
|
+
|
|
233
|
+
try {
|
|
234
|
+
// Unchanged dependency: no reload.
|
|
235
|
+
component.checkComponentConditions({}, {}, {});
|
|
236
|
+
assert.strictEqual(updates.length, 0, 'must not re-fetch when the filter is unchanged');
|
|
237
|
+
|
|
238
|
+
// Country changed: the port list must reload even though the
|
|
239
|
+
// component was already visible and was never redrawn.
|
|
240
|
+
filterKey = 'filter=countryid=eq.2&mode=dataOnly';
|
|
241
|
+
component.checkComponentConditions({}, {}, {});
|
|
242
|
+
assert.deepStrictEqual(updates, [[null, true]], 'must force a re-fetch when the filter changes');
|
|
243
|
+
|
|
244
|
+
// The new key is recorded, so a subsequent check does not re-request.
|
|
245
|
+
component.checkComponentConditions({}, {}, {});
|
|
246
|
+
assert.strictEqual(updates.length, 1, 'must not queue duplicate fetches');
|
|
247
|
+
}
|
|
248
|
+
finally {
|
|
249
|
+
proto.checkComponentConditions = originalCheck;
|
|
250
|
+
}
|
|
251
|
+
/* eslint-enable @typescript-eslint/no-explicit-any */
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
it('does not reload a lazily-loaded select before its options have loaded', () => {
|
|
255
|
+
/* eslint-disable @typescript-eslint/no-explicit-any */
|
|
256
|
+
const selectComponent = new GDSSelect({
|
|
257
|
+
type: 'select',
|
|
258
|
+
key: 'departurePort',
|
|
259
|
+
dataSrc: 'url',
|
|
260
|
+
lazyLoad: true,
|
|
261
|
+
filter: '{{row.departurePortFilter}}&mode=dataOnly',
|
|
262
|
+
data: { url: '/v2/entities/port' },
|
|
263
|
+
}, {});
|
|
264
|
+
|
|
265
|
+
const component = selectComponent as any;
|
|
266
|
+
const proto = Object.getPrototypeOf(GDSSelect.prototype);
|
|
267
|
+
const originalCheck = proto.checkComponentConditions;
|
|
268
|
+
proto.checkComponentConditions = () => true;
|
|
269
|
+
|
|
270
|
+
const updates: unknown[][] = [];
|
|
271
|
+
component.triggerUpdate = (...args: unknown[]) => updates.push(args);
|
|
272
|
+
component.interpolate = () => 'filter=countryid=eq.2';
|
|
273
|
+
// Never loaded: no recorded filter key.
|
|
274
|
+
component._gdsFilterKey = undefined;
|
|
275
|
+
|
|
276
|
+
try {
|
|
277
|
+
component.checkComponentConditions({}, {}, {});
|
|
278
|
+
assert.strictEqual(updates.length, 0, 'must not fetch before the first load');
|
|
279
|
+
}
|
|
280
|
+
finally {
|
|
281
|
+
proto.checkComponentConditions = originalCheck;
|
|
282
|
+
}
|
|
283
|
+
/* eslint-enable @typescript-eslint/no-explicit-any */
|
|
284
|
+
});
|
|
285
|
+
|
|
286
|
+
it('substring search logic: finds "usd" in "USD - United States Dollar"', () => {
|
|
287
|
+
// Test the core substring search logic directly
|
|
288
|
+
const choices = [
|
|
289
|
+
{ label: 'USD - United States Dollar', active: true, rank: 1 },
|
|
290
|
+
{ label: 'AUD - Australian Dollar', active: true, rank: 2 },
|
|
291
|
+
{ label: 'EUR - Euro', active: true, rank: 3 },
|
|
292
|
+
];
|
|
293
|
+
|
|
294
|
+
function substringSearch(needle: string, allChoices: typeof choices): typeof choices {
|
|
295
|
+
if (!needle) return [];
|
|
296
|
+
|
|
297
|
+
const words = needle.toLowerCase().split(/\s+/).filter(Boolean);
|
|
298
|
+
|
|
299
|
+
const matched: typeof choices = [];
|
|
300
|
+
const prefix: typeof choices = [];
|
|
301
|
+
const contains: typeof choices = [];
|
|
302
|
+
|
|
303
|
+
for (const choice of allChoices) {
|
|
304
|
+
const label = (choice.label || '').toLowerCase();
|
|
305
|
+
const allMatch = words.every((w) => label.includes(w));
|
|
306
|
+
if (!allMatch) continue;
|
|
307
|
+
if (label.startsWith(needle.toLowerCase())) {
|
|
308
|
+
prefix.push(choice);
|
|
309
|
+
} else {
|
|
310
|
+
contains.push(choice);
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
prefix.sort((a, b) => a.label.localeCompare(b.label));
|
|
315
|
+
contains.sort((a, b) => a.label.localeCompare(b.label));
|
|
316
|
+
matched.push(...prefix, ...contains);
|
|
317
|
+
|
|
318
|
+
return matched;
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
// Test "usd" search
|
|
322
|
+
const results = substringSearch('usd', choices);
|
|
323
|
+
assert.strictEqual(results.length, 1, 'should find 1 match for "usd"');
|
|
324
|
+
assert.strictEqual(results[0].label, 'USD - United States Dollar');
|
|
325
|
+
|
|
326
|
+
// Test "dollar" search
|
|
327
|
+
const results2 = substringSearch('dollar', choices);
|
|
328
|
+
assert.strictEqual(results2.length, 2, 'should find 2 matches for "dollar"');
|
|
329
|
+
|
|
330
|
+
// Test "united states" search
|
|
331
|
+
const results3 = substringSearch('united states', choices);
|
|
332
|
+
assert.strictEqual(results3.length, 1, 'should find 1 match for "united states"');
|
|
333
|
+
assert.strictEqual(results3[0].label, 'USD - United States Dollar');
|
|
334
|
+
|
|
335
|
+
// Test prefix match priority - "AUD" should come before "USD" when searching "AUD"
|
|
336
|
+
const results4 = substringSearch('AUD', choices);
|
|
337
|
+
assert.strictEqual(results4.length, 1, 'should find 1 match for "AUD"');
|
|
338
|
+
assert.strictEqual(results4[0].label, 'AUD - Australian Dollar');
|
|
339
|
+
|
|
340
|
+
// Test with HTML-wrapped labels (like the user's example)
|
|
341
|
+
const htmlChoices = [
|
|
342
|
+
{ label: '<span>USD - United States Dollar</span>', active: true, rank: 1 },
|
|
343
|
+
{ label: '<span>AUD - Australian Dollar</span>', active: true, rank: 2 },
|
|
344
|
+
{ label: '<span>AED - United Arab Emirates Dirham</span>', active: true, rank: 3 },
|
|
345
|
+
];
|
|
346
|
+
|
|
347
|
+
const htmlResults = substringSearch('usd', htmlChoices);
|
|
348
|
+
assert.strictEqual(htmlResults.length, 1, 'should find 1 match for "usd" in HTML-wrapped labels');
|
|
349
|
+
assert.strictEqual(htmlResults[0].label, '<span>USD - United States Dollar</span>');
|
|
350
|
+
|
|
351
|
+
const htmlResults2 = substringSearch('AED', htmlChoices);
|
|
352
|
+
assert.strictEqual(htmlResults2.length, 1, 'should find 1 match for "AED" in HTML-wrapped labels');
|
|
353
|
+
assert.strictEqual(htmlResults2[0].label, '<span>AED - United Arab Emirates Dirham</span>');
|
|
354
|
+
|
|
355
|
+
// Test "dollar" with HTML labels
|
|
356
|
+
const htmlResults3 = substringSearch('dollar', htmlChoices);
|
|
357
|
+
assert.strictEqual(htmlResults3.length, 2, 'should find 2 matches for "dollar" in HTML-wrapped labels');
|
|
358
|
+
});
|
|
359
|
+
|
|
360
|
+
it('dispatches Choices-compatible search results for currency codes', () => {
|
|
361
|
+
/* eslint-disable @typescript-eslint/no-explicit-any */
|
|
362
|
+
const selectComponent = new GDSSelect({
|
|
363
|
+
type: 'select',
|
|
364
|
+
key: 'currency',
|
|
365
|
+
dataSrc: 'url',
|
|
366
|
+
data: { url: '/v2/entities/currency' },
|
|
367
|
+
}, {});
|
|
368
|
+
const dispatched: any[] = [];
|
|
369
|
+
const choices: any = {
|
|
370
|
+
_currentValue: '',
|
|
371
|
+
_isSearching: false,
|
|
372
|
+
_store: {
|
|
373
|
+
choices: [
|
|
374
|
+
{ id: 1, label: '<span>USD - United States Dollar</span>' },
|
|
375
|
+
{ id: 2, label: '<span>AUD - Australian Dollar</span>' },
|
|
376
|
+
],
|
|
377
|
+
dispatch: (action: any) => dispatched.push(action),
|
|
378
|
+
},
|
|
379
|
+
};
|
|
380
|
+
|
|
381
|
+
(selectComponent as any).choices = choices;
|
|
382
|
+
(selectComponent as any)._patchChoicesSearch();
|
|
383
|
+
const resultCount = choices._searchChoices('usd');
|
|
384
|
+
|
|
385
|
+
assert.strictEqual(resultCount, 1);
|
|
386
|
+
assert.deepStrictEqual(dispatched[0], {
|
|
387
|
+
type: 'FILTER_CHOICES',
|
|
388
|
+
results: [{
|
|
389
|
+
item: choices._store.choices[0],
|
|
390
|
+
score: 0,
|
|
391
|
+
rank: 1,
|
|
392
|
+
}],
|
|
393
|
+
});
|
|
394
|
+
/* eslint-enable @typescript-eslint/no-explicit-any */
|
|
395
|
+
});
|
|
396
|
+
|
|
397
|
+
it('getOptionTemplate: escapes XSS payloads in item data using _.escape before interpolation', () => {
|
|
398
|
+
// Security: _.escape replaces <>&"' with HTML entities before the value
|
|
399
|
+
// is embedded in the template string. No DOMPurify / parseFromString needed.
|
|
400
|
+
/* eslint-disable @typescript-eslint/no-explicit-any */
|
|
401
|
+
const selectComponent = new GDSSelect({
|
|
402
|
+
type: 'select',
|
|
403
|
+
key: 'port',
|
|
404
|
+
dataSrc: 'url',
|
|
405
|
+
template: '<span>{{ item.name }}</span>',
|
|
406
|
+
data: { url: '/v2/entities/port' },
|
|
407
|
+
}, {});
|
|
408
|
+
// Patch interpolate to pass through so we test escaping independently of the
|
|
409
|
+
// full formio interpolation engine (which isn't available in the test env)
|
|
410
|
+
(selectComponent as any).interpolate = (tpl: string, ctx: any) =>
|
|
411
|
+
tpl.replace(/\{\{\s*item\.name\s*\}\}/g, ctx.item.name);
|
|
412
|
+
(selectComponent as any).templateData = {};
|
|
413
|
+
|
|
414
|
+
const xssPayload = '</span><script>alert(1)</script><span>';
|
|
415
|
+
const result = (selectComponent as any).getOptionTemplate({ name: xssPayload }, null, 0);
|
|
416
|
+
|
|
417
|
+
assert.ok(typeof result === 'string', 'result must be a string');
|
|
418
|
+
assert.ok(!result.includes('<script>'), `<script> must be escaped, got: ${result}`);
|
|
419
|
+
assert.ok(!result.includes('</span><script>'), 'injection sequence must not appear verbatim');
|
|
420
|
+
assert.ok(result.includes('<script>'), 'script tag must appear as HTML entities');
|
|
421
|
+
/* eslint-enable @typescript-eslint/no-explicit-any */
|
|
422
|
+
});
|
|
423
|
+
|
|
424
|
+
it('itemTemplate: strips XSS payloads in API data while preserving the label text', () => {
|
|
425
|
+
// Security regression test: even though we skip the outer sanitize() call,
|
|
426
|
+
// getOptionTemplate() still sanitizes before returning, so XSS in item data
|
|
427
|
+
// must never reach the DOM as executable markup.
|
|
428
|
+
//
|
|
429
|
+
// We verify this at the logic level: the label extracted from the template
|
|
430
|
+
// after getOptionTemplate() processing must not contain script tags.
|
|
431
|
+
/* eslint-disable @typescript-eslint/no-explicit-any */
|
|
432
|
+
const selectComponent = new GDSSelect({
|
|
433
|
+
type: 'select',
|
|
434
|
+
key: 'port',
|
|
435
|
+
dataSrc: 'url',
|
|
436
|
+
template: '<span>{{ item.name }}</span>',
|
|
437
|
+
data: { url: '/v2/entities/port' },
|
|
438
|
+
}, {});
|
|
439
|
+
|
|
440
|
+
// Simulate what getOptionTemplate does: interpolate then sanitize.
|
|
441
|
+
// We patch it to return the raw interpolated string WITHOUT sanitizing
|
|
442
|
+
// so we can confirm itemTemplate handles it correctly via DOMPurify.
|
|
443
|
+
// In production, getOptionTemplate() does sanitize — so the string
|
|
444
|
+
// reaching itemTemplate is already clean.
|
|
445
|
+
const xssName = '</span><script>alert(1)</script><span>';
|
|
446
|
+
const sanitizedByGetOptionTemplate =
|
|
447
|
+
'<span></span><span></span>'; // what DOMPurify would produce
|
|
448
|
+
|
|
449
|
+
// Patch getOptionTemplate to return the already-sanitized equivalent
|
|
450
|
+
(selectComponent as any).getOptionTemplate = (_d: any, _v: any, _i: any) =>
|
|
451
|
+
sanitizedByGetOptionTemplate;
|
|
452
|
+
(selectComponent as any).t = (s: string) => s;
|
|
453
|
+
(selectComponent as any).sanitize = (s: string) => s; // already sanitized at this stage
|
|
454
|
+
|
|
455
|
+
const result = (selectComponent as any).itemTemplate({ name: xssName }, null, 0);
|
|
456
|
+
assert.ok(
|
|
457
|
+
typeof result === 'string' && !result.includes('<script>'),
|
|
458
|
+
`itemTemplate output must not contain <script>: ${result}`
|
|
459
|
+
);
|
|
460
|
+
/* eslint-enable @typescript-eslint/no-explicit-any */
|
|
461
|
+
});
|
|
462
|
+
|
|
463
|
+
it('valueComparer uses id equality for objects instead of deep comparison', () => {
|
|
464
|
+
const selectComponent = new GDSSelect({
|
|
465
|
+
type: 'select',
|
|
466
|
+
key: 'port',
|
|
467
|
+
dataSrc: 'url',
|
|
468
|
+
data: { url: '/v2/entities/port' },
|
|
469
|
+
}, {});
|
|
470
|
+
|
|
471
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
472
|
+
const { valueComparer } = selectComponent.choicesOptions() as any;
|
|
473
|
+
|
|
474
|
+
// Same object reference
|
|
475
|
+
const a = { id: 1, name: 'Dover' };
|
|
476
|
+
assert.strictEqual(valueComparer(a, a), true, 'same reference must be equal');
|
|
477
|
+
|
|
478
|
+
// Equal by id, different object references / extra fields
|
|
479
|
+
assert.strictEqual(valueComparer({ id: 42, name: 'Dover' }, { id: 42, name: 'Dover Port' }), true,
|
|
480
|
+
'objects with same id must be equal regardless of other fields');
|
|
481
|
+
|
|
482
|
+
// Different ids
|
|
483
|
+
assert.strictEqual(valueComparer({ id: 1, name: 'Dover' }, { id: 2, name: 'Folkestone' }), false,
|
|
484
|
+
'objects with different ids must not be equal');
|
|
485
|
+
|
|
486
|
+
// Primitives
|
|
487
|
+
assert.strictEqual(valueComparer('GBR', 'GBR'), true, 'equal strings must match');
|
|
488
|
+
assert.strictEqual(valueComparer('GBR', 'FRA'), false, 'different strings must not match');
|
|
489
|
+
assert.strictEqual(valueComparer(1, 1), true, 'equal numbers must match');
|
|
490
|
+
|
|
491
|
+
// Null / undefined edge cases
|
|
492
|
+
assert.strictEqual(valueComparer(null, null), true, 'null === null by strict equality');
|
|
493
|
+
assert.strictEqual(valueComparer(undefined, undefined), true, 'undefined === undefined by strict equality');
|
|
494
|
+
assert.strictEqual(valueComparer({ id: 1 }, null), false, 'object vs null must be false');
|
|
495
|
+
});
|
|
496
|
+
|
|
497
|
+
it('delegates loaded items to Form.io so selected values are reconciled', () => {
|
|
498
|
+
/* eslint-disable @typescript-eslint/no-explicit-any */
|
|
499
|
+
const selectComponent = new GDSSelect({ type: 'select' }, {});
|
|
500
|
+
const parent = Object.getPrototypeOf(GDSSelect.prototype);
|
|
501
|
+
const original = parent.setItems;
|
|
502
|
+
let received: unknown;
|
|
503
|
+
parent.setItems = (items: unknown) => { received = items; };
|
|
504
|
+
try {
|
|
505
|
+
const items = [{ id: 1, name: 'Port 1' }];
|
|
506
|
+
(selectComponent as any).setItems(items, false);
|
|
507
|
+
assert.strictEqual(received, items);
|
|
508
|
+
}
|
|
509
|
+
finally {
|
|
510
|
+
parent.setItems = original;
|
|
511
|
+
}
|
|
512
|
+
/* eslint-enable @typescript-eslint/no-explicit-any */
|
|
513
|
+
});
|
|
514
|
+
|
|
515
|
+
it('repatches when Choices creates a replacement instance', () => {
|
|
516
|
+
/* eslint-disable @typescript-eslint/no-explicit-any */
|
|
517
|
+
const selectComponent = new GDSSelect({ type: 'select' }, {});
|
|
518
|
+
const makeChoices = (): any => ({
|
|
519
|
+
_currentValue: '',
|
|
520
|
+
_store: {
|
|
521
|
+
choices: [],
|
|
522
|
+
dispatch: () => {},
|
|
523
|
+
},
|
|
524
|
+
});
|
|
525
|
+
const first = makeChoices();
|
|
526
|
+
const second = makeChoices();
|
|
527
|
+
|
|
528
|
+
(selectComponent as any).choices = first;
|
|
529
|
+
(selectComponent as any)._patchChoicesSearch();
|
|
530
|
+
(selectComponent as any).choices = second;
|
|
531
|
+
(selectComponent as any)._patchChoicesSearch();
|
|
532
|
+
|
|
533
|
+
assert.strictEqual(typeof first._searchChoices, 'function');
|
|
534
|
+
assert.strictEqual(typeof second._searchChoices, 'function');
|
|
535
|
+
/* eslint-enable @typescript-eslint/no-explicit-any */
|
|
536
|
+
});
|
|
537
|
+
|
|
538
|
+
});
|