@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.
Files changed (150) hide show
  1. package/.github/workflows/build-and-test.yml +3 -3
  2. package/LICENCE.md +21 -0
  3. package/README.md +8 -0
  4. package/eslint.config.mjs +59 -0
  5. package/lib/components/datamap/GDSDataMap.d.ts +5 -14
  6. package/lib/components/datamap/GDSDataMap.d.ts.map +1 -0
  7. package/lib/components/datamap/GDSDataMap.js +9 -29
  8. package/lib/components/datetime/GDSDatetimeComponent.d.ts +43 -22
  9. package/lib/components/datetime/GDSDatetimeComponent.d.ts.map +1 -0
  10. package/lib/components/datetime/GDSDatetimeComponent.js +167 -178
  11. package/lib/components/index.d.ts +3 -0
  12. package/lib/components/index.d.ts.map +1 -0
  13. package/lib/components/index.js +6 -4
  14. package/lib/components/select/GDSSelect.d.ts +143 -0
  15. package/lib/components/select/GDSSelect.d.ts.map +1 -0
  16. package/lib/components/select/GDSSelect.js +340 -0
  17. package/lib/components/selectboxes/GDSCheckBoxes.d.ts +5 -2
  18. package/lib/components/selectboxes/GDSCheckBoxes.d.ts.map +1 -0
  19. package/lib/components/selectboxes/GDSCheckBoxes.js +7 -33
  20. package/lib/components/time/GDSTimeComponent.d.ts +13 -10
  21. package/lib/components/time/GDSTimeComponent.d.ts.map +1 -0
  22. package/lib/components/time/GDSTimeComponent.js +59 -84
  23. package/lib/components/util/TimeHelper.d.ts +6 -4
  24. package/lib/components/util/TimeHelper.d.ts.map +1 -0
  25. package/lib/components/util/TimeHelper.js +13 -16
  26. package/lib/index.d.ts +4 -2
  27. package/lib/index.d.ts.map +1 -0
  28. package/lib/index.js +2 -2
  29. package/lib/templates/gds/button/index.d.ts +1 -0
  30. package/lib/templates/gds/button/index.d.ts.map +1 -0
  31. package/lib/templates/gds/button/index.js +1 -1
  32. package/lib/templates/gds/checkbox/index.d.ts +1 -0
  33. package/lib/templates/gds/checkbox/index.d.ts.map +1 -0
  34. package/lib/templates/gds/checkbox/index.js +1 -1
  35. package/lib/templates/gds/columns/index.d.ts +1 -0
  36. package/lib/templates/gds/columns/index.d.ts.map +1 -0
  37. package/lib/templates/gds/columns/index.js +1 -1
  38. package/lib/templates/gds/component/index.d.ts +1 -0
  39. package/lib/templates/gds/component/index.d.ts.map +1 -0
  40. package/lib/templates/gds/component/index.js +1 -1
  41. package/lib/templates/gds/container/index.d.ts +1 -0
  42. package/lib/templates/gds/container/index.d.ts.map +1 -0
  43. package/lib/templates/gds/container/index.js +1 -1
  44. package/lib/templates/gds/cssClasses.d.ts +1 -0
  45. package/lib/templates/gds/cssClasses.d.ts.map +1 -0
  46. package/lib/templates/gds/datagrid/form.ejs.js +2 -2
  47. package/lib/templates/gds/datagrid/index.d.ts +1 -0
  48. package/lib/templates/gds/datagrid/index.d.ts.map +1 -0
  49. package/lib/templates/gds/datagrid/index.js +1 -1
  50. package/lib/templates/gds/datamap/index.d.ts +1 -0
  51. package/lib/templates/gds/datamap/index.d.ts.map +1 -0
  52. package/lib/templates/gds/datamap/index.js +1 -1
  53. package/lib/templates/gds/datetime/index.d.ts +1 -0
  54. package/lib/templates/gds/datetime/index.d.ts.map +1 -0
  55. package/lib/templates/gds/datetime/index.js +1 -1
  56. package/lib/templates/gds/day/index.d.ts +1 -0
  57. package/lib/templates/gds/day/index.d.ts.map +1 -0
  58. package/lib/templates/gds/day/index.js +1 -1
  59. package/lib/templates/gds/editgrid/index.d.ts +1 -0
  60. package/lib/templates/gds/editgrid/index.d.ts.map +1 -0
  61. package/lib/templates/gds/editgrid/index.js +1 -1
  62. package/lib/templates/gds/field/index.d.ts +1 -0
  63. package/lib/templates/gds/field/index.d.ts.map +1 -0
  64. package/lib/templates/gds/field/index.js +1 -1
  65. package/lib/templates/gds/fieldset/index.d.ts +1 -0
  66. package/lib/templates/gds/fieldset/index.d.ts.map +1 -0
  67. package/lib/templates/gds/fieldset/index.js +1 -1
  68. package/lib/templates/gds/file/index.d.ts +1 -0
  69. package/lib/templates/gds/file/index.d.ts.map +1 -0
  70. package/lib/templates/gds/file/index.js +1 -1
  71. package/lib/templates/gds/iconClass.d.ts +2 -1
  72. package/lib/templates/gds/iconClass.d.ts.map +1 -0
  73. package/lib/templates/gds/iconClass.js +3 -3
  74. package/lib/templates/gds/index.d.ts +3 -2
  75. package/lib/templates/gds/index.d.ts.map +1 -0
  76. package/lib/templates/gds/index.js +72 -64
  77. package/lib/templates/gds/input/index.d.ts +1 -0
  78. package/lib/templates/gds/input/index.d.ts.map +1 -0
  79. package/lib/templates/gds/input/index.js +1 -1
  80. package/lib/templates/gds/label/index.d.ts +1 -0
  81. package/lib/templates/gds/label/index.d.ts.map +1 -0
  82. package/lib/templates/gds/label/index.js +1 -1
  83. package/lib/templates/gds/message/index.d.ts +1 -0
  84. package/lib/templates/gds/message/index.d.ts.map +1 -0
  85. package/lib/templates/gds/message/index.js +1 -1
  86. package/lib/templates/gds/panel/index.d.ts +1 -0
  87. package/lib/templates/gds/panel/index.d.ts.map +1 -0
  88. package/lib/templates/gds/panel/index.js +1 -1
  89. package/lib/templates/gds/radio/index.d.ts +1 -0
  90. package/lib/templates/gds/radio/index.d.ts.map +1 -0
  91. package/lib/templates/gds/radio/index.js +1 -1
  92. package/lib/templates/gds/select/index.d.ts +1 -0
  93. package/lib/templates/gds/select/index.d.ts.map +1 -0
  94. package/lib/templates/gds/select/index.js +1 -1
  95. package/lib/templates/gds/selectOption/index.d.ts +1 -0
  96. package/lib/templates/gds/selectOption/index.d.ts.map +1 -0
  97. package/lib/templates/gds/selectOption/index.js +1 -1
  98. package/lib/templates/gds/selectboxes/index.d.ts +1 -0
  99. package/lib/templates/gds/selectboxes/index.d.ts.map +1 -0
  100. package/lib/templates/gds/selectboxes/index.js +1 -1
  101. package/lib/templates/gds/survey/index.d.ts +1 -0
  102. package/lib/templates/gds/survey/index.d.ts.map +1 -0
  103. package/lib/templates/gds/survey/index.js +1 -1
  104. package/lib/templates/gds/tab/index.d.ts +1 -0
  105. package/lib/templates/gds/tab/index.d.ts.map +1 -0
  106. package/lib/templates/gds/tab/index.js +1 -1
  107. package/lib/templates/gds/table/index.d.ts +1 -0
  108. package/lib/templates/gds/table/index.d.ts.map +1 -0
  109. package/lib/templates/gds/table/index.js +1 -1
  110. package/lib/templates/gds/template.css +27 -0
  111. package/lib/templates/gds/template.css.map +1 -1
  112. package/lib/templates/gds/time/index.d.ts +1 -0
  113. package/lib/templates/gds/time/index.d.ts.map +1 -0
  114. package/lib/templates/gds/time/index.js +1 -1
  115. package/lib/templates/gds/warning/index.d.ts +1 -0
  116. package/lib/templates/gds/warning/index.d.ts.map +1 -0
  117. package/lib/templates/gds/warning/index.js +1 -1
  118. package/lib/templates/gds/wizard/index.d.ts +1 -0
  119. package/lib/templates/gds/wizard/index.d.ts.map +1 -0
  120. package/lib/templates/gds/wizard/index.js +1 -1
  121. package/lib/templates/gds/wizardHeader/index.d.ts +1 -0
  122. package/lib/templates/gds/wizardHeader/index.d.ts.map +1 -0
  123. package/lib/templates/gds/wizardHeader/index.js +1 -1
  124. package/lib/templates/gds/wizardNav/index.d.ts +1 -0
  125. package/lib/templates/gds/wizardNav/index.d.ts.map +1 -0
  126. package/lib/templates/gds/wizardNav/index.js +1 -1
  127. package/lib/templates/index.d.ts +3 -2
  128. package/lib/templates/index.d.ts.map +1 -0
  129. package/lib/templates/index.js +1 -1
  130. package/lib/types.d.ts +37 -0
  131. package/lib/types.d.ts.map +1 -0
  132. package/lib/types.js +2 -0
  133. package/package.json +14 -7
  134. package/src/components/datamap/GDSDataMap.ts +5 -14
  135. package/src/components/datetime/GDSDatetimeComponent.ts +128 -91
  136. package/src/components/index.ts +2 -0
  137. package/src/components/select/GDSSelect.spec.ts +538 -0
  138. package/src/components/select/GDSSelect.ts +418 -0
  139. package/src/components/selectboxes/GDSCheckBoxes.ts +7 -8
  140. package/src/components/time/GDSTimeComponent.ts +37 -27
  141. package/src/components/util/TimeHelper.ts +6 -5
  142. package/src/css.d.ts +4 -0
  143. package/src/templates/gds/datagrid/form.ejs +1 -2
  144. package/src/templates/gds/iconClass.ts +1 -1
  145. package/src/templates/gds/index.ts +43 -33
  146. package/src/templates/gds/template.scss +48 -1
  147. package/src/types.ts +56 -0
  148. package/tsconfig.json +21 -5
  149. package/webpack.config.js +1 -1
  150. 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('&lt;script&gt;'), '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
+ });