@player-ui/player 0.8.0--canary.307.9621 → 0.8.0-next.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.
Files changed (214) hide show
  1. package/dist/Player.native.js +11630 -0
  2. package/dist/Player.native.js.map +1 -0
  3. package/dist/cjs/index.cjs +5626 -0
  4. package/dist/cjs/index.cjs.map +1 -0
  5. package/dist/{index.esm.js → index.legacy-esm.js} +2044 -1667
  6. package/dist/{index.cjs.js → index.mjs} +2052 -1761
  7. package/dist/index.mjs.map +1 -0
  8. package/package.json +29 -63
  9. package/src/__tests__/data.test.ts +498 -0
  10. package/src/__tests__/flow.test.ts +312 -0
  11. package/src/__tests__/helpers/action-exp.plugin.ts +22 -0
  12. package/src/__tests__/helpers/actions.flow.ts +67 -0
  13. package/src/__tests__/helpers/binding.plugin.ts +125 -0
  14. package/src/__tests__/helpers/expression.plugin.ts +88 -0
  15. package/src/__tests__/helpers/transform-plugin.ts +19 -0
  16. package/src/__tests__/helpers/validation.flow.ts +56 -0
  17. package/src/__tests__/player.test.ts +597 -0
  18. package/src/__tests__/string-resolver.test.ts +186 -0
  19. package/src/__tests__/validation.test.ts +3555 -0
  20. package/src/__tests__/view.test.ts +715 -0
  21. package/src/binding/__tests__/binding.test.ts +113 -0
  22. package/src/binding/__tests__/index.test.ts +208 -0
  23. package/src/binding/__tests__/resolver.test.ts +83 -0
  24. package/src/binding/binding.ts +6 -6
  25. package/src/binding/index.ts +34 -34
  26. package/src/binding/resolver.ts +19 -19
  27. package/src/binding/utils.ts +7 -7
  28. package/src/binding-grammar/__tests__/parser.test.ts +64 -0
  29. package/src/binding-grammar/__tests__/test-utils/ast-cases.ts +198 -0
  30. package/src/binding-grammar/__tests__/test-utils/perf-test.ts +66 -0
  31. package/src/binding-grammar/ast.ts +11 -11
  32. package/src/binding-grammar/custom/index.ts +19 -22
  33. package/src/binding-grammar/ebnf/index.ts +20 -21
  34. package/src/binding-grammar/ebnf/types.ts +13 -13
  35. package/src/binding-grammar/index.ts +4 -4
  36. package/src/binding-grammar/parsimmon/index.ts +14 -14
  37. package/src/controllers/constants/__tests__/index.test.ts +106 -0
  38. package/src/controllers/constants/index.ts +3 -3
  39. package/src/controllers/constants/utils.ts +4 -4
  40. package/src/controllers/data/controller.ts +22 -22
  41. package/src/controllers/data/index.ts +1 -1
  42. package/src/controllers/data/utils.ts +7 -7
  43. package/src/controllers/flow/__tests__/controller.test.ts +195 -0
  44. package/src/controllers/flow/__tests__/flow.test.ts +381 -0
  45. package/src/controllers/flow/controller.ts +13 -13
  46. package/src/controllers/flow/flow.ts +23 -23
  47. package/src/controllers/flow/index.ts +2 -2
  48. package/src/controllers/index.ts +5 -5
  49. package/src/controllers/validation/binding-tracker.ts +71 -59
  50. package/src/controllers/validation/controller.ts +104 -104
  51. package/src/controllers/validation/index.ts +2 -2
  52. package/src/controllers/view/asset-transform.ts +20 -20
  53. package/src/controllers/view/controller.ts +27 -27
  54. package/src/controllers/view/index.ts +4 -4
  55. package/src/controllers/view/store.ts +3 -3
  56. package/src/controllers/view/types.ts +7 -7
  57. package/src/data/__tests__/__snapshots__/dependency-tracker.test.ts.snap +64 -0
  58. package/src/data/__tests__/dependency-tracker.test.ts +146 -0
  59. package/src/data/__tests__/local-model.test.ts +46 -0
  60. package/src/data/__tests__/model.test.ts +78 -0
  61. package/src/data/dependency-tracker.ts +16 -16
  62. package/src/data/index.ts +4 -4
  63. package/src/data/local-model.ts +6 -6
  64. package/src/data/model.ts +17 -17
  65. package/src/data/noop-model.ts +1 -1
  66. package/src/expressions/__tests__/__snapshots__/parser.test.ts.snap +854 -0
  67. package/src/expressions/__tests__/evaluator-functions.test.ts +47 -0
  68. package/src/expressions/__tests__/evaluator.test.ts +410 -0
  69. package/src/expressions/__tests__/parser.test.ts +115 -0
  70. package/src/expressions/__tests__/utils.test.ts +44 -0
  71. package/src/expressions/evaluator-functions.ts +6 -6
  72. package/src/expressions/evaluator.ts +71 -67
  73. package/src/expressions/index.ts +4 -4
  74. package/src/expressions/parser.ts +102 -105
  75. package/src/expressions/types.ts +29 -21
  76. package/src/expressions/utils.ts +32 -21
  77. package/src/index.ts +13 -13
  78. package/src/logger/__tests__/consoleLogger.test.ts +46 -0
  79. package/src/logger/__tests__/noopLogger.test.ts +13 -0
  80. package/src/logger/__tests__/proxyLogger.test.ts +31 -0
  81. package/src/logger/__tests__/tapableLogger.test.ts +41 -0
  82. package/src/logger/consoleLogger.ts +9 -9
  83. package/src/logger/index.ts +5 -5
  84. package/src/logger/noopLogger.ts +1 -1
  85. package/src/logger/proxyLogger.ts +6 -6
  86. package/src/logger/tapableLogger.ts +7 -7
  87. package/src/logger/types.ts +2 -2
  88. package/src/player.ts +60 -58
  89. package/src/plugins/default-exp-plugin.ts +10 -10
  90. package/src/plugins/default-view-plugin.ts +29 -0
  91. package/src/plugins/flow-exp-plugin.ts +6 -6
  92. package/src/schema/__tests__/schema.test.ts +243 -0
  93. package/src/schema/index.ts +2 -2
  94. package/src/schema/schema.ts +24 -24
  95. package/src/schema/types.ts +4 -4
  96. package/src/string-resolver/__tests__/index.test.ts +361 -0
  97. package/src/string-resolver/index.ts +17 -17
  98. package/src/types.ts +17 -17
  99. package/src/utils/__tests__/replaceParams.test.ts +33 -0
  100. package/src/utils/index.ts +1 -1
  101. package/src/utils/replaceParams.ts +1 -1
  102. package/src/validator/__tests__/binding-map-splice.test.ts +53 -0
  103. package/src/validator/__tests__/validation-middleware.test.ts +127 -0
  104. package/src/validator/binding-map-splice.ts +5 -5
  105. package/src/validator/index.ts +4 -4
  106. package/src/validator/registry.ts +1 -1
  107. package/src/validator/types.ts +13 -13
  108. package/src/validator/validation-middleware.ts +15 -15
  109. package/src/view/__tests__/view.immutable.test.ts +269 -0
  110. package/src/view/__tests__/view.test.ts +959 -0
  111. package/src/view/builder/index.test.ts +69 -0
  112. package/src/view/builder/index.ts +3 -3
  113. package/src/view/index.ts +5 -5
  114. package/src/view/parser/__tests__/__snapshots__/parser.test.ts.snap +394 -0
  115. package/src/view/parser/__tests__/parser.test.ts +264 -0
  116. package/src/view/parser/index.ts +43 -33
  117. package/src/view/parser/types.ts +11 -11
  118. package/src/view/parser/utils.ts +5 -5
  119. package/src/view/plugins/__tests__/__snapshots__/template.test.ts.snap +278 -0
  120. package/src/view/plugins/__tests__/applicability.test.ts +265 -0
  121. package/src/view/plugins/__tests__/string.test.ts +122 -0
  122. package/src/view/plugins/__tests__/template.test.ts +724 -0
  123. package/src/view/plugins/applicability.ts +19 -19
  124. package/src/view/plugins/index.ts +4 -5
  125. package/src/view/plugins/options.ts +1 -1
  126. package/src/view/plugins/string-resolver.ts +22 -22
  127. package/src/view/plugins/switch.ts +22 -23
  128. package/src/view/plugins/template-plugin.ts +26 -27
  129. package/src/view/resolver/__tests__/dependencies.test.ts +321 -0
  130. package/src/view/resolver/__tests__/edgecases.test.ts +626 -0
  131. package/src/view/resolver/index.ts +42 -42
  132. package/src/view/resolver/types.ts +21 -20
  133. package/src/view/resolver/utils.ts +9 -9
  134. package/src/view/view.ts +32 -22
  135. package/types/binding/binding.d.ts +50 -0
  136. package/types/binding/index.d.ts +29 -0
  137. package/types/binding/resolver.d.ts +26 -0
  138. package/types/binding/utils.d.ts +12 -0
  139. package/types/binding-grammar/ast.d.ts +67 -0
  140. package/types/binding-grammar/custom/index.d.ts +4 -0
  141. package/types/binding-grammar/ebnf/index.d.ts +4 -0
  142. package/types/binding-grammar/ebnf/types.d.ts +75 -0
  143. package/types/binding-grammar/index.d.ts +5 -0
  144. package/types/binding-grammar/parsimmon/index.d.ts +4 -0
  145. package/types/controllers/constants/index.d.ts +45 -0
  146. package/types/controllers/constants/utils.d.ts +6 -0
  147. package/types/controllers/data/controller.d.ts +45 -0
  148. package/types/controllers/data/index.d.ts +2 -0
  149. package/types/controllers/data/utils.d.ts +14 -0
  150. package/types/controllers/flow/controller.d.ts +25 -0
  151. package/types/controllers/flow/flow.d.ts +50 -0
  152. package/types/controllers/flow/index.d.ts +3 -0
  153. package/types/controllers/index.d.ts +6 -0
  154. package/types/controllers/validation/binding-tracker.d.ts +32 -0
  155. package/types/controllers/validation/controller.d.ts +151 -0
  156. package/types/controllers/validation/index.d.ts +3 -0
  157. package/types/controllers/view/asset-transform.d.ts +19 -0
  158. package/types/controllers/view/controller.d.ts +37 -0
  159. package/types/controllers/view/index.d.ts +5 -0
  160. package/types/controllers/view/store.d.ts +20 -0
  161. package/types/controllers/view/types.d.ts +16 -0
  162. package/types/data/dependency-tracker.d.ts +49 -0
  163. package/types/data/index.d.ts +5 -0
  164. package/types/data/local-model.d.ts +16 -0
  165. package/types/data/model.d.ts +86 -0
  166. package/types/data/noop-model.d.ts +13 -0
  167. package/types/expressions/evaluator-functions.d.ts +15 -0
  168. package/types/expressions/evaluator.d.ts +52 -0
  169. package/types/expressions/index.d.ts +5 -0
  170. package/types/expressions/parser.d.ts +10 -0
  171. package/types/expressions/types.d.ts +144 -0
  172. package/types/expressions/utils.d.ts +12 -0
  173. package/types/index.d.ts +14 -0
  174. package/types/logger/consoleLogger.d.ts +17 -0
  175. package/types/logger/index.d.ts +6 -0
  176. package/types/logger/noopLogger.d.ts +10 -0
  177. package/types/logger/proxyLogger.d.ts +15 -0
  178. package/types/logger/tapableLogger.d.ts +23 -0
  179. package/types/logger/types.d.ts +6 -0
  180. package/types/player.d.ts +101 -0
  181. package/types/plugins/default-exp-plugin.d.ts +9 -0
  182. package/types/plugins/default-view-plugin.d.ts +9 -0
  183. package/types/plugins/flow-exp-plugin.d.ts +11 -0
  184. package/types/schema/index.d.ts +3 -0
  185. package/types/schema/schema.d.ts +36 -0
  186. package/types/schema/types.d.ts +38 -0
  187. package/types/string-resolver/index.d.ts +30 -0
  188. package/types/types.d.ts +73 -0
  189. package/types/utils/index.d.ts +2 -0
  190. package/types/utils/replaceParams.d.ts +9 -0
  191. package/types/validator/binding-map-splice.d.ts +10 -0
  192. package/types/validator/index.d.ts +5 -0
  193. package/types/validator/registry.d.ts +11 -0
  194. package/types/validator/types.d.ts +53 -0
  195. package/types/validator/validation-middleware.d.ts +36 -0
  196. package/types/view/builder/index.d.ts +35 -0
  197. package/types/view/index.d.ts +6 -0
  198. package/types/view/parser/index.d.ts +52 -0
  199. package/types/view/parser/types.d.ts +109 -0
  200. package/types/view/parser/utils.d.ts +6 -0
  201. package/types/view/plugins/applicability.d.ts +10 -0
  202. package/types/view/plugins/index.d.ts +5 -0
  203. package/types/view/plugins/options.d.ts +4 -0
  204. package/types/view/plugins/string-resolver.d.ts +13 -0
  205. package/types/view/plugins/switch.d.ts +14 -0
  206. package/types/view/plugins/template-plugin.d.ts +33 -0
  207. package/types/view/resolver/index.d.ts +73 -0
  208. package/types/view/resolver/types.d.ts +129 -0
  209. package/types/view/resolver/utils.d.ts +11 -0
  210. package/types/view/view.d.ts +37 -0
  211. package/dist/index.d.ts +0 -1814
  212. package/dist/player.dev.js +0 -11472
  213. package/dist/player.prod.js +0 -2
  214. package/src/view/plugins/plugin.ts +0 -21
@@ -0,0 +1,3555 @@
1
+ import { test, expect, describe, it, beforeEach } from "vitest";
2
+ import { omit } from "timm";
3
+ import { makeFlow } from "@player-ui/make-flow";
4
+ import { vitest } from "vitest";
5
+ import type { Flow } from "@player-ui/types";
6
+ import type { SchemaController } from "../schema";
7
+ import type { BindingParser } from "../binding";
8
+ import TrackBindingPlugin, { addValidator } from "./helpers/binding.plugin";
9
+ import { Player } from "..";
10
+ import { VALIDATION_PROVIDER_NAME_SYMBOL } from "../controllers/validation";
11
+ import type { ValidationController } from "../controllers/validation";
12
+ import type { InProgressState } from "../types";
13
+ import TestExpressionPlugin, {
14
+ RequiredIfValidationProviderPlugin,
15
+ } from "./helpers/expression.plugin";
16
+
17
+ const simpleFlow: Flow = {
18
+ id: "test-flow",
19
+ views: [
20
+ {
21
+ id: "view-1",
22
+ type: "view",
23
+ thing1: {
24
+ asset: {
25
+ type: "whatevs",
26
+ id: "thing1",
27
+ binding: "data.thing1",
28
+ },
29
+ },
30
+ thing2: {
31
+ asset: {
32
+ type: "whatevs",
33
+ id: "thing2",
34
+ binding: "data.thing2",
35
+ },
36
+ },
37
+ },
38
+ ],
39
+ data: {},
40
+ schema: {
41
+ ROOT: {
42
+ data: {
43
+ type: "DataType",
44
+ },
45
+ },
46
+ DataType: {
47
+ thing1: {
48
+ type: "CatType",
49
+ validation: [
50
+ {
51
+ type: "names",
52
+ names: ["frodo", "sam"],
53
+ trigger: "navigation",
54
+ severity: "warning",
55
+ },
56
+ ],
57
+ },
58
+ thing2: {
59
+ type: "CatType",
60
+ validation: [
61
+ {
62
+ type: "names",
63
+ trigger: "navigation",
64
+ names: ["frodo", "sam"],
65
+ severity: "warning",
66
+ },
67
+ ],
68
+ },
69
+ },
70
+ },
71
+ navigation: {
72
+ BEGIN: "FLOW_1",
73
+ FLOW_1: {
74
+ startState: "VIEW_1",
75
+ VIEW_1: {
76
+ state_type: "VIEW",
77
+ ref: "view-1",
78
+ transitions: {
79
+ "*": "END_1",
80
+ },
81
+ },
82
+ END_1: {
83
+ state_type: "END",
84
+ outcome: "test",
85
+ },
86
+ },
87
+ },
88
+ };
89
+
90
+ const simpleExpressionFlow: Flow = {
91
+ id: "test-flow",
92
+ views: [
93
+ {
94
+ id: "view-1",
95
+ type: "view",
96
+ foo: {
97
+ asset: {
98
+ type: "whatevs",
99
+ id: "foo",
100
+ binding: "data.foo",
101
+ },
102
+ },
103
+ foo2: {
104
+ asset: {
105
+ type: "whatevs",
106
+ id: "foo2",
107
+ binding: "data.foo2",
108
+ },
109
+ },
110
+ bar: {
111
+ asset: {
112
+ type: "whatevs",
113
+ id: "bar",
114
+ binding: "data.bar",
115
+ },
116
+ },
117
+ bar2: {
118
+ asset: {
119
+ type: "whatevs",
120
+ id: "bar2",
121
+ binding: "data.bar2",
122
+ },
123
+ },
124
+ },
125
+ ],
126
+ data: {},
127
+ schema: {
128
+ ROOT: {
129
+ data: {
130
+ type: "DataType",
131
+ },
132
+ },
133
+ DataType: {
134
+ foo: {
135
+ type: "CatType",
136
+ validation: [
137
+ {
138
+ type: "expression",
139
+ exp: "!(isEmpty({{data.foo}}) && !isEmpty({{data.foo2}}))",
140
+ severity: "warning",
141
+ },
142
+ ],
143
+ },
144
+ bar: {
145
+ type: "CatType",
146
+ validation: [
147
+ {
148
+ type: "expression",
149
+ exp: "!(isEmpty({{data.bar}}) && !isEmpty({{data.bar2}}))",
150
+ severity: "warning",
151
+ },
152
+ ],
153
+ },
154
+ },
155
+ },
156
+ navigation: {
157
+ BEGIN: "FLOW_1",
158
+ FLOW_1: {
159
+ startState: "VIEW_1",
160
+ VIEW_1: {
161
+ state_type: "VIEW",
162
+ ref: "view-1",
163
+ transitions: {
164
+ "*": "END_1",
165
+ },
166
+ },
167
+ END_1: {
168
+ state_type: "END",
169
+ outcome: "test",
170
+ },
171
+ },
172
+ },
173
+ };
174
+
175
+ const flowWithMultiNode: Flow = {
176
+ id: "test-flow",
177
+ views: [
178
+ {
179
+ id: "view-1",
180
+ type: "view",
181
+ multiNode: [
182
+ {
183
+ nestedMultiNode: [
184
+ {
185
+ asset: {
186
+ type: "asset-type",
187
+ id: "nested-asset",
188
+ binding: "data.foo",
189
+ },
190
+ },
191
+ ],
192
+ },
193
+ ],
194
+ },
195
+ ],
196
+ data: {},
197
+ schema: {
198
+ ROOT: {
199
+ data: {
200
+ type: "DataType",
201
+ },
202
+ },
203
+ DataType: {
204
+ foo: {
205
+ type: "CatType",
206
+ validation: [
207
+ {
208
+ type: "names",
209
+ names: ["frodo", "sam"],
210
+ trigger: "navigation",
211
+ severity: "warning",
212
+ },
213
+ ],
214
+ },
215
+ },
216
+ },
217
+ navigation: {
218
+ BEGIN: "FLOW_1",
219
+ FLOW_1: {
220
+ startState: "VIEW_1",
221
+ VIEW_1: {
222
+ state_type: "VIEW",
223
+ ref: "view-1",
224
+ transitions: {
225
+ "*": "END_1",
226
+ },
227
+ },
228
+ END_1: {
229
+ state_type: "END",
230
+ outcome: "test",
231
+ },
232
+ },
233
+ },
234
+ };
235
+
236
+ const flowWithThings: Flow = {
237
+ id: "test-flow",
238
+ views: [
239
+ {
240
+ id: "view-1",
241
+ type: "view",
242
+ thing1: {
243
+ asset: {
244
+ type: "whatevs",
245
+ id: "thing1",
246
+ binding: "data.thing1",
247
+ applicability: "{{applicability.thing1}}",
248
+ },
249
+ },
250
+ thing2: {
251
+ asset: {
252
+ type: "whatevs",
253
+ id: "thing2",
254
+ binding: "data.thing2",
255
+ applicability: "{{applicability.thing2}}",
256
+ },
257
+ },
258
+ thing3: {
259
+ asset: {
260
+ type: "whatevs",
261
+ id: "thing3",
262
+ applicability: "{{applicability.thing3}}",
263
+ binding: "data.thing3",
264
+ other: {
265
+ asset: {
266
+ type: "whatevs",
267
+ id: "thing3a",
268
+ binding: "data.thing3a",
269
+ applicability: "{{applicability.thing3a}}",
270
+ },
271
+ },
272
+ },
273
+ },
274
+ thing5: {
275
+ asset: {
276
+ type: "section",
277
+ id: "thing5",
278
+ binding: "data.thing5",
279
+ applicability: "{{applicability.thing5}}",
280
+ thing6: {
281
+ asset: {
282
+ type: "section",
283
+ id: "thing6",
284
+ binding: "data.thing6",
285
+ applicability: "{{applicability.thing6}}",
286
+ thing7: {
287
+ asset: {
288
+ type: "whatevs",
289
+ id: "thing7",
290
+ binding: "data.thing7",
291
+ applicability: "{{applicability.thing7}}",
292
+ },
293
+ },
294
+ },
295
+ },
296
+ },
297
+ },
298
+ alreadyInvalidData: {
299
+ asset: {
300
+ type: "invalid",
301
+ id: "thing4",
302
+ binding: "data.thing4",
303
+ },
304
+ },
305
+ },
306
+ ],
307
+ data: {
308
+ applicability: {
309
+ thing1: true,
310
+ thing2: true,
311
+ thing3: true,
312
+ thing3a: true,
313
+ thing5: true,
314
+ thing6: true,
315
+ thing7: true,
316
+ },
317
+ data: {
318
+ thing2: "frodo",
319
+ thing4: "frodo",
320
+ },
321
+ },
322
+ schema: {
323
+ ROOT: {
324
+ data: {
325
+ type: "DataType",
326
+ },
327
+ },
328
+ DataType: {
329
+ thing2: {
330
+ type: "CatType",
331
+ validation: [
332
+ {
333
+ type: "names",
334
+ names: ["frodo", "sam"],
335
+ },
336
+ ],
337
+ },
338
+ thing4: {
339
+ type: "CatType",
340
+ validation: [
341
+ {
342
+ type: "names",
343
+ names: ["sam"],
344
+ },
345
+ ],
346
+ },
347
+ thing5: {
348
+ type: "CatType",
349
+ validation: [
350
+ {
351
+ type: "names",
352
+ names: ["frodo"],
353
+ displayTarget: "page",
354
+ },
355
+ ],
356
+ },
357
+ thing6: {
358
+ type: "CatType",
359
+ validation: [
360
+ {
361
+ type: "names",
362
+ names: ["sam"],
363
+ displayTarget: "section",
364
+ },
365
+ ],
366
+ },
367
+ thing7: {
368
+ type: "CatType",
369
+ validation: [
370
+ {
371
+ type: "names",
372
+ names: ["bilbo"],
373
+ displayTarget: "section",
374
+ },
375
+ ],
376
+ },
377
+ },
378
+ },
379
+ navigation: {
380
+ BEGIN: "FLOW_1",
381
+ FLOW_1: {
382
+ startState: "VIEW_1",
383
+ VIEW_1: {
384
+ state_type: "VIEW",
385
+ ref: "view-1",
386
+ transitions: {
387
+ "*": "END_1",
388
+ },
389
+ },
390
+ END_1: {
391
+ state_type: "END",
392
+ outcome: "test",
393
+ },
394
+ },
395
+ },
396
+ };
397
+
398
+ const flowWithApplicability: Flow = {
399
+ id: "test-flow",
400
+ views: [
401
+ {
402
+ id: "view-1",
403
+ type: "view",
404
+ thing1: {
405
+ asset: {
406
+ type: "whatevs",
407
+ id: "thing1",
408
+ binding: "dependentBinding",
409
+ },
410
+ },
411
+ thing2: {
412
+ asset: {
413
+ type: "whatevs",
414
+ id: "thing2",
415
+ binding: "independentBinding",
416
+ },
417
+ },
418
+ thing3: {
419
+ asset: {
420
+ type: "whatevs",
421
+ id: "thing3",
422
+ applicability: "{{independentBinding}} == true",
423
+ },
424
+ },
425
+ validation: [
426
+ {
427
+ type: "requiredIf",
428
+ ref: "dependentBinding",
429
+ trigger: "load",
430
+ param: "{{independentBinding}}",
431
+ message: "required based on independent value",
432
+ },
433
+ ],
434
+ },
435
+ ],
436
+ data: {},
437
+ navigation: {
438
+ BEGIN: "FLOW_1",
439
+ FLOW_1: {
440
+ startState: "VIEW_1",
441
+ VIEW_1: {
442
+ state_type: "VIEW",
443
+ ref: "view-1",
444
+ transitions: {
445
+ "*": "END_1",
446
+ },
447
+ },
448
+ END_1: {
449
+ state_type: "END",
450
+ outcome: "test",
451
+ },
452
+ },
453
+ },
454
+ };
455
+
456
+ const flowWithItemsInArray: Flow = {
457
+ id: "test-flow",
458
+ views: [
459
+ {
460
+ id: "view-1",
461
+ type: "view",
462
+ pets: [
463
+ {
464
+ asset: {
465
+ type: "whatevs",
466
+ id: "thing1",
467
+ binding: "pets.0.name",
468
+ },
469
+ },
470
+ {
471
+ asset: {
472
+ type: "whatevs",
473
+ id: "thing2",
474
+ binding: "pets.1.name",
475
+ },
476
+ },
477
+ {
478
+ asset: {
479
+ type: "whatevs",
480
+ id: "thing2",
481
+ binding: "pets.2.name",
482
+ },
483
+ },
484
+ ],
485
+ },
486
+ ],
487
+ data: {
488
+ pets: [],
489
+ },
490
+ schema: {
491
+ ROOT: {
492
+ pets: {
493
+ type: "PetType",
494
+ isArray: true,
495
+ },
496
+ },
497
+ PetType: {
498
+ name: {
499
+ type: "string",
500
+ validation: [
501
+ {
502
+ type: "required",
503
+ },
504
+ ],
505
+ },
506
+ },
507
+ },
508
+ navigation: {
509
+ BEGIN: "FLOW_1",
510
+ FLOW_1: {
511
+ startState: "VIEW_1",
512
+ VIEW_1: {
513
+ state_type: "VIEW",
514
+ ref: "view-1",
515
+ transitions: {
516
+ "*": "END_1",
517
+ },
518
+ },
519
+ END_1: {
520
+ state_type: "END",
521
+ outcome: "test",
522
+ },
523
+ },
524
+ },
525
+ };
526
+
527
+ const multipleWarningsFlow: Flow = {
528
+ id: "input-validation-flow",
529
+ views: [
530
+ {
531
+ type: "view",
532
+ id: "view",
533
+ loadWarning: {
534
+ asset: {
535
+ id: "load-warning",
536
+ type: "warning-asset",
537
+ binding: "foo.load",
538
+ },
539
+ },
540
+ navigationWarning: {
541
+ asset: {
542
+ id: "required-warning",
543
+ type: "warning-asset",
544
+ binding: "foo.navigation",
545
+ },
546
+ },
547
+ },
548
+ ],
549
+ schema: {
550
+ ROOT: {
551
+ foo: {
552
+ type: "FooType",
553
+ },
554
+ },
555
+ FooType: {
556
+ navigation: {
557
+ type: "String",
558
+ validation: [
559
+ {
560
+ type: "required",
561
+ severity: "warning",
562
+ blocking: "once",
563
+ trigger: "navigation",
564
+ },
565
+ ],
566
+ },
567
+ load: {
568
+ type: "String",
569
+ validation: [
570
+ {
571
+ type: "required",
572
+ severity: "warning",
573
+ blocking: "once",
574
+ trigger: "load",
575
+ },
576
+ ],
577
+ },
578
+ },
579
+ },
580
+ data: {},
581
+ navigation: {
582
+ BEGIN: "FLOW_1",
583
+ FLOW_1: {
584
+ startState: "VIEW_1",
585
+ VIEW_1: {
586
+ state_type: "VIEW",
587
+ ref: "view",
588
+ transitions: {
589
+ "*": "END_Done",
590
+ },
591
+ },
592
+ END_Done: {
593
+ state_type: "END",
594
+ outcome: "done",
595
+ },
596
+ },
597
+ },
598
+ };
599
+
600
+ const simpleFlowWithViewValidation: Flow = {
601
+ id: "test-flow",
602
+ views: [
603
+ {
604
+ id: "view-1",
605
+ type: "view",
606
+ thing1: {
607
+ asset: {
608
+ type: "whatevs",
609
+ id: "thing1",
610
+ binding: "data.thing1",
611
+ },
612
+ },
613
+ validation: [
614
+ {
615
+ ref: "data.thing1",
616
+ type: "expression",
617
+ exp: "{{data.thing1}} > 50",
618
+ trigger: "navigation",
619
+ message: "Must be greater than 50",
620
+ },
621
+ ],
622
+ },
623
+ ],
624
+ data: {},
625
+ schema: {
626
+ ROOT: {
627
+ data: {
628
+ type: "DataType",
629
+ },
630
+ },
631
+ DataType: {
632
+ thing1: {
633
+ type: "IntegerType",
634
+ validation: [
635
+ {
636
+ type: "required",
637
+ },
638
+ ],
639
+ },
640
+ },
641
+ },
642
+ navigation: {
643
+ BEGIN: "FLOW_1",
644
+ FLOW_1: {
645
+ startState: "VIEW_1",
646
+ VIEW_1: {
647
+ state_type: "VIEW",
648
+ ref: "view-1",
649
+ transitions: {
650
+ "*": "END_1",
651
+ },
652
+ },
653
+ END_1: {
654
+ state_type: "END",
655
+ outcome: "test",
656
+ },
657
+ },
658
+ },
659
+ };
660
+
661
+ test("alt APIs", async () => {
662
+ const player = new Player();
663
+
664
+ player.hooks.validationController.tap("test", (validationProvider) => {
665
+ addValidator(validationProvider);
666
+ });
667
+
668
+ player.hooks.viewController.tap("test", (vc) => {
669
+ vc.hooks.view.tap("test", (view) => {
670
+ view.hooks.resolver.tap("test", (resolver) => {
671
+ resolver.hooks.resolve.tap("test", (val, node, options) => {
672
+ if (val.type === "section") {
673
+ options.validation?.register({ type: "section" });
674
+ }
675
+
676
+ if (val?.binding) {
677
+ return {
678
+ ...val,
679
+ validation: options.validation?.get(val.binding, { track: true }),
680
+ childValidations: options.validation?.getChildren,
681
+ sectionValidations: options.validation?.getValidationsForSection,
682
+ allValidations: options.validation?.getAll(),
683
+ };
684
+ }
685
+
686
+ return {
687
+ ...val,
688
+ childValidations: options.validation?.getChildren,
689
+ groupValidations: options.validation?.getValidationsForSection,
690
+ allValidations: options.validation?.getAll(),
691
+ };
692
+ });
693
+ });
694
+ });
695
+ });
696
+ player.start(flowWithThings);
697
+
698
+ const state = player.getState() as InProgressState;
699
+
700
+ // Starts out with nothing
701
+ expect(
702
+ state.controllers.view.currentView?.lastUpdate?.thing2.asset.validation,
703
+ ).toBe(undefined);
704
+
705
+ // Updates when data is updated to throw an error
706
+ state.controllers.data.set([["data.thing2", "ginger"]]);
707
+ await vitest.waitFor(() =>
708
+ expect(
709
+ state.controllers.view.currentView?.lastUpdate?.thing2.asset.validation,
710
+ ).toMatchObject({
711
+ severity: "error",
712
+ message: `Names just be in: frodo,sam`,
713
+ displayTarget: "field",
714
+ }),
715
+ );
716
+
717
+ expect(
718
+ Array.from(
719
+ state.controllers.view.currentView?.lastUpdate?.thing2.asset.allValidations.values(),
720
+ ),
721
+ ).toMatchObject([
722
+ {
723
+ severity: "error",
724
+ message: `Names just be in: frodo,sam`,
725
+ displayTarget: "field",
726
+ },
727
+ ]);
728
+
729
+ // check that the childValidations and sectionValidation computation works and
730
+ state.controllers.data.set([["data.thing5", "sam"]]);
731
+ state.controllers.data.set([["data.thing6", "frodo"]]);
732
+ state.controllers.data.set([["data.thing7", "golumn"]]);
733
+
734
+ // Gets all page errors for all children
735
+ await vitest.waitFor(() =>
736
+ expect(
737
+ Array.from(
738
+ state.controllers.view.currentView?.lastUpdate
739
+ ?.childValidations("page")
740
+ .values(),
741
+ ),
742
+ ).toMatchObject([
743
+ {
744
+ severity: "error",
745
+ message: `Names just be in: frodo`,
746
+ displayTarget: "page",
747
+ },
748
+ ]),
749
+ );
750
+
751
+ // Gets all section errors for all children
752
+ expect(
753
+ Array.from(
754
+ state.controllers.view.currentView?.lastUpdate
755
+ ?.childValidations("section")
756
+ .values(),
757
+ ),
758
+ ).toMatchObject([
759
+ {
760
+ severity: "error",
761
+ message: `Names just be in: sam`,
762
+ displayTarget: "section",
763
+ },
764
+ {
765
+ severity: "error",
766
+ message: `Names just be in: bilbo`,
767
+ displayTarget: "section",
768
+ },
769
+ ]);
770
+
771
+ // Gets section error for child that is not wrapped in nested section
772
+ expect(
773
+ Array.from(
774
+ state.controllers.view.currentView?.lastUpdate?.thing5.asset
775
+ ?.sectionValidations()
776
+ .values(),
777
+ ),
778
+ ).toMatchObject([
779
+ {
780
+ severity: "error",
781
+ message: `Names just be in: sam`,
782
+ displayTarget: "section",
783
+ },
784
+ ]);
785
+
786
+ // Ensure that nested section still produces an error
787
+ expect(
788
+ Array.from(
789
+ state.controllers.view.currentView?.lastUpdate?.thing5.asset.thing6.asset
790
+ ?.sectionValidations()
791
+ .values(),
792
+ ),
793
+ ).toMatchObject([
794
+ {
795
+ severity: "error",
796
+ message: `Names just be in: bilbo`,
797
+ displayTarget: "section",
798
+ },
799
+ ]);
800
+ });
801
+
802
+ describe("validation", () => {
803
+ let player: Player;
804
+ let validationController: ValidationController;
805
+ let schema: SchemaController;
806
+ let parser: BindingParser;
807
+
808
+ beforeEach(() => {
809
+ player = new Player({
810
+ plugins: [new TrackBindingPlugin()],
811
+ });
812
+ player.hooks.validationController.tap("test", (vc) => {
813
+ validationController = vc;
814
+ });
815
+ player.hooks.schema.tap("test", (s) => {
816
+ schema = s;
817
+ });
818
+ player.hooks.bindingParser.tap("test", (p) => {
819
+ parser = p;
820
+ });
821
+
822
+ player.start(flowWithThings);
823
+ });
824
+
825
+ describe("binding tracker", () => {
826
+ it("tracks bindings in the view", () => {
827
+ expect(validationController?.getBindings().size).toStrictEqual(8);
828
+ });
829
+
830
+ it("preserves tracked bindings for non-updated things", () => {
831
+ expect(validationController?.getBindings().size).toStrictEqual(8);
832
+
833
+ (player.getState() as InProgressState).controllers.data.set([
834
+ ["not.there", false],
835
+ ]);
836
+ expect(validationController?.getBindings().size).toStrictEqual(8);
837
+ });
838
+
839
+ it("drops bindings for non-applicable things", async () => {
840
+ expect(validationController?.getBindings().size).toStrictEqual(8);
841
+
842
+ (player.getState() as InProgressState).controllers.data.set([
843
+ ["applicability.thing3", false],
844
+ ]);
845
+
846
+ await vitest.waitFor(() =>
847
+ expect(validationController?.getBindings().size).toStrictEqual(6),
848
+ );
849
+ });
850
+
851
+ it("track bindings in nested multi nodes", async () => {
852
+ player.start(flowWithMultiNode);
853
+
854
+ await vitest.waitFor(() =>
855
+ expect(validationController?.getBindings().size).toStrictEqual(1),
856
+ );
857
+ });
858
+ });
859
+
860
+ describe("schema", () => {
861
+ it("tests the types right", () => {
862
+ expect(schema.getType(parser.parse("data.thing2"))?.type).toBe("CatType");
863
+ });
864
+ });
865
+
866
+ describe("data model delete", () => {
867
+ it("deletes the validation when the data is deleted", async () => {
868
+ const state = player.getState() as InProgressState;
869
+
870
+ const { validation, data, binding, view } = state.controllers;
871
+ const thing2Binding = binding.parse("data.thing2");
872
+
873
+ expect(validation.getBindings().has(thing2Binding)).toBe(true);
874
+
875
+ await vitest.waitFor(() => {
876
+ expect(
877
+ view.currentView?.lastUpdate?.thing2.asset.validation,
878
+ ).toBeUndefined();
879
+ });
880
+
881
+ data.set([["data.thing2", "gandalf"]]);
882
+
883
+ await vitest.waitFor(() => {
884
+ expect(
885
+ view.currentView?.lastUpdate?.thing2.asset.validation?.message,
886
+ ).toBe("Names just be in: frodo,sam");
887
+ });
888
+
889
+ data.delete("data.thing2");
890
+ expect(data.get("data.thing2", { includeInvalid: true })).toBe(undefined);
891
+
892
+ await vitest.waitFor(() => {
893
+ expect(
894
+ view.currentView?.lastUpdate?.thing2.asset.validation,
895
+ ).toBeUndefined();
896
+ });
897
+
898
+ data.set([["data.thing2", "gandalf"]]);
899
+ await vitest.waitFor(() => {
900
+ expect(
901
+ view.currentView?.lastUpdate?.thing2.asset.validation?.message,
902
+ ).toBe("Names just be in: frodo,sam");
903
+ });
904
+ });
905
+
906
+ it("handles arrays", async () => {
907
+ player.start(flowWithItemsInArray);
908
+ const state = player.getState() as InProgressState;
909
+ const { data, binding, view } = state.controllers;
910
+
911
+ await vitest.waitFor(() => {
912
+ expect(
913
+ view.currentView?.lastUpdate?.pets[1].asset.validation,
914
+ ).toBeUndefined();
915
+ });
916
+
917
+ // Trigger validation for the second item
918
+ data.set([["pets.1.name", ""]]);
919
+ expect(
920
+ schema.getType(binding.parse("pets.1.name"))?.validation,
921
+ ).toHaveLength(1);
922
+
923
+ await vitest.waitFor(() => {
924
+ expect(
925
+ view.currentView?.lastUpdate?.pets[1].asset.validation?.message,
926
+ ).toBe("A value is required");
927
+ });
928
+
929
+ // Delete the first item, the items should shift up and validation moves to the first item
930
+ data.delete("pets.0");
931
+
932
+ await vitest.waitFor(() => {
933
+ expect(
934
+ view.currentView?.lastUpdate?.pets[1].asset.validation,
935
+ ).toBeUndefined();
936
+ expect(
937
+ view.currentView?.lastUpdate?.pets[0].asset.validation?.message,
938
+ ).toBe("A value is required");
939
+ });
940
+ });
941
+ });
942
+
943
+ describe("state", () => {
944
+ it("updates when setting data", async () => {
945
+ const state = player.getState() as InProgressState;
946
+
947
+ // Starts out with nothing
948
+ expect(
949
+ state.controllers.view.currentView?.lastUpdate?.thing2.asset.validation,
950
+ ).toBe(undefined);
951
+
952
+ // Updates when data is updated to throw an error
953
+ state.controllers.data.set([["data.thing2", "ginger"]]);
954
+ await vitest.waitFor(() =>
955
+ expect(
956
+ state.controllers.view.currentView?.lastUpdate?.thing2.asset
957
+ .validation,
958
+ ).toMatchObject({
959
+ severity: "error",
960
+ message: `Names just be in: frodo,sam`,
961
+ displayTarget: "field",
962
+ }),
963
+ );
964
+
965
+ // Back to nothing when the error is fixed
966
+ state.controllers.data.set([["data.thing2", "frodo"]]);
967
+ await vitest.waitFor(() =>
968
+ expect(
969
+ state.controllers.view.currentView?.lastUpdate?.thing2.asset
970
+ .validation,
971
+ ).toBe(undefined),
972
+ );
973
+ });
974
+ });
975
+
976
+ describe("validation object", () => {
977
+ it("returns the whole validation object", async () => {
978
+ const state = player.getState() as InProgressState;
979
+
980
+ // Starts out with nothing
981
+ expect(
982
+ state.controllers.view.currentView?.lastUpdate?.thing2.asset.validation,
983
+ ).toBe(undefined);
984
+
985
+ // Updates when data is updated to throw an error
986
+ state.controllers.data.set([["data.thing2", "ginger"]]);
987
+ await vitest.waitFor(() =>
988
+ expect(
989
+ state.controllers.view.currentView?.lastUpdate?.thing2.asset
990
+ .validation,
991
+ ).toStrictEqual({
992
+ severity: "error",
993
+ message: `Names just be in: frodo,sam`,
994
+ names: ["frodo", "sam"],
995
+ displayTarget: "field",
996
+ trigger: "change",
997
+ type: "names",
998
+ blocking: true,
999
+ [VALIDATION_PROVIDER_NAME_SYMBOL]: "schema",
1000
+ }),
1001
+ );
1002
+
1003
+ // Back to nothing when the error is fixed
1004
+ state.controllers.data.set([["data.thing2", "frodo"]]);
1005
+ await vitest.waitFor(() =>
1006
+ expect(
1007
+ state.controllers.view.currentView?.lastUpdate?.thing2.asset
1008
+ .validation,
1009
+ ).toBe(undefined),
1010
+ );
1011
+ });
1012
+ });
1013
+
1014
+ describe("navigation", () => {
1015
+ it("prevents navigation for pre-existing invalid data", async () => {
1016
+ const state = player.getState() as InProgressState;
1017
+ const { flowResult } = state;
1018
+ // Starts out with nothing
1019
+ expect(
1020
+ state.controllers.view.currentView?.lastUpdate?.alreadyInvalidData.asset
1021
+ .validation,
1022
+ ).toBe(undefined);
1023
+
1024
+ // Try to transition
1025
+ state.controllers.flow.transition("foo");
1026
+
1027
+ // Stays on the same view
1028
+ expect(
1029
+ state.controllers.flow.current?.currentState?.value.state_type,
1030
+ ).toBe("VIEW");
1031
+
1032
+ // Fix the error.
1033
+ state.controllers.data.set([["data.thing4", "sam"]]);
1034
+ state.controllers.data.set([["data.thing5", "frodo"]]);
1035
+ state.controllers.data.set([["data.thing6", "sam"]]);
1036
+ state.controllers.data.set([["data.thing7", "bilbo"]]);
1037
+
1038
+ // Try to transition again
1039
+ state.controllers.flow.transition("foo");
1040
+
1041
+ // Should work now that there's no error
1042
+ const result = await flowResult;
1043
+ expect(result.endState.outcome).toBe("test");
1044
+ });
1045
+
1046
+ it("block navigation after data changes on first input, show warning on second input, then navigation succeeds", async () => {
1047
+ player.start(simpleFlow);
1048
+ const state = player.getState() as InProgressState;
1049
+ const { flowResult } = state;
1050
+ // Starts out with nothing
1051
+ expect(
1052
+ state.controllers.view.currentView?.lastUpdate?.thing1.asset.validation,
1053
+ ).toBe(undefined);
1054
+
1055
+ expect(
1056
+ state.controllers.view.currentView?.lastUpdate?.thing2.asset.validation,
1057
+ ).toBe(undefined);
1058
+
1059
+ state.controllers.data.set([["data.thing1", "sam"]]);
1060
+
1061
+ // Try to transition
1062
+ state.controllers.flow.transition("foo");
1063
+
1064
+ // Stays on the same view
1065
+ expect(
1066
+ state.controllers.flow.current?.currentState?.value.state_type,
1067
+ ).toBe("VIEW");
1068
+
1069
+ expect(
1070
+ state.controllers.view.currentView?.lastUpdate?.thing1.asset.validation,
1071
+ ).toBe(undefined);
1072
+
1073
+ expect(
1074
+ state.controllers.view.currentView?.lastUpdate?.thing2.asset.validation,
1075
+ ).not.toBe(undefined);
1076
+
1077
+ state.controllers.data.set([["data.thing1", "bilbo"]]);
1078
+
1079
+ // Try to transition
1080
+ state.controllers.flow.transition("foo");
1081
+
1082
+ // Should transition to end since data changes already occured on first input
1083
+ expect(
1084
+ state.controllers.flow.current?.currentState?.value.state_type,
1085
+ ).toBe("END");
1086
+
1087
+ // Should work now that there's no error
1088
+ const result = await flowResult;
1089
+ expect(result.endState.outcome).toBe("test");
1090
+ });
1091
+ it("doesnt remove existing expression warnings if a new warning is triggered", async () => {
1092
+ player.hooks.expressionEvaluator.tap("test", (evaluator) => {
1093
+ evaluator.addExpressionFunction("isEmpty", (ctx: any, val: any) => {
1094
+ if (val === undefined || val === null) {
1095
+ return true;
1096
+ }
1097
+
1098
+ if (typeof val === "string") {
1099
+ return val.length === 0;
1100
+ }
1101
+
1102
+ return false;
1103
+ });
1104
+ });
1105
+ player.start(simpleExpressionFlow);
1106
+ const state = player.getState() as InProgressState;
1107
+ const { flowResult } = state;
1108
+ // Starts out with nothing
1109
+ expect(
1110
+ state.controllers.view.currentView?.lastUpdate?.foo.asset.validation,
1111
+ ).toBe(undefined);
1112
+
1113
+ expect(
1114
+ state.controllers.view.currentView?.lastUpdate?.bar.asset.validation,
1115
+ ).toBe(undefined);
1116
+
1117
+ state.controllers.data.set([["data.foo2", "someData"]]);
1118
+
1119
+ // Try to transition
1120
+ state.controllers.flow.transition("foo");
1121
+
1122
+ // Stays on the same view
1123
+ expect(
1124
+ state.controllers.flow.current?.currentState?.value.state_type,
1125
+ ).toBe("VIEW");
1126
+
1127
+ expect(
1128
+ state.controllers.view.currentView?.lastUpdate?.foo.asset.validation,
1129
+ ).not.toBe(undefined);
1130
+
1131
+ expect(
1132
+ state.controllers.view.currentView?.lastUpdate?.foo2.asset.validation,
1133
+ ).toBe(undefined);
1134
+
1135
+ expect(
1136
+ state.controllers.view.currentView?.lastUpdate?.bar.asset.validation,
1137
+ ).toBe(undefined);
1138
+
1139
+ expect(
1140
+ state.controllers.view.currentView?.lastUpdate?.bar2.asset.validation,
1141
+ ).toBe(undefined);
1142
+
1143
+ state.controllers.data.set([["data.bar2", "someData"]]);
1144
+
1145
+ // Try to transition
1146
+ state.controllers.flow.transition("foo");
1147
+
1148
+ // Stays on the same view
1149
+ expect(
1150
+ state.controllers.flow.current?.currentState?.value.state_type,
1151
+ ).toBe("VIEW");
1152
+
1153
+ // existing validation
1154
+ // FAILS HERE
1155
+ expect(
1156
+ state.controllers.view.currentView?.lastUpdate?.foo.asset.validation,
1157
+ ).not.toBe(undefined);
1158
+
1159
+ expect(
1160
+ state.controllers.view.currentView?.lastUpdate?.foo2.asset.validation,
1161
+ ).toBe(undefined);
1162
+
1163
+ // new validation
1164
+ expect(
1165
+ state.controllers.view.currentView?.lastUpdate?.bar.asset.validation,
1166
+ ).not.toBe(undefined);
1167
+
1168
+ expect(
1169
+ state.controllers.view.currentView?.lastUpdate?.bar2.asset.validation,
1170
+ ).toBe(undefined);
1171
+
1172
+ state.controllers.data.set([["data.foo", "frodo"]]);
1173
+ state.controllers.data.set([["data.bar", "sam"]]);
1174
+
1175
+ // Try to transition again
1176
+ state.controllers.flow.transition("foo");
1177
+
1178
+ // Should work now that there's no error
1179
+ const result = await flowResult;
1180
+ expect(result.endState.outcome).toBe("test");
1181
+ });
1182
+
1183
+ it("autodismiss if data change already took place on input with warning, manually dismiss second warning", async () => {
1184
+ player.start(simpleFlow);
1185
+ const state = player.getState() as InProgressState;
1186
+ const { flowResult } = state;
1187
+ // Starts out with nothing
1188
+ expect(
1189
+ state.controllers.view.currentView?.lastUpdate?.thing1.asset.validation,
1190
+ ).toBe(undefined);
1191
+
1192
+ expect(
1193
+ state.controllers.view.currentView?.lastUpdate?.thing2.asset.validation,
1194
+ ).toBe(undefined);
1195
+
1196
+ state.controllers.data.set([["data.thing1", "sam"]]);
1197
+
1198
+ // Try to transition
1199
+ state.controllers.flow.transition("foo");
1200
+
1201
+ // Stays on the same view
1202
+ expect(
1203
+ state.controllers.flow.current?.currentState?.value.state_type,
1204
+ ).toBe("VIEW");
1205
+
1206
+ expect(
1207
+ state.controllers.view.currentView?.lastUpdate?.thing1.asset.validation,
1208
+ ).toBe(undefined);
1209
+
1210
+ expect(
1211
+ state.controllers.view.currentView?.lastUpdate?.thing2.asset.validation,
1212
+ ).not.toBe(undefined);
1213
+
1214
+ state.controllers.data.set([["data.thing1", "bilbo"]]);
1215
+ state.controllers.view.currentView?.lastUpdate?.thing2.asset.validation.dismiss();
1216
+
1217
+ // Try to transition
1218
+ state.controllers.flow.transition("foo");
1219
+
1220
+ // Since data change (setting "sam") already triggered validation next step is auto dismiss
1221
+ expect(
1222
+ state.controllers.flow.current?.currentState?.value.state_type,
1223
+ ).toBe("END");
1224
+
1225
+ // Should work now that there's no error
1226
+ const result = await flowResult;
1227
+ expect(result.endState.outcome).toBe("test");
1228
+ });
1229
+
1230
+ it("should auto-dismiss when dismissal is triggered", async () => {
1231
+ player.start(multipleWarningsFlow);
1232
+ const state = player.getState() as InProgressState;
1233
+ const { flowResult } = state;
1234
+ // Starts with one warning
1235
+ expect(
1236
+ state.controllers.view.currentView?.lastUpdate?.loadWarning.asset
1237
+ .validation,
1238
+ ).toBeDefined();
1239
+
1240
+ expect(
1241
+ state.controllers.view.currentView?.lastUpdate?.navigationWarning.asset
1242
+ .validation,
1243
+ ).toBeUndefined();
1244
+
1245
+ // Try to transition
1246
+ state.controllers.flow.transition("next");
1247
+
1248
+ // Stays on the same view
1249
+ expect(
1250
+ state.controllers.flow.current?.currentState?.value.state_type,
1251
+ ).toBe("VIEW");
1252
+
1253
+ // new warning appears
1254
+ expect(
1255
+ state.controllers.view.currentView?.lastUpdate?.loadWarning.asset
1256
+ .validation,
1257
+ ).toBeDefined();
1258
+
1259
+ expect(
1260
+ state.controllers.view.currentView?.lastUpdate?.navigationWarning.asset
1261
+ .validation,
1262
+ ).toBeDefined();
1263
+
1264
+ // Try to transition
1265
+ state.controllers.flow.transition("next");
1266
+
1267
+ // Since data change (setting "sam") already triggered validation next step is auto dismiss
1268
+ expect(
1269
+ state.controllers.flow.current?.currentState?.value.state_type,
1270
+ ).toBe("END");
1271
+
1272
+ // Should work now that there's no error
1273
+ const result = await flowResult;
1274
+ expect(result.endState.outcome).toBe("done");
1275
+ });
1276
+ });
1277
+
1278
+ describe("introspection and filtering", () => {
1279
+ /**
1280
+ *
1281
+ */
1282
+ const getAllKnownValidations = () => {
1283
+ const allBindings = validationController.getBindings();
1284
+ const allValidations = Array.from(allBindings).flatMap((b) => {
1285
+ const validatedBinding =
1286
+ validationController.getValidationForBinding(b);
1287
+
1288
+ if (!validatedBinding) {
1289
+ return [];
1290
+ }
1291
+
1292
+ return validatedBinding.allValidations.map((v) => {
1293
+ return {
1294
+ binding: b,
1295
+ validation: v,
1296
+ response: validationController.validationRunner(v.value, b),
1297
+ };
1298
+ });
1299
+ });
1300
+
1301
+ return allValidations;
1302
+ };
1303
+
1304
+ it("can query all triggered validations", async () => {
1305
+ const state = player.getState() as InProgressState;
1306
+ state.controllers.data.set([["data.thing4", "not-sam"]]);
1307
+
1308
+ await vitest.waitFor(() => {
1309
+ expect(
1310
+ state.controllers.view.currentView?.lastUpdate?.alreadyInvalidData
1311
+ .asset.validation.message,
1312
+ ).toBe("Names just be in: sam");
1313
+ });
1314
+
1315
+ const currentValidations = getAllKnownValidations();
1316
+
1317
+ expect(currentValidations).toHaveLength(5);
1318
+ expect(
1319
+ currentValidations[0].validation.value[VALIDATION_PROVIDER_NAME_SYMBOL],
1320
+ ).toBe("schema");
1321
+ });
1322
+
1323
+ it("can compute new validations without dismissing existing ones", async () => {
1324
+ const updatedFlow = {
1325
+ ...flowWithThings,
1326
+ views: [
1327
+ {
1328
+ ...flowWithThings.views?.[0],
1329
+ validation: [
1330
+ {
1331
+ type: "expression",
1332
+ ref: "data.thing2",
1333
+ message: "Both need to equal 100",
1334
+ exp: "{{data.thing1}} + {{data.thing2}} == 100",
1335
+ },
1336
+ ],
1337
+ },
1338
+ ],
1339
+ };
1340
+
1341
+ player.start(updatedFlow as any);
1342
+ const currentValidations = getAllKnownValidations();
1343
+ expect(currentValidations).toHaveLength(6);
1344
+ });
1345
+ });
1346
+ });
1347
+
1348
+ describe("cross-field validation", () => {
1349
+ const crossFieldFlow = makeFlow({
1350
+ id: "view-1",
1351
+ type: "view",
1352
+ thing1: {
1353
+ asset: {
1354
+ id: "thing-1",
1355
+ binding: "foo.data.thing1",
1356
+ type: "input",
1357
+ },
1358
+ },
1359
+ thing2: {
1360
+ asset: {
1361
+ id: "thing-2",
1362
+ binding: "foo.data.thing2",
1363
+ type: "input",
1364
+ },
1365
+ },
1366
+ validation: [
1367
+ {
1368
+ type: "expression",
1369
+ ref: "foo.data.thing1",
1370
+ message: "Both need to equal 100",
1371
+ exp: "{{foo.data.thing1}} + {{foo.data.thing2}} == 100",
1372
+ },
1373
+ ],
1374
+ });
1375
+
1376
+ it("works for navigate triggers", async () => {
1377
+ const player = new Player({
1378
+ plugins: [new TrackBindingPlugin()],
1379
+ });
1380
+ player.start(crossFieldFlow);
1381
+ const state = player.getState() as InProgressState;
1382
+
1383
+ // Validation starts as nothing
1384
+ expect(
1385
+ state.controllers.view.currentView?.lastUpdate?.thing1.asset.validation,
1386
+ ).toBe(undefined);
1387
+
1388
+ // Updating a thing is still nothing (haven't navigated yet)
1389
+ state.controllers.data.set([["foo.data.thing1", 20]]);
1390
+ expect(
1391
+ state.controllers.view.currentView?.lastUpdate?.thing1.asset.validation,
1392
+ ).toBe(undefined);
1393
+
1394
+ // Try to navigate, should show the validation now
1395
+ state.controllers.flow.transition("next");
1396
+ expect(state.controllers.flow.current?.currentState?.value.state_type).toBe(
1397
+ "VIEW",
1398
+ );
1399
+ expect(
1400
+ state.controllers.view.currentView?.lastUpdate?.thing1.asset.validation,
1401
+ ).toMatchObject({
1402
+ severity: "error",
1403
+ message: "Both need to equal 100",
1404
+ displayTarget: "field",
1405
+ });
1406
+
1407
+ // Updating a thing is still nothing (haven't navigated yet)
1408
+ state.controllers.data.set([["foo.data.thing2", 85]]);
1409
+ expect(
1410
+ state.controllers.view.currentView?.lastUpdate?.thing1.asset.validation,
1411
+ ).toMatchObject({
1412
+ severity: "error",
1413
+ message: "Both need to equal 100",
1414
+ displayTarget: "field",
1415
+ });
1416
+
1417
+ // Set it equal to 100 and continue on
1418
+ state.controllers.data.set([["foo.data.thing2", 80]]);
1419
+ state.controllers.flow.transition("next");
1420
+
1421
+ expect(state.controllers.flow.current?.currentState?.value.state_type).toBe(
1422
+ "END",
1423
+ );
1424
+ });
1425
+
1426
+ it("takes precedence over schema validation for the same binding", async () => {
1427
+ const player = new Player({
1428
+ plugins: [new TrackBindingPlugin()],
1429
+ });
1430
+ player.start(simpleFlowWithViewValidation);
1431
+ const state = player.getState() as InProgressState;
1432
+
1433
+ // Validation starts as nothing
1434
+ expect(
1435
+ state.controllers.view.currentView?.lastUpdate?.thing1.asset.validation,
1436
+ ).toBe(undefined);
1437
+
1438
+ // Try to navigate, should show the validation now
1439
+ state.controllers.flow.transition("next");
1440
+ expect(state.controllers.flow.current?.currentState?.value.state_type).toBe(
1441
+ "VIEW",
1442
+ );
1443
+ expect(
1444
+ state.controllers.view.currentView?.lastUpdate?.thing1.asset.validation,
1445
+ ).toMatchObject({
1446
+ severity: "error",
1447
+ message: "Must be greater than 50",
1448
+ displayTarget: "field",
1449
+ });
1450
+
1451
+ // Updating a thing is still nothing (haven't navigated yet)
1452
+ state.controllers.data.set([["data.thing1", 51]]);
1453
+ expect(
1454
+ state.controllers.view.currentView?.lastUpdate?.thing1.asset.validation,
1455
+ ).toMatchObject({
1456
+ severity: "error",
1457
+ message: "Must be greater than 50",
1458
+ displayTarget: "field",
1459
+ });
1460
+
1461
+ // Set it equal to 100 and continue on
1462
+ state.controllers.flow.transition("next");
1463
+
1464
+ expect(state.controllers.flow.current?.currentState?.value.state_type).toBe(
1465
+ "END",
1466
+ );
1467
+ });
1468
+ });
1469
+
1470
+ test("shows errors on load", () => {
1471
+ const errFlow = makeFlow({
1472
+ id: "view-1",
1473
+ type: "view",
1474
+ thing1: {
1475
+ asset: {
1476
+ id: "thing-1",
1477
+ binding: "foo.data.thing1",
1478
+ type: "input",
1479
+ },
1480
+ },
1481
+ validation: [
1482
+ {
1483
+ type: "required",
1484
+ ref: "foo.data.thing1",
1485
+ message: "Stuffs broken",
1486
+ trigger: "load",
1487
+ severity: "error",
1488
+ },
1489
+ ],
1490
+ });
1491
+
1492
+ const player = new Player({ plugins: [new TrackBindingPlugin()] });
1493
+ player.start(errFlow);
1494
+ const state = player.getState() as InProgressState;
1495
+
1496
+ // Validation starts with a warning on load
1497
+ expect(
1498
+ state.controllers.view.currentView?.lastUpdate?.thing1.asset.validation,
1499
+ ).toMatchObject({
1500
+ message: "Stuffs broken",
1501
+ severity: "error",
1502
+ displayTarget: "field",
1503
+ });
1504
+ });
1505
+
1506
+ describe("errors", () => {
1507
+ const errorFlow = makeFlow({
1508
+ id: "view-1",
1509
+ type: "view",
1510
+ thing1: {
1511
+ asset: {
1512
+ id: "thing-1",
1513
+ binding: "foo.data.thing1",
1514
+ type: "input",
1515
+ },
1516
+ },
1517
+ validation: [
1518
+ {
1519
+ type: "required",
1520
+ ref: "foo.data.thing1",
1521
+ trigger: "load",
1522
+ severity: "error",
1523
+ },
1524
+ ],
1525
+ });
1526
+ const nonBlockingErrorFlow = makeFlow({
1527
+ id: "view-1",
1528
+ type: "view",
1529
+ thing1: {
1530
+ asset: {
1531
+ id: "thing-1",
1532
+ binding: "foo.data.thing1",
1533
+ type: "input",
1534
+ },
1535
+ },
1536
+ validation: [
1537
+ {
1538
+ type: "required",
1539
+ ref: "foo.data.thing1",
1540
+ trigger: "load",
1541
+ severity: "error",
1542
+ blocking: false,
1543
+ },
1544
+ ],
1545
+ });
1546
+ const onceBlockingErrorFlow = makeFlow({
1547
+ id: "view-1",
1548
+ type: "view",
1549
+ thing1: {
1550
+ asset: {
1551
+ id: "thing-1",
1552
+ binding: "foo.data.thing1",
1553
+ type: "input",
1554
+ },
1555
+ },
1556
+ validation: [
1557
+ {
1558
+ type: "required",
1559
+ ref: "foo.data.thing1",
1560
+ trigger: "navigation",
1561
+ severity: "error",
1562
+ blocking: "once",
1563
+ },
1564
+ ],
1565
+ });
1566
+
1567
+ const oneInputWithErrorOnLoadBlockingFalseAndWarningNavigationTriggerFlow =
1568
+ makeFlow({
1569
+ id: "view-1",
1570
+ type: "view",
1571
+ thing1: {
1572
+ asset: {
1573
+ id: "thing-1",
1574
+ binding: "foo.data.thing1",
1575
+ type: "input",
1576
+ },
1577
+ },
1578
+ validation: [
1579
+ {
1580
+ type: "required",
1581
+ ref: "foo.data.thing1",
1582
+ severity: "error",
1583
+ trigger: "load",
1584
+ blocking: "false",
1585
+ },
1586
+ {
1587
+ type: "required",
1588
+ ref: "foo.data.thing1",
1589
+ trigger: "navigation",
1590
+ severity: "warning",
1591
+ },
1592
+ ],
1593
+ });
1594
+
1595
+ const oneInputWithErrorOnLoadBlockingFalseAndWarningChangeTriggerFlow =
1596
+ makeFlow({
1597
+ id: "view-1",
1598
+ type: "view",
1599
+ thing1: {
1600
+ asset: {
1601
+ id: "thing-1",
1602
+ binding: "foo.data.thing1",
1603
+ type: "input",
1604
+ },
1605
+ },
1606
+ validation: [
1607
+ {
1608
+ type: "required",
1609
+ ref: "foo.data.thing1",
1610
+ severity: "error",
1611
+ trigger: "load",
1612
+ blocking: "false",
1613
+ },
1614
+ {
1615
+ type: "required",
1616
+ ref: "foo.data.thing1",
1617
+ trigger: "change",
1618
+ severity: "warning",
1619
+ },
1620
+ ],
1621
+ });
1622
+
1623
+ it("blocks navigation by default", async () => {
1624
+ const player = new Player({ plugins: [new TrackBindingPlugin()] });
1625
+ player.start(errorFlow);
1626
+ const state = player.getState() as InProgressState;
1627
+
1628
+ // Try to navigate, should prevent the navigation and display the error
1629
+ state.controllers.flow.transition("next");
1630
+ expect(state.controllers.flow.current?.currentState?.value.state_type).toBe(
1631
+ "VIEW",
1632
+ );
1633
+ expect(
1634
+ state.controllers.view.currentView?.lastUpdate?.thing1.asset.validation,
1635
+ ).toMatchObject({
1636
+ message: "A value is required",
1637
+ severity: "error",
1638
+ displayTarget: "field",
1639
+ });
1640
+
1641
+ // Try to navigate, should prevent the navigation and keep displaying the error
1642
+ state.controllers.flow.transition("next");
1643
+ // We make it to the next state
1644
+ expect(state.controllers.flow.current?.currentState?.value.state_type).toBe(
1645
+ "VIEW",
1646
+ );
1647
+ });
1648
+ it("blocking once allows navigation on second attempt", async () => {
1649
+ const player = new Player({ plugins: [new TrackBindingPlugin()] });
1650
+ player.start(onceBlockingErrorFlow);
1651
+ const state = player.getState() as InProgressState;
1652
+
1653
+ // Try to navigate, should prevent the navigation and display the error
1654
+ state.controllers.flow.transition("next");
1655
+ expect(state.controllers.flow.current?.currentState?.value.state_type).toBe(
1656
+ "VIEW",
1657
+ );
1658
+ expect(
1659
+ state.controllers.view.currentView?.lastUpdate?.thing1.asset.validation,
1660
+ ).toMatchObject({
1661
+ message: "A value is required",
1662
+ severity: "error",
1663
+ displayTarget: "field",
1664
+ });
1665
+
1666
+ // Navigate _again_ this should dismiss it
1667
+ state.controllers.flow.transition("next");
1668
+ // We make it to the next state
1669
+ expect(state.controllers.flow.current?.currentState?.value.state_type).toBe(
1670
+ "END",
1671
+ );
1672
+ });
1673
+
1674
+ it("error on load blocking false then warning with change trigger on navigation attempt", async () => {
1675
+ const player = new Player({ plugins: [new TrackBindingPlugin()] });
1676
+ player.start(
1677
+ oneInputWithErrorOnLoadBlockingFalseAndWarningChangeTriggerFlow,
1678
+ );
1679
+ const state = player.getState() as InProgressState;
1680
+
1681
+ expect(
1682
+ state.controllers.view.currentView?.lastUpdate?.thing1.asset.validation,
1683
+ ).toMatchObject({
1684
+ message: "A value is required",
1685
+ severity: "error",
1686
+ displayTarget: "field",
1687
+ });
1688
+
1689
+ // Try to navigate, should prevent the navigation and display the warning
1690
+ state.controllers.flow.transition("next");
1691
+ expect(state.controllers.flow.current?.currentState?.value.state_type).toBe(
1692
+ "VIEW",
1693
+ );
1694
+
1695
+ expect(
1696
+ state.controllers.view.currentView?.lastUpdate?.thing1.asset.validation,
1697
+ ).toMatchObject({
1698
+ message: "A value is required",
1699
+ severity: "warning",
1700
+ displayTarget: "field",
1701
+ });
1702
+
1703
+ // Navigate _again_ this should dismiss it
1704
+ state.controllers.flow.transition("next");
1705
+ // We make it to the next state
1706
+
1707
+ expect(state.controllers.flow.current?.currentState?.value.state_type).toBe(
1708
+ "END",
1709
+ );
1710
+ });
1711
+
1712
+ it("error on load blocking false then warning on navigation attempt", async () => {
1713
+ const player = new Player({ plugins: [new TrackBindingPlugin()] });
1714
+ player.start(
1715
+ oneInputWithErrorOnLoadBlockingFalseAndWarningNavigationTriggerFlow,
1716
+ );
1717
+ const state = player.getState() as InProgressState;
1718
+
1719
+ expect(
1720
+ state.controllers.view.currentView?.lastUpdate?.thing1.asset.validation,
1721
+ ).toMatchObject({
1722
+ message: "A value is required",
1723
+ severity: "error",
1724
+ displayTarget: "field",
1725
+ });
1726
+
1727
+ // Try to navigate, should prevent the navigation and display the warning
1728
+ state.controllers.flow.transition("next");
1729
+ expect(state.controllers.flow.current?.currentState?.value.state_type).toBe(
1730
+ "VIEW",
1731
+ );
1732
+
1733
+ expect(
1734
+ state.controllers.view.currentView?.lastUpdate?.thing1.asset.validation,
1735
+ ).toMatchObject({
1736
+ message: "A value is required",
1737
+ severity: "warning",
1738
+ displayTarget: "field",
1739
+ });
1740
+
1741
+ // Navigate _again_ this should dismiss it
1742
+ state.controllers.flow.transition("next");
1743
+ // We make it to the next state
1744
+
1745
+ expect(state.controllers.flow.current?.currentState?.value.state_type).toBe(
1746
+ "END",
1747
+ );
1748
+ });
1749
+
1750
+ it("error on load blocking false then input active then warning on navigation attempt", async () => {
1751
+ const player = new Player({ plugins: [new TrackBindingPlugin()] });
1752
+ player.start(
1753
+ oneInputWithErrorOnLoadBlockingFalseAndWarningNavigationTriggerFlow,
1754
+ );
1755
+ const state = player.getState() as InProgressState;
1756
+
1757
+ expect(
1758
+ state.controllers.view.currentView?.lastUpdate?.thing1.asset.validation,
1759
+ ).toMatchObject({
1760
+ message: "A value is required",
1761
+ severity: "error",
1762
+ displayTarget: "field",
1763
+ });
1764
+
1765
+ // Type something to dismiss the error, should be empty to see the warning
1766
+ state.controllers.data.set([["foo.data.thing1", ""]]);
1767
+
1768
+ // Try to navigate, should prevent the navigation and display the warning
1769
+ state.controllers.flow.transition("next");
1770
+ expect(state.controllers.flow.current?.currentState?.value.state_type).toBe(
1771
+ "VIEW",
1772
+ );
1773
+
1774
+ expect(
1775
+ state.controllers.view.currentView?.lastUpdate?.thing1.asset.validation,
1776
+ ).toMatchObject({
1777
+ message: "A value is required",
1778
+ severity: "warning",
1779
+ displayTarget: "field",
1780
+ });
1781
+
1782
+ // Navigate _again_ this should dismiss it
1783
+ state.controllers.flow.transition("next");
1784
+ // We make it to the next state
1785
+
1786
+ expect(state.controllers.flow.current?.currentState?.value.state_type).toBe(
1787
+ "END",
1788
+ );
1789
+ });
1790
+
1791
+ it("blocking false allows navigation", async () => {
1792
+ const player = new Player({ plugins: [new TrackBindingPlugin()] });
1793
+ player.start(nonBlockingErrorFlow);
1794
+ const state = player.getState() as InProgressState;
1795
+
1796
+ // Try to navigate, should allow navigation because blocking is false
1797
+ state.controllers.flow.transition("next");
1798
+ expect(state.controllers.flow.current?.currentState?.value.state_type).toBe(
1799
+ "END",
1800
+ );
1801
+ });
1802
+ it("blocking false still shows validation", async () => {
1803
+ const player = new Player({ plugins: [new TrackBindingPlugin()] });
1804
+ player.start(nonBlockingErrorFlow);
1805
+ const state = player.getState() as InProgressState;
1806
+
1807
+ expect(
1808
+ state.controllers.view.currentView?.lastUpdate?.thing1.asset.validation,
1809
+ ).toMatchObject({
1810
+ message: "A value is required",
1811
+ severity: "error",
1812
+ displayTarget: "field",
1813
+ });
1814
+
1815
+ // Try to navigate, should allow navigation because blocking is false
1816
+ state.controllers.flow.transition("next");
1817
+ expect(state.controllers.flow.current?.currentState?.value.state_type).toBe(
1818
+ "END",
1819
+ );
1820
+ });
1821
+ });
1822
+
1823
+ test("validations return non-blocking errors", async () => {
1824
+ const flow = makeFlow({
1825
+ id: "view-1",
1826
+ type: "view",
1827
+ blocking: {
1828
+ asset: {
1829
+ id: "thing-1",
1830
+ binding: "foo.blocking",
1831
+ type: "input",
1832
+ },
1833
+ },
1834
+ nonblocking: {
1835
+ asset: {
1836
+ id: "thing-2",
1837
+ binding: "foo.nonblocking",
1838
+ type: "input",
1839
+ },
1840
+ },
1841
+ });
1842
+
1843
+ flow.schema = {
1844
+ ROOT: {
1845
+ foo: {
1846
+ type: "FooType",
1847
+ },
1848
+ },
1849
+ FooType: {
1850
+ blocking: {
1851
+ type: "TestType",
1852
+ validation: [
1853
+ {
1854
+ type: "required",
1855
+ },
1856
+ ],
1857
+ },
1858
+ nonblocking: {
1859
+ type: "TestType",
1860
+ validation: [
1861
+ {
1862
+ type: "required",
1863
+ blocking: false,
1864
+ },
1865
+ ],
1866
+ },
1867
+ },
1868
+ };
1869
+
1870
+ const player = new Player({ plugins: [new TrackBindingPlugin()] });
1871
+ player.start(flow);
1872
+
1873
+ /**
1874
+ *
1875
+ */
1876
+ const getState = () => player.getState() as InProgressState;
1877
+
1878
+ /**
1879
+ *
1880
+ */
1881
+ const getCurrentView = () =>
1882
+ getState().controllers.view.currentView?.lastUpdate;
1883
+
1884
+ // No errors show up initially
1885
+
1886
+ await vitest.waitFor(() => {
1887
+ expect(getState().controllers.view.currentView?.lastUpdate?.id).toBe(
1888
+ "view-1",
1889
+ );
1890
+ });
1891
+
1892
+ expect(getCurrentView()?.blocking.asset.validation).toBeUndefined();
1893
+ expect(getCurrentView()?.nonblocking.asset.validation).toBeUndefined();
1894
+
1895
+ getState().controllers.flow.transition("next");
1896
+ expect(
1897
+ getState().controllers.flow.current?.currentState?.value.state_type,
1898
+ ).toBe("VIEW");
1899
+
1900
+ expect(player.getState().status).toBe("in-progress");
1901
+
1902
+ await vitest.waitFor(() => {
1903
+ expect(getCurrentView()?.blocking.asset.validation).toMatchObject({
1904
+ message: "A value is required",
1905
+ severity: "error",
1906
+ displayTarget: "field",
1907
+ });
1908
+
1909
+ expect(getCurrentView()?.nonblocking.asset.validation).toMatchObject({
1910
+ message: "A value is required",
1911
+ severity: "error",
1912
+ displayTarget: "field",
1913
+ });
1914
+ });
1915
+
1916
+ getState().controllers.data.set([["foo.blocking", "foo"]]);
1917
+
1918
+ await vitest.waitFor(() => {
1919
+ expect(getCurrentView()?.blocking.asset.validation).toBeUndefined();
1920
+
1921
+ expect(getCurrentView()?.nonblocking.asset.validation).toMatchObject({
1922
+ message: "A value is required",
1923
+ severity: "error",
1924
+ displayTarget: "field",
1925
+ });
1926
+ });
1927
+
1928
+ getState().controllers.flow.transition("next");
1929
+
1930
+ await vitest.waitFor(() => {
1931
+ expect(player.getState().status).toBe("completed");
1932
+ });
1933
+ });
1934
+
1935
+ describe("warnings", () => {
1936
+ const warningFlowOnNavigation = makeFlow({
1937
+ id: "view-1",
1938
+ type: "view",
1939
+ thing1: {
1940
+ asset: {
1941
+ id: "thing-1",
1942
+ binding: "foo.data.thing1",
1943
+ type: "input",
1944
+ },
1945
+ },
1946
+ validation: [
1947
+ {
1948
+ type: "required",
1949
+ ref: "foo.data.thing1",
1950
+ trigger: "navigation",
1951
+ severity: "warning",
1952
+ },
1953
+ ],
1954
+ });
1955
+
1956
+ const warningFlowOnLoad = makeFlow({
1957
+ id: "view-1",
1958
+ type: "view",
1959
+ thing1: {
1960
+ asset: {
1961
+ id: "thing-1",
1962
+ binding: "foo.data.thing1",
1963
+ type: "input",
1964
+ },
1965
+ },
1966
+ validation: [
1967
+ {
1968
+ type: "required",
1969
+ ref: "foo.data.thing1",
1970
+ trigger: "load",
1971
+ severity: "warning",
1972
+ },
1973
+ ],
1974
+ });
1975
+
1976
+ const blockingWarningFlow = makeFlow({
1977
+ id: "view-1",
1978
+ type: "view",
1979
+ thing1: {
1980
+ asset: {
1981
+ id: "thing-1",
1982
+ binding: "foo.data.thing1",
1983
+ type: "input",
1984
+ },
1985
+ },
1986
+ validation: [
1987
+ {
1988
+ type: "required",
1989
+ ref: "foo.data.thing1",
1990
+ trigger: "load",
1991
+ blocking: true,
1992
+ severity: "warning",
1993
+ },
1994
+ ],
1995
+ });
1996
+
1997
+ const onceBlockingWarningFlow = makeFlow({
1998
+ id: "view-1",
1999
+ type: "view",
2000
+ thing1: {
2001
+ asset: {
2002
+ id: "thing-1",
2003
+ binding: "foo.data.thing1",
2004
+ type: "input",
2005
+ },
2006
+ },
2007
+ validation: [
2008
+ {
2009
+ type: "required",
2010
+ ref: "foo.data.thing1",
2011
+ trigger: "navigation",
2012
+ blocking: "once",
2013
+ severity: "warning",
2014
+ },
2015
+ ],
2016
+ });
2017
+
2018
+ const onceBlockingWarningFlowWithChangeTrigger = makeFlow({
2019
+ id: "view-1",
2020
+ type: "view",
2021
+ thing1: {
2022
+ asset: {
2023
+ id: "thing-1",
2024
+ binding: "foo.data.thing1",
2025
+ type: "input",
2026
+ },
2027
+ },
2028
+ validation: [
2029
+ {
2030
+ type: "required",
2031
+ ref: "foo.data.thing1",
2032
+ trigger: "change",
2033
+ blocking: "once",
2034
+ severity: "warning",
2035
+ },
2036
+ ],
2037
+ });
2038
+
2039
+ it("shows warnings on load", () => {
2040
+ const player = new Player({ plugins: [new TrackBindingPlugin()] });
2041
+ player.start(warningFlowOnLoad);
2042
+ const state = player.getState() as InProgressState;
2043
+
2044
+ // Validation starts with a warning on load
2045
+ expect(
2046
+ omit(
2047
+ state.controllers.view.currentView?.lastUpdate?.thing1.asset.validation,
2048
+ "dismiss",
2049
+ ),
2050
+ ).toMatchObject({
2051
+ message: "A value is required",
2052
+ severity: "warning",
2053
+ displayTarget: "field",
2054
+ });
2055
+ });
2056
+
2057
+ it("auto-dismiss on double-navigation", async () => {
2058
+ const player = new Player({ plugins: [new TrackBindingPlugin()] });
2059
+ player.start(warningFlowOnNavigation);
2060
+ const state = player.getState() as InProgressState;
2061
+
2062
+ // Try to navigate, should prevent the navigation and keep the warning
2063
+ state.controllers.flow.transition("next");
2064
+ expect(state.controllers.flow.current?.currentState?.value.state_type).toBe(
2065
+ "VIEW",
2066
+ );
2067
+ expect(
2068
+ omit(
2069
+ state.controllers.view.currentView?.lastUpdate?.thing1.asset.validation,
2070
+ "dismiss",
2071
+ ),
2072
+ ).toMatchObject({
2073
+ message: "A value is required",
2074
+ severity: "warning",
2075
+ displayTarget: "field",
2076
+ });
2077
+
2078
+ // Navigate _again_ this should dismiss it
2079
+ state.controllers.flow.transition("next");
2080
+ // We make it to the next state
2081
+ expect(state.controllers.flow.current?.currentState?.value.state_type).toBe(
2082
+ "END",
2083
+ );
2084
+ });
2085
+
2086
+ it("should dismiss triggered navigation warnings on change", async () => {
2087
+ const player = new Player({ plugins: [new TrackBindingPlugin()] });
2088
+ player.start(warningFlowOnNavigation);
2089
+ const state = player.getState() as InProgressState;
2090
+
2091
+ // Try to navigate, should prevent the navigation and keep the warning
2092
+ state.controllers.flow.transition("next");
2093
+ expect(state.controllers.flow.current?.currentState?.value.state_type).toBe(
2094
+ "VIEW",
2095
+ );
2096
+ expect(
2097
+ state.controllers.view.currentView?.lastUpdate?.thing1.asset.validation,
2098
+ ).toMatchObject(
2099
+ expect.objectContaining({
2100
+ message: "A value is required",
2101
+ severity: "warning",
2102
+ displayTarget: "field",
2103
+ }),
2104
+ );
2105
+
2106
+ state.controllers.data.set([["foo.data.thing1", "value"]]);
2107
+
2108
+ await vitest.waitFor(() => {
2109
+ expect(
2110
+ state.controllers.view.currentView?.lastUpdate?.thing1.asset.validation,
2111
+ ).toBeUndefined();
2112
+ });
2113
+ });
2114
+
2115
+ it("blocking warnings dont auto-dismiss on double-navigation", async () => {
2116
+ const player = new Player({ plugins: [new TrackBindingPlugin()] });
2117
+ player.start(blockingWarningFlow);
2118
+ const state = player.getState() as InProgressState;
2119
+
2120
+ // Try to navigate, should prevent the navigation and keep the warning
2121
+ state.controllers.flow.transition("next");
2122
+ expect(state.controllers.flow.current?.currentState?.value.state_type).toBe(
2123
+ "VIEW",
2124
+ );
2125
+ expect(
2126
+ omit(
2127
+ state.controllers.view.currentView?.lastUpdate?.thing1.asset.validation,
2128
+ "dismiss",
2129
+ ),
2130
+ ).toMatchObject({
2131
+ message: "A value is required",
2132
+ severity: "warning",
2133
+ displayTarget: "field",
2134
+ });
2135
+
2136
+ // Navigate _again_ this should dismiss it
2137
+ state.controllers.flow.transition("next");
2138
+ // We make it to the next state
2139
+ expect(state.controllers.flow.current?.currentState?.value.state_type).toBe(
2140
+ "VIEW",
2141
+ );
2142
+ });
2143
+
2144
+ it("warnings do not stop data saving", () => {
2145
+ const flow = makeFlow({
2146
+ asset: {
2147
+ id: "input-2",
2148
+ type: "input",
2149
+ binding: "person.name",
2150
+ label: {
2151
+ asset: {
2152
+ id: "input-2-label",
2153
+ type: "text",
2154
+ value: "Name",
2155
+ },
2156
+ },
2157
+ },
2158
+ });
2159
+
2160
+ flow.schema = {
2161
+ ROOT: {
2162
+ person: {
2163
+ type: "PersonType",
2164
+ },
2165
+ },
2166
+ PersonType: {
2167
+ name: {
2168
+ type: "StringType",
2169
+ validation: [
2170
+ {
2171
+ type: "names",
2172
+ names: ["frodo", "sam"],
2173
+ severity: "warning",
2174
+ },
2175
+ ],
2176
+ },
2177
+ },
2178
+ };
2179
+
2180
+ const player = new Player({
2181
+ plugins: [new TrackBindingPlugin()],
2182
+ });
2183
+ player.start(flow);
2184
+ const state = player.getState() as InProgressState;
2185
+
2186
+ state.controllers.data.set([["person.name", "peter"]], {
2187
+ formatted: true,
2188
+ });
2189
+
2190
+ expect(
2191
+ state.controllers.data.get("person.name", { includeInvalid: false }),
2192
+ ).toBe("peter");
2193
+ });
2194
+
2195
+ it("errors still do stop data saving", () => {
2196
+ const flow = makeFlow({
2197
+ asset: {
2198
+ id: "input-2",
2199
+ type: "input",
2200
+ binding: "person.name",
2201
+ label: {
2202
+ asset: {
2203
+ id: "input-2-label",
2204
+ type: "text",
2205
+ value: "Name",
2206
+ },
2207
+ },
2208
+ },
2209
+ });
2210
+
2211
+ flow.schema = {
2212
+ ROOT: {
2213
+ person: {
2214
+ type: "PersonType",
2215
+ },
2216
+ },
2217
+ PersonType: {
2218
+ name: {
2219
+ type: "StringType",
2220
+ validation: [
2221
+ {
2222
+ type: "names",
2223
+ names: ["frodo", "sam"],
2224
+ },
2225
+ ],
2226
+ },
2227
+ },
2228
+ };
2229
+
2230
+ const player = new Player({
2231
+ plugins: [new TrackBindingPlugin()],
2232
+ });
2233
+ player.start(flow);
2234
+ const state = player.getState() as InProgressState;
2235
+
2236
+ state.controllers.data.set([["person.name", "peter"]], {
2237
+ formatted: true,
2238
+ });
2239
+
2240
+ expect(
2241
+ state.controllers.data.get("person.name", { includeInvalid: false }),
2242
+ ).toBe(undefined);
2243
+ });
2244
+
2245
+ it("once blocking warnings auto-dismiss on double-navigation", async () => {
2246
+ const player = new Player({ plugins: [new TrackBindingPlugin()] });
2247
+ player.start(onceBlockingWarningFlow);
2248
+ const state = player.getState() as InProgressState;
2249
+
2250
+ // Try to navigate, should prevent the navigation and keep the warning
2251
+ state.controllers.flow.transition("next");
2252
+ expect(state.controllers.flow.current?.currentState?.value.state_type).toBe(
2253
+ "VIEW",
2254
+ );
2255
+ expect(
2256
+ omit(
2257
+ state.controllers.view.currentView?.lastUpdate?.thing1.asset.validation,
2258
+ "dismiss",
2259
+ ),
2260
+ ).toMatchObject({
2261
+ message: "A value is required",
2262
+ severity: "warning",
2263
+ displayTarget: "field",
2264
+ });
2265
+
2266
+ // Navigate _again_ this should dismiss it
2267
+ state.controllers.flow.transition("next");
2268
+ // We make it to the next state
2269
+
2270
+ expect(state.controllers.flow.current?.currentState?.value.state_type).toBe(
2271
+ "END",
2272
+ );
2273
+ });
2274
+
2275
+ it("once blocking warnings with change trigger auto-dismiss on double-navigation", async () => {
2276
+ const player = new Player({ plugins: [new TrackBindingPlugin()] });
2277
+ player.start(onceBlockingWarningFlowWithChangeTrigger);
2278
+ const state = player.getState() as InProgressState;
2279
+
2280
+ // Validation starts with no warnings on load
2281
+ expect(
2282
+ state.controllers.view.currentView?.lastUpdate?.thing1.asset.validation,
2283
+ ).toBeUndefined();
2284
+
2285
+ // Try to navigate, should prevent the navigation and show the warning
2286
+ state.controllers.flow.transition("next");
2287
+ expect(state.controllers.flow.current?.currentState?.value.state_type).toBe(
2288
+ "VIEW",
2289
+ );
2290
+ expect(
2291
+ omit(
2292
+ state.controllers.view.currentView?.lastUpdate?.thing1.asset.validation,
2293
+ "dismiss",
2294
+ ),
2295
+ ).toMatchObject({
2296
+ message: "A value is required",
2297
+ severity: "warning",
2298
+ displayTarget: "field",
2299
+ });
2300
+
2301
+ // Navigate _again_ this should dismiss it
2302
+ state.controllers.flow.transition("next");
2303
+ // We make it to the next state
2304
+
2305
+ await vitest.waitFor(() => {
2306
+ expect(
2307
+ state.controllers.flow.current?.currentState?.value.state_type,
2308
+ ).toBe("END");
2309
+ });
2310
+ });
2311
+
2312
+ it("triggers re-render on dismiss call", () => {
2313
+ const player = new Player({ plugins: [new TrackBindingPlugin()] });
2314
+ player.start(warningFlowOnLoad);
2315
+ const state = player.getState() as InProgressState;
2316
+
2317
+ // Validation starts with a warning on load
2318
+ expect(
2319
+ omit(
2320
+ state.controllers.view.currentView?.lastUpdate?.thing1.asset.validation,
2321
+ "dismiss",
2322
+ ),
2323
+ ).toMatchObject({
2324
+ message: "A value is required",
2325
+ severity: "warning",
2326
+ displayTarget: "field",
2327
+ });
2328
+
2329
+ state.controllers.view.currentView?.lastUpdate?.thing1.asset.validation.dismiss();
2330
+ expect(
2331
+ state.controllers.view.currentView?.lastUpdate?.thing1.asset.validation,
2332
+ ).toBe(undefined);
2333
+
2334
+ // Should be able to navigate w/o issues
2335
+ state.controllers.flow.transition("next");
2336
+ // We make it to the next state
2337
+ expect(state.controllers.flow.current?.currentState?.value.state_type).toBe(
2338
+ "END",
2339
+ );
2340
+ });
2341
+ });
2342
+
2343
+ describe("validation within arrays", () => {
2344
+ const arrayFlow = makeFlow({
2345
+ id: "view-1",
2346
+ type: "view",
2347
+ thing1: {
2348
+ asset: {
2349
+ id: "thing-1",
2350
+ binding: "thing.1.data.3.name",
2351
+ type: "input",
2352
+ },
2353
+ },
2354
+ thing2: {
2355
+ asset: {
2356
+ id: "thing-2",
2357
+ binding: "thing.2.data.0.name",
2358
+ type: "input",
2359
+ },
2360
+ },
2361
+ });
2362
+
2363
+ arrayFlow.schema = {
2364
+ ROOT: {
2365
+ thing: {
2366
+ type: "ThingType",
2367
+ isArray: true,
2368
+ },
2369
+ },
2370
+ ThingType: {
2371
+ data: {
2372
+ type: "DataType",
2373
+ isArray: true,
2374
+ },
2375
+ },
2376
+ DataType: {
2377
+ name: {
2378
+ type: "StringType",
2379
+ validation: [
2380
+ {
2381
+ type: "required",
2382
+ },
2383
+ ],
2384
+ },
2385
+ },
2386
+ };
2387
+
2388
+ it("validates things correctly within an array", async () => {
2389
+ const player = new Player({ plugins: [new TrackBindingPlugin()] });
2390
+ player.start(arrayFlow);
2391
+ const state = player.getState() as InProgressState;
2392
+
2393
+ // Nothing initially
2394
+ expect(
2395
+ state.controllers.view.currentView?.lastUpdate?.thing1.asset.validation,
2396
+ ).toBe(undefined);
2397
+ expect(
2398
+ state.controllers.view.currentView?.lastUpdate?.thing2.asset.validation,
2399
+ ).toBe(undefined);
2400
+
2401
+ // Error if set to an falsy value
2402
+ state.controllers.data.set([["thing.1.data.3.name", ""]]);
2403
+ await vitest.waitFor(() => {
2404
+ expect(
2405
+ state.controllers.view.currentView?.lastUpdate?.thing1.asset.validation,
2406
+ ).toMatchObject({
2407
+ severity: "error",
2408
+ message: "A value is required",
2409
+ displayTarget: "field",
2410
+ });
2411
+ expect(
2412
+ state.controllers.view.currentView?.lastUpdate?.thing2.asset.validation,
2413
+ ).toBe(undefined);
2414
+ });
2415
+
2416
+ // Other one gets error if i try to navigate
2417
+ state.controllers.data.set([["thing.1.data.3.name", "adam"]]);
2418
+ state.controllers.flow.transition("anything");
2419
+ await vitest.waitFor(() => {
2420
+ expect(
2421
+ state.controllers.view.currentView?.lastUpdate?.thing1.asset.validation,
2422
+ ).toBe(undefined);
2423
+ expect(
2424
+ state.controllers.view.currentView?.lastUpdate?.thing2.asset.validation,
2425
+ ).toMatchObject({
2426
+ severity: "error",
2427
+ message: "A value is required",
2428
+ displayTarget: "field",
2429
+ });
2430
+ });
2431
+ });
2432
+ });
2433
+
2434
+ describe("models can get valid or invalid data", () => {
2435
+ const flow = makeFlow({
2436
+ asset: {
2437
+ id: "input-2",
2438
+ type: "input",
2439
+ binding: "person.name",
2440
+ label: {
2441
+ asset: {
2442
+ id: "input-2-label",
2443
+ type: "text",
2444
+ value: "Name",
2445
+ },
2446
+ },
2447
+ },
2448
+ });
2449
+
2450
+ flow.schema = {
2451
+ ROOT: {
2452
+ person: {
2453
+ type: "PersonType",
2454
+ },
2455
+ },
2456
+ PersonType: {
2457
+ name: {
2458
+ type: "StringType",
2459
+ validation: [
2460
+ {
2461
+ type: "names",
2462
+ names: ["frodo", "sam"],
2463
+ },
2464
+ ],
2465
+ },
2466
+ },
2467
+ };
2468
+
2469
+ it("gets both", () => {
2470
+ const player = new Player({ plugins: [new TrackBindingPlugin()] });
2471
+ player.start(flow);
2472
+ const state = player.getState() as InProgressState;
2473
+
2474
+ state.controllers.data.set([["person.name", "adam"]]);
2475
+
2476
+ expect(state.controllers.data.get("person.name")).toBe(undefined);
2477
+ expect(
2478
+ state.controllers.data.get("person.name", { includeInvalid: true }),
2479
+ ).toBe("adam");
2480
+
2481
+ state.controllers.data.set([["person.name", "sam"]]);
2482
+ expect(state.controllers.data.get("person.name")).toBe("sam");
2483
+ expect(
2484
+ state.controllers.data.get("person.name", { includeInvalid: true }),
2485
+ ).toBe("sam");
2486
+ });
2487
+ });
2488
+
2489
+ test("validations can run against formatted or deformatted values", async () => {
2490
+ const flow = makeFlow({
2491
+ asset: {
2492
+ id: "input-2",
2493
+ type: "input",
2494
+ binding: "person.name",
2495
+ label: {
2496
+ asset: {
2497
+ id: "input-2-label",
2498
+ type: "text",
2499
+ value: "Name",
2500
+ },
2501
+ },
2502
+ },
2503
+ });
2504
+
2505
+ flow.schema = {
2506
+ ROOT: {
2507
+ person: {
2508
+ type: "PersonType",
2509
+ },
2510
+ },
2511
+ PersonType: {
2512
+ name: {
2513
+ type: "NumberType",
2514
+ format: {
2515
+ type: "indexOf",
2516
+ options: ["frodo", "sam"],
2517
+ },
2518
+ validation: [
2519
+ {
2520
+ type: "names",
2521
+ dataTarget: "formatted",
2522
+ names: ["frodo", "sam"],
2523
+ },
2524
+ ],
2525
+ },
2526
+ },
2527
+ };
2528
+
2529
+ const player = new Player({ plugins: [new TrackBindingPlugin()] });
2530
+
2531
+ player.start(flow);
2532
+ const state = player.getState() as InProgressState;
2533
+
2534
+ state.controllers.data.set([["person.name", 0]]);
2535
+ expect(state.controllers.data.get("person.name")).toBe(0);
2536
+ expect(
2537
+ state.controllers.view.currentView?.lastUpdate?.validation,
2538
+ ).toBeUndefined();
2539
+
2540
+ state.controllers.data.set([["person.name", "adam"]], { formatted: true });
2541
+ await vitest.waitFor(() => {
2542
+ expect(
2543
+ state.controllers.view.currentView?.lastUpdate?.validation.message,
2544
+ ).toBe("Names just be in: frodo,sam");
2545
+ });
2546
+
2547
+ state.controllers.data.set([["person.name", "sam"]], { formatted: true });
2548
+ await vitest.waitFor(() => {
2549
+ expect(
2550
+ state.controllers.view.currentView?.lastUpdate?.validation,
2551
+ ).toBeUndefined();
2552
+ });
2553
+ });
2554
+
2555
+ test("tracking a binding commits the default value", () => {
2556
+ const flow = makeFlow({
2557
+ asset: {
2558
+ id: "input-2",
2559
+ type: "input",
2560
+ binding: "person.name",
2561
+ label: {
2562
+ asset: {
2563
+ id: "input-2-label",
2564
+ type: "text",
2565
+ value: "{{other.name}}",
2566
+ },
2567
+ },
2568
+ },
2569
+ });
2570
+
2571
+ flow.schema = {
2572
+ ROOT: {
2573
+ person: {
2574
+ type: "PersonType",
2575
+ },
2576
+ other: {
2577
+ type: "PersonType",
2578
+ },
2579
+ },
2580
+ PersonType: {
2581
+ name: {
2582
+ type: "StringType",
2583
+ default: "Adam",
2584
+ },
2585
+ },
2586
+ };
2587
+
2588
+ const player = new Player({ plugins: [new TrackBindingPlugin()] });
2589
+
2590
+ player.start(flow);
2591
+ const state = player.getState() as InProgressState;
2592
+ expect(state.controllers.data.get("person.name")).toBe("Adam");
2593
+ expect(state.controllers.data.get("other.name")).toBe("Adam");
2594
+ expect(
2595
+ state.controllers.view.currentView?.lastUpdate?.label.asset.value,
2596
+ ).toBe("Adam");
2597
+ expect(state.controllers.data.get("")).toStrictEqual({
2598
+ person: { name: "Adam" },
2599
+ });
2600
+ });
2601
+
2602
+ test("does not validate on expressions outside of view", async () => {
2603
+ const flowWithExp: Flow = {
2604
+ id: "flow-with-exp",
2605
+ views: [
2606
+ {
2607
+ id: "view-1",
2608
+ type: "view",
2609
+ fields: {
2610
+ asset: {
2611
+ id: "input",
2612
+ type: "input",
2613
+ binding: "person.name",
2614
+ },
2615
+ },
2616
+ },
2617
+ ],
2618
+ data: { person: { name: "frodo" } },
2619
+ schema: {
2620
+ ROOT: {
2621
+ person: {
2622
+ type: "PersonType",
2623
+ },
2624
+ },
2625
+ PersonType: {
2626
+ name: {
2627
+ type: "String",
2628
+ validation: [
2629
+ {
2630
+ type: "names",
2631
+ dataTarget: "formatted",
2632
+ names: ["frodo", "sam"],
2633
+ },
2634
+ ],
2635
+ },
2636
+ },
2637
+ },
2638
+ navigation: {
2639
+ BEGIN: "FLOW_1",
2640
+ FLOW_1: {
2641
+ startState: "VIEW_1",
2642
+ VIEW_1: {
2643
+ state_type: "VIEW",
2644
+ ref: "view-1",
2645
+ transitions: {
2646
+ "*": "ACTION_1",
2647
+ },
2648
+ },
2649
+ ACTION_1: {
2650
+ state_type: "ACTION",
2651
+ exp: '{{person.name}} = "invalid"',
2652
+ transitions: {
2653
+ "*": "END_1",
2654
+ },
2655
+ },
2656
+ END_1: {
2657
+ state_type: "END",
2658
+ outcome: "done",
2659
+ },
2660
+ },
2661
+ },
2662
+ };
2663
+
2664
+ const player = new Player({ plugins: [new TrackBindingPlugin()] });
2665
+ const outcome = player.start(flowWithExp);
2666
+
2667
+ const state = player.getState() as InProgressState;
2668
+ state.controllers.flow.transition("Next");
2669
+
2670
+ const response = await outcome;
2671
+ expect(response.data).toStrictEqual({ person: { name: "invalid" } });
2672
+ });
2673
+
2674
+ describe("Validation applicability", () => {
2675
+ let player: Player;
2676
+
2677
+ beforeEach(() => {
2678
+ player = new Player({
2679
+ plugins: [
2680
+ new TrackBindingPlugin(),
2681
+ new RequiredIfValidationProviderPlugin(),
2682
+ ],
2683
+ });
2684
+
2685
+ player.start(flowWithApplicability);
2686
+ });
2687
+
2688
+ describe("weak validation", () => {
2689
+ it("weak binding updates should be allowed despite strong validation errors", async () => {
2690
+ const state = player.getState() as InProgressState;
2691
+
2692
+ state.controllers.data.set([["independentBinding", true]]);
2693
+ await vitest.waitFor(() => {
2694
+ expect(state.controllers.data.get("independentBinding")).toStrictEqual(
2695
+ true,
2696
+ );
2697
+ expect(
2698
+ state.controllers.view.currentView?.lastUpdate?.thing1.asset
2699
+ .validation,
2700
+ ).toMatchObject({
2701
+ severity: "error",
2702
+ message: `required based on independent value`,
2703
+ });
2704
+ });
2705
+
2706
+ state.controllers.data.set([["dependentBinding", "foo"]]);
2707
+ await vitest.waitFor(() => {
2708
+ expect(state.controllers.data.get("dependentBinding")).toStrictEqual(
2709
+ "foo",
2710
+ );
2711
+ expect(
2712
+ state.controllers.view.currentView?.lastUpdate?.thing1.asset
2713
+ .validation,
2714
+ ).toBeUndefined();
2715
+ });
2716
+
2717
+ state.controllers.data.set([["dependentBinding", undefined]]);
2718
+ await vitest.waitFor(() => {
2719
+ expect(
2720
+ state.controllers.view.currentView?.lastUpdate?.thing1.asset
2721
+ .validation,
2722
+ ).toMatchObject({
2723
+ severity: "error",
2724
+ message: `required based on independent value`,
2725
+ });
2726
+ });
2727
+
2728
+ state.controllers.data.set([["independentBinding", false]]);
2729
+ await vitest.waitFor(() => {
2730
+ expect(state.controllers.data.get("independentBinding")).toStrictEqual(
2731
+ false,
2732
+ );
2733
+ expect(
2734
+ state.controllers.view.currentView?.lastUpdate?.thing1.asset
2735
+ .validation,
2736
+ ).toMatchObject({
2737
+ severity: "error",
2738
+ message: `required based on independent value`,
2739
+ });
2740
+ });
2741
+ });
2742
+ });
2743
+ });
2744
+
2745
+ test("updating a binding only updates its data and not other bindings due to weak binding connections", async () => {
2746
+ const flow = makeFlow({
2747
+ id: "view-1",
2748
+ type: "view",
2749
+ thing1: {
2750
+ asset: {
2751
+ id: "thing-1",
2752
+ binding: "input.text",
2753
+ },
2754
+ },
2755
+ thing2: {
2756
+ asset: {
2757
+ id: "thing-2",
2758
+ binding: "input.check",
2759
+ },
2760
+ },
2761
+ validation: [
2762
+ {
2763
+ type: "requiredIf",
2764
+ ref: "input.text",
2765
+ param: "input.check",
2766
+ },
2767
+ ],
2768
+ });
2769
+
2770
+ flow.data = {
2771
+ someOtherParam: "notFoo",
2772
+ };
2773
+
2774
+ flow.schema = {
2775
+ ROOT: {
2776
+ input: {
2777
+ type: "InputType",
2778
+ },
2779
+ },
2780
+ InputType: {
2781
+ text: {
2782
+ type: "DateType",
2783
+ validation: [
2784
+ {
2785
+ type: "paramIsFoo",
2786
+ param: "someOtherParam",
2787
+ },
2788
+ ],
2789
+ },
2790
+ check: {
2791
+ type: "BooleanType",
2792
+ validation: [
2793
+ {
2794
+ type: "required",
2795
+ },
2796
+ ],
2797
+ },
2798
+ },
2799
+ };
2800
+
2801
+ const basicValidationPlugin = {
2802
+ name: "basic-validation",
2803
+ apply: (player: Player) => {
2804
+ player.hooks.schema.tap("basic-validation", (schema) => {
2805
+ schema.addDataTypes([
2806
+ {
2807
+ type: "DateType",
2808
+ validation: [{ type: "date" }],
2809
+ },
2810
+ {
2811
+ type: "BooleanType",
2812
+ validation: [{ type: "boolean" }],
2813
+ },
2814
+ ]);
2815
+ });
2816
+
2817
+ player.hooks.validationController.tap("basic-validation", (vc) => {
2818
+ vc.hooks.createValidatorRegistry.tap("basic-validation", (registry) => {
2819
+ registry.register("date", (ctx, value) => {
2820
+ if (value === undefined) {
2821
+ return;
2822
+ }
2823
+
2824
+ return value.match(/^\d{4}-\d{2}-\d{2}$/)
2825
+ ? undefined
2826
+ : { message: "Not a date" };
2827
+ });
2828
+ registry.register("boolean", (ctx, value) => {
2829
+ if (value === undefined || value === true || value === false) {
2830
+ return;
2831
+ }
2832
+
2833
+ return {
2834
+ message: "Not a boolean",
2835
+ };
2836
+ });
2837
+
2838
+ registry.register("required", (ctx, value) => {
2839
+ if (value === undefined) {
2840
+ return {
2841
+ message: "Required",
2842
+ };
2843
+ }
2844
+ });
2845
+
2846
+ registry.register<any>("requiredIf", (ctx, value, { param }) => {
2847
+ const paramValue = ctx.model.get(param);
2848
+ if (paramValue === undefined) {
2849
+ return;
2850
+ }
2851
+
2852
+ if (value === undefined) {
2853
+ return {
2854
+ message: "Required",
2855
+ };
2856
+ }
2857
+ });
2858
+
2859
+ registry.register<any>("paramIsFoo", (ctx, value, { param }) => {
2860
+ const paramValue = ctx.model.get(param);
2861
+ if (paramValue === "foo") {
2862
+ return;
2863
+ }
2864
+
2865
+ if (value === undefined) {
2866
+ return {
2867
+ message: "Must be foo",
2868
+ };
2869
+ }
2870
+ });
2871
+ });
2872
+ });
2873
+ },
2874
+ };
2875
+
2876
+ const player = new Player({
2877
+ plugins: [new TrackBindingPlugin(), basicValidationPlugin],
2878
+ });
2879
+ player.start(flow);
2880
+ const state = player.getState() as InProgressState;
2881
+
2882
+ state.controllers.flow.transition("next");
2883
+ await vitest.waitFor(() => {
2884
+ state.controllers.data.set([["input.text", ""]]);
2885
+ });
2886
+
2887
+ await vitest.waitFor(() => {
2888
+ state.controllers.data.set([["input.check", true]]);
2889
+ });
2890
+
2891
+ await vitest.waitFor(() => {
2892
+ const finalState = player.getState() as InProgressState;
2893
+ const otherParam = finalState.controllers.data.get("someOtherParam");
2894
+ expect(otherParam).toBe("notFoo");
2895
+ });
2896
+ });
2897
+
2898
+ describe("Validations with custom field messages", () => {
2899
+ it("can evaluate expressions in message", async () => {
2900
+ const flow = makeFlow({
2901
+ id: "view-1",
2902
+ type: "view",
2903
+ thing1: {
2904
+ asset: {
2905
+ id: "thing-1",
2906
+ binding: "foo.data.thing1",
2907
+ type: "input",
2908
+ },
2909
+ },
2910
+ validation: [
2911
+ {
2912
+ type: "expression",
2913
+ ref: "foo.data.thing1",
2914
+ message: "The entered value {{foo.data.thing1}} is greater than 100",
2915
+ exp: "{{foo.data.thing1}} < 100",
2916
+ },
2917
+ ],
2918
+ });
2919
+ const player = new Player({
2920
+ plugins: [new TrackBindingPlugin()],
2921
+ });
2922
+ player.start(flow);
2923
+ const state = player.getState() as InProgressState;
2924
+
2925
+ state.controllers.data.set([["foo.data.thing1", 200]]);
2926
+ state.controllers.flow.transition("next");
2927
+ expect(
2928
+ state.controllers.view.currentView?.lastUpdate?.thing1.asset.validation,
2929
+ ).toMatchObject({
2930
+ severity: "error",
2931
+ message: "The entered value 200 is greater than 100",
2932
+ displayTarget: "field",
2933
+ });
2934
+ });
2935
+
2936
+ it("can templatize messages", async () => {
2937
+ const errFlow = makeFlow({
2938
+ id: "view-1",
2939
+ type: "view",
2940
+ thing1: {
2941
+ asset: {
2942
+ id: "thing-1",
2943
+ binding: "foo.data.thing1",
2944
+ type: "integer",
2945
+ },
2946
+ },
2947
+ validation: [
2948
+ {
2949
+ type: "integer",
2950
+ ref: "foo.data.thing1",
2951
+ message:
2952
+ "foo.data.thing1 is a number. You have provided a value of %type, which is correct. But floored value, %flooredValue is not equal to entered value, %value",
2953
+ trigger: "load",
2954
+ severity: "error",
2955
+ },
2956
+ ],
2957
+ });
2958
+
2959
+ const player = new Player({ plugins: [new TrackBindingPlugin()] });
2960
+ player.start(errFlow);
2961
+ const state = player.getState() as InProgressState;
2962
+
2963
+ state.controllers.data.set([["foo.data.thing1", 200.567]]);
2964
+
2965
+ await vitest.waitFor(() => {
2966
+ expect(
2967
+ state.controllers.view.currentView?.lastUpdate?.thing1.asset.validation,
2968
+ ).toMatchObject({
2969
+ message:
2970
+ "foo.data.thing1 is a number. You have provided a value of number, which is correct. But floored value, 200 is not equal to entered value, 200.567",
2971
+ severity: "error",
2972
+ displayTarget: "field",
2973
+ });
2974
+ });
2975
+ });
2976
+ });
2977
+
2978
+ describe("Validations with multiple inputs", () => {
2979
+ const complexValidation = makeFlow({
2980
+ id: "view-1",
2981
+ type: "view",
2982
+ thing1: {
2983
+ asset: {
2984
+ id: "thing-1",
2985
+ binding: "foo.a",
2986
+ type: "input",
2987
+ },
2988
+ },
2989
+ thing2: {
2990
+ asset: {
2991
+ id: "thing-2",
2992
+ binding: "foo.b",
2993
+ type: "input",
2994
+ },
2995
+ },
2996
+ validation: [
2997
+ {
2998
+ type: "expression",
2999
+ ref: "foo.a",
3000
+ message: "Both need to equal 100",
3001
+ exp: 'sumValues(["foo.a", "foo.b"]) == 100',
3002
+ severity: "error",
3003
+ trigger: "load",
3004
+ },
3005
+ ],
3006
+ });
3007
+
3008
+ let player: Player;
3009
+ let validationController: ValidationController;
3010
+ let schema: SchemaController;
3011
+ let parser: BindingParser;
3012
+
3013
+ beforeEach(() => {
3014
+ player = new Player({
3015
+ plugins: [new TrackBindingPlugin(), new TestExpressionPlugin()],
3016
+ });
3017
+ player.hooks.validationController.tap("test", (vc) => {
3018
+ validationController = vc;
3019
+ });
3020
+ player.hooks.schema.tap("test", (s) => {
3021
+ schema = s;
3022
+ });
3023
+ player.hooks.bindingParser.tap("test", (p) => {
3024
+ parser = p;
3025
+ });
3026
+
3027
+ player.start(flowWithThings);
3028
+ });
3029
+
3030
+ it("Throws errors when a weak referenced field is changed", async () => {
3031
+ complexValidation.data = {
3032
+ foo: {
3033
+ a: 90,
3034
+ b: 10,
3035
+ },
3036
+ };
3037
+
3038
+ player.start(complexValidation);
3039
+ const state = player.getState() as InProgressState;
3040
+
3041
+ expect(
3042
+ state.controllers.view.currentView?.lastUpdate?.thing1.asset.validation,
3043
+ ).toBeUndefined();
3044
+
3045
+ state.controllers.data.set([["foo.b", 70]]);
3046
+ await vitest.waitFor(() => {
3047
+ expect(
3048
+ state.controllers.view.currentView?.lastUpdate?.thing1.asset.validation,
3049
+ ).toMatchObject({
3050
+ severity: "error",
3051
+ message: "Both need to equal 100",
3052
+ });
3053
+
3054
+ expect(state.controllers.data.get("")).toMatchObject({
3055
+ foo: {
3056
+ a: 90,
3057
+ b: 70,
3058
+ },
3059
+ });
3060
+ });
3061
+ });
3062
+
3063
+ it("Clears errors when a weak referenced field is changed", async () => {
3064
+ complexValidation.data = {
3065
+ foo: {
3066
+ a: 90,
3067
+ b: 10,
3068
+ },
3069
+ };
3070
+
3071
+ player.start(complexValidation);
3072
+ const state = player.getState() as InProgressState;
3073
+
3074
+ expect(
3075
+ state.controllers.view.currentView?.lastUpdate?.thing1.asset.validation,
3076
+ ).toBeUndefined();
3077
+
3078
+ state.controllers.data.set([["foo.a", 15]]);
3079
+ await vitest.waitFor(() => {
3080
+ expect(
3081
+ state.controllers.view.currentView?.lastUpdate?.thing1.asset.validation,
3082
+ ).toMatchObject({
3083
+ severity: "error",
3084
+ message: "Both need to equal 100",
3085
+ });
3086
+
3087
+ expect(
3088
+ state.controllers.data.get("", { includeInvalid: false }),
3089
+ ).toMatchObject({
3090
+ foo: {
3091
+ a: 90,
3092
+ b: 10,
3093
+ },
3094
+ });
3095
+
3096
+ expect(
3097
+ state.controllers.data.get("", { includeInvalid: true }),
3098
+ ).toMatchObject({
3099
+ foo: {
3100
+ a: 15,
3101
+ b: 10,
3102
+ },
3103
+ });
3104
+ });
3105
+
3106
+ state.controllers.data.set([["foo.b", 85]]);
3107
+ await vitest.waitFor(() => {
3108
+ expect(
3109
+ state.controllers.view.currentView?.lastUpdate?.thing1.asset.validation,
3110
+ ).toBeUndefined();
3111
+
3112
+ expect(
3113
+ state.controllers.data.get("", { includeInvalid: false }),
3114
+ ).toMatchObject({
3115
+ foo: {
3116
+ a: 15,
3117
+ b: 85,
3118
+ },
3119
+ });
3120
+
3121
+ expect(
3122
+ state.controllers.data.get("", { includeInvalid: true }),
3123
+ ).toMatchObject({
3124
+ foo: {
3125
+ a: 15,
3126
+ b: 85,
3127
+ },
3128
+ });
3129
+ });
3130
+ });
3131
+ });
3132
+
3133
+ describe("weak binding edge cases", () => {
3134
+ test("requiredIf", async () => {
3135
+ const flow = makeFlow({
3136
+ id: "view-1",
3137
+ type: "view",
3138
+ thing1: {
3139
+ asset: {
3140
+ id: "thing-1",
3141
+ binding: "input.text",
3142
+ },
3143
+ },
3144
+ thing2: {
3145
+ asset: {
3146
+ id: "thing-2",
3147
+ binding: "input.check",
3148
+ },
3149
+ },
3150
+ validation: [
3151
+ {
3152
+ type: "requiredIf",
3153
+ ref: "input.text",
3154
+ param: "input.check",
3155
+ },
3156
+ ],
3157
+ });
3158
+
3159
+ flow.schema = {
3160
+ ROOT: {
3161
+ input: {
3162
+ type: "InputType",
3163
+ },
3164
+ },
3165
+ InputType: {
3166
+ text: {
3167
+ type: "DateType",
3168
+ },
3169
+ check: {
3170
+ type: "BooleanType",
3171
+ validation: [
3172
+ {
3173
+ type: "required",
3174
+ },
3175
+ ],
3176
+ },
3177
+ },
3178
+ };
3179
+
3180
+ const basicValidationPlugin = {
3181
+ name: "basic-validation",
3182
+ apply: (player: Player) => {
3183
+ player.hooks.schema.tap("basic-validation", (schema) => {
3184
+ schema.addDataTypes([
3185
+ {
3186
+ type: "DateType",
3187
+ validation: [{ type: "date" }],
3188
+ },
3189
+ {
3190
+ type: "BooleanType",
3191
+ validation: [{ type: "boolean" }],
3192
+ },
3193
+ ]);
3194
+ });
3195
+
3196
+ player.hooks.validationController.tap("basic-validation", (vc) => {
3197
+ vc.hooks.createValidatorRegistry.tap(
3198
+ "basic-validation",
3199
+ (registry) => {
3200
+ registry.register("date", (ctx, value) => {
3201
+ if (value === undefined) {
3202
+ return;
3203
+ }
3204
+
3205
+ return value.match(/^\d{4}-\d{2}-\d{2}$/)
3206
+ ? undefined
3207
+ : { message: "Not a date" };
3208
+ });
3209
+ registry.register("boolean", (ctx, value) => {
3210
+ if (value === undefined || value === true || value === false) {
3211
+ return;
3212
+ }
3213
+
3214
+ return {
3215
+ message: "Not a boolean",
3216
+ };
3217
+ });
3218
+
3219
+ registry.register("required", (ctx, value) => {
3220
+ if (value === undefined) {
3221
+ return {
3222
+ message: "Required",
3223
+ };
3224
+ }
3225
+ });
3226
+
3227
+ registry.register<any>("requiredIf", (ctx, value, { param }) => {
3228
+ const paramValue = ctx.model.get(param);
3229
+ if (paramValue === undefined) {
3230
+ return;
3231
+ }
3232
+
3233
+ if (value === undefined) {
3234
+ return {
3235
+ message: "Required",
3236
+ };
3237
+ }
3238
+ });
3239
+ },
3240
+ );
3241
+ });
3242
+ },
3243
+ };
3244
+
3245
+ const player = new Player({
3246
+ plugins: [new TrackBindingPlugin(), basicValidationPlugin],
3247
+ });
3248
+ player.start(flow);
3249
+ const state = player.getState() as InProgressState;
3250
+
3251
+ state.controllers.flow.transition("next");
3252
+ await vitest.waitFor(() => {
3253
+ state.controllers.data.set([["input.text", "1999-12-31"]]);
3254
+ });
3255
+ await vitest.waitFor(() => {
3256
+ state.controllers.data.set([["input.check", true]]);
3257
+ });
3258
+ await vitest.waitFor(() => {
3259
+ state.controllers.flow.transition("next");
3260
+ });
3261
+ await vitest.waitFor(() => {
3262
+ expect(player.getState().status).toBe("completed");
3263
+ });
3264
+ });
3265
+ });
3266
+
3267
+ describe("Validation Providers", () => {
3268
+ it("uses a locally defined handler", async () => {
3269
+ let shouldError = true;
3270
+
3271
+ const player = new Player({
3272
+ plugins: [
3273
+ new TrackBindingPlugin(),
3274
+
3275
+ {
3276
+ name: "basic-validation",
3277
+ apply: (p: Player) => {
3278
+ p.hooks.validationController.tap("basic-validation", (vc) => {
3279
+ vc.hooks.resolveValidationProviders.tap(
3280
+ "basic-validation",
3281
+ (providers) => {
3282
+ return [
3283
+ ...providers,
3284
+ {
3285
+ source: "local-test",
3286
+ provider: {
3287
+ getValidationsForBinding(binding) {
3288
+ if (binding.asString() === "data.thing1") {
3289
+ return [
3290
+ {
3291
+ type: "custom",
3292
+ trigger: "load",
3293
+ severity: "error",
3294
+ handler: (ctx, value) => {
3295
+ if (shouldError) {
3296
+ return {
3297
+ message: "Local Error",
3298
+ };
3299
+ }
3300
+ },
3301
+ },
3302
+ ];
3303
+ }
3304
+ },
3305
+ },
3306
+ },
3307
+ ];
3308
+ },
3309
+ );
3310
+ });
3311
+ },
3312
+ },
3313
+ ],
3314
+ });
3315
+
3316
+ player.start(simpleFlow);
3317
+
3318
+ /**
3319
+ *
3320
+ */
3321
+ const getControllers = () => {
3322
+ const state = player.getState() as InProgressState;
3323
+ return state.controllers;
3324
+ };
3325
+
3326
+ /**
3327
+ *
3328
+ */
3329
+ const getFirstInput = () => {
3330
+ return getControllers().view.currentView?.lastUpdate?.thing1.asset;
3331
+ };
3332
+
3333
+ expect(getFirstInput()?.validation?.message).toBe("Local Error");
3334
+ getControllers().data.set([["data.thing1", "foo"]]);
3335
+ expect(getFirstInput()?.validation?.message).toBe("Local Error");
3336
+
3337
+ shouldError = false;
3338
+
3339
+ getControllers().data.set([["data.thing1", "sam"]]);
3340
+
3341
+ await vitest.waitFor(() => {
3342
+ expect(getFirstInput()?.validation?.message).toBe(undefined);
3343
+ });
3344
+ });
3345
+ });
3346
+
3347
+ describe("Validation + Default Data", () => {
3348
+ it("triggers validation default data is invalid", async () => {
3349
+ const flow = makeFlow({
3350
+ id: "view-1",
3351
+ type: "view",
3352
+ requiredField: {
3353
+ asset: {
3354
+ id: "required-field",
3355
+ type: "input",
3356
+ binding: "input.text",
3357
+ },
3358
+ },
3359
+ thing2: {
3360
+ asset: {
3361
+ id: "thing-2",
3362
+ binding: "input.check",
3363
+ },
3364
+ },
3365
+ validation: [
3366
+ {
3367
+ type: "requiredIf",
3368
+ ref: "input.text",
3369
+ param: "input.check",
3370
+ },
3371
+ ],
3372
+ });
3373
+
3374
+ flow.schema = {
3375
+ ROOT: {
3376
+ input: {
3377
+ type: "InputType",
3378
+ },
3379
+ },
3380
+ InputType: {
3381
+ text: {
3382
+ type: "StringType",
3383
+ // The default value is an empty string, which is invalid b/c of the required check
3384
+ default: "",
3385
+ validation: [
3386
+ {
3387
+ type: "required",
3388
+ },
3389
+ ],
3390
+ },
3391
+ },
3392
+ };
3393
+
3394
+ const player = new Player({
3395
+ plugins: [new TrackBindingPlugin()],
3396
+ });
3397
+
3398
+ player.start(flow);
3399
+
3400
+ /**
3401
+ *
3402
+ */
3403
+ const getControllers = () => {
3404
+ const state = player.getState() as InProgressState;
3405
+ return state.controllers;
3406
+ };
3407
+
3408
+ /**
3409
+ *
3410
+ */
3411
+ const getFirstInput = () => {
3412
+ return getControllers().view.currentView?.lastUpdate?.requiredField.asset;
3413
+ };
3414
+
3415
+ await vitest.waitFor(() => {
3416
+ expect(getFirstInput()?.validation).toBeUndefined();
3417
+ });
3418
+
3419
+ // Set the value to the same as the default
3420
+ getControllers().data.set([["input.text", ""]]);
3421
+
3422
+ await vitest.waitFor(() => {
3423
+ expect(getFirstInput()?.validation.message).toBe("A value is required");
3424
+ });
3425
+
3426
+ // Set the value to something else
3427
+ getControllers().data.set([["input.text", "foo"]]);
3428
+ await vitest.waitFor(() => {
3429
+ expect(getFirstInput()?.validation).toBeUndefined();
3430
+ });
3431
+ });
3432
+ });
3433
+
3434
+ describe("Validation in subflow", () => {
3435
+ it("validations are evaluated when in a subflow", async () => {
3436
+ const flow = {
3437
+ id: "input-validation-flow",
3438
+ views: [
3439
+ {
3440
+ id: "view-1",
3441
+ type: "input",
3442
+ binding: "foo.requiredInput",
3443
+ label: {
3444
+ asset: {
3445
+ id: "input-required-label",
3446
+ type: "text",
3447
+ value: "This input is required",
3448
+ },
3449
+ },
3450
+ },
3451
+ ],
3452
+ schema: {
3453
+ ROOT: {
3454
+ foo: {
3455
+ type: "FooType",
3456
+ },
3457
+ },
3458
+ FooType: {
3459
+ requiredInput: {
3460
+ type: "StringType",
3461
+ validation: [
3462
+ {
3463
+ type: "required",
3464
+ },
3465
+ ],
3466
+ },
3467
+ },
3468
+ },
3469
+ data: {},
3470
+ navigation: {
3471
+ BEGIN: "FLOW_1",
3472
+ FLOW_1: {
3473
+ startState: "SUBFLOW",
3474
+ SUBFLOW: {
3475
+ state_type: "FLOW",
3476
+ ref: "FLOW_2",
3477
+ transitions: {
3478
+ "*": "END_Done",
3479
+ },
3480
+ },
3481
+ END_Done: {
3482
+ state_type: "END",
3483
+ outcome: "done",
3484
+ },
3485
+ },
3486
+ FLOW_2: {
3487
+ startState: "VIEW_1",
3488
+ VIEW_1: {
3489
+ state_type: "VIEW",
3490
+ ref: "view-1",
3491
+ transitions: {
3492
+ "*": "END_Done",
3493
+ },
3494
+ },
3495
+ END_Done: {
3496
+ state_type: "END",
3497
+ outcome: "done",
3498
+ },
3499
+ },
3500
+ },
3501
+ } as Flow;
3502
+
3503
+ const player = new Player({
3504
+ plugins: [new TrackBindingPlugin()],
3505
+ });
3506
+
3507
+ player.start(flow);
3508
+
3509
+ /**
3510
+ *
3511
+ */
3512
+ const getControllers = () => {
3513
+ const state = player.getState() as InProgressState;
3514
+ return state.controllers;
3515
+ };
3516
+
3517
+ /**
3518
+ *
3519
+ */
3520
+ const getValidationMessage = () => {
3521
+ return getControllers().view.currentView?.lastUpdate?.validation;
3522
+ };
3523
+
3524
+ /**
3525
+ *
3526
+ */
3527
+ const attemptTransition = () => {
3528
+ getControllers().flow.transition("next");
3529
+ };
3530
+
3531
+ await vitest.waitFor(() => {
3532
+ expect(getControllers().view.currentView?.lastUpdate?.id).toStrictEqual(
3533
+ "view-1",
3534
+ );
3535
+ });
3536
+
3537
+ attemptTransition();
3538
+ expect(getControllers().view.currentView?.lastUpdate?.id).toStrictEqual(
3539
+ "view-1",
3540
+ );
3541
+ const firstRequiredValidation = getValidationMessage();
3542
+ expect(firstRequiredValidation.message).toStrictEqual(
3543
+ "A value is required",
3544
+ );
3545
+ getControllers().data.set([["foo.requiredInput", 1]]);
3546
+
3547
+ await vitest.waitFor(() => {
3548
+ attemptTransition();
3549
+ });
3550
+
3551
+ await vitest.waitFor(() => {
3552
+ expect(player.getState().status).toStrictEqual("completed");
3553
+ });
3554
+ });
3555
+ });