create-foldkit-app 0.32.1 → 0.33.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 (117) hide show
  1. package/dist/templates/base/.oxfmtrc.json +28 -0
  2. package/dist/templates/base/.oxlintrc.json +4 -0
  3. package/dist/templates/base/FOLDKIT.md +1 -1
  4. package/dist/templates/base/package.json +1 -1
  5. package/dist/templates/examples/api-cache/package.json +0 -2
  6. package/dist/templates/examples/api-cache/src/scene.test.ts +1 -2
  7. package/dist/templates/examples/api-cache/src/story.test.ts +3 -4
  8. package/dist/templates/examples/auth/package.json +0 -2
  9. package/dist/templates/examples/canvas-art/package.json +0 -2
  10. package/dist/templates/examples/charting/package.json +0 -2
  11. package/dist/templates/examples/charting/src/scene.test.ts +1 -2
  12. package/dist/templates/examples/charting/src/story.test.ts +3 -4
  13. package/dist/templates/examples/counter/package.json +0 -2
  14. package/dist/templates/examples/counters/package.json +0 -2
  15. package/dist/templates/examples/crash-view/package.json +0 -2
  16. package/dist/templates/examples/embedding/package.json +0 -2
  17. package/dist/templates/examples/form/package.json +0 -2
  18. package/dist/templates/examples/generative-art/package.json +0 -2
  19. package/dist/templates/examples/interrupting-commands/package.json +0 -2
  20. package/dist/templates/examples/job-application/package.json +0 -2
  21. package/dist/templates/examples/job-application/src/main.fixture.ts +69 -0
  22. package/dist/templates/examples/job-application/src/main.ts +1 -1
  23. package/dist/templates/examples/job-application/src/scene.test.ts +6 -66
  24. package/dist/templates/examples/job-application/src/step/attachments/index.ts +2 -0
  25. package/dist/templates/examples/job-application/src/step/attachments/scene.test.ts +19 -0
  26. package/dist/templates/examples/job-application/src/step/attachments/story.test.ts +46 -0
  27. package/dist/templates/examples/job-application/src/{view/attachments.ts → step/attachments/view.ts} +4 -11
  28. package/dist/templates/examples/job-application/src/{view → step/coverLetter}/coverLetter.ts +36 -7
  29. package/dist/templates/examples/job-application/src/step/coverLetter/index.ts +1 -0
  30. package/dist/templates/examples/job-application/src/step/coverLetter/scene.test.ts +24 -0
  31. package/dist/templates/examples/job-application/src/step/coverLetter/story.test.ts +19 -0
  32. package/dist/templates/examples/job-application/src/step/education/{entry.ts → entry/entry.ts} +2 -2
  33. package/dist/templates/examples/job-application/src/step/education/entry/index.ts +11 -0
  34. package/dist/templates/examples/job-application/src/step/education/entry/story.test.ts +39 -0
  35. package/dist/templates/examples/job-application/src/step/education/entry/view.ts +160 -0
  36. package/dist/templates/examples/job-application/src/step/education/index.ts +11 -1
  37. package/dist/templates/examples/job-application/src/step/education/scene.test.ts +20 -0
  38. package/dist/templates/examples/job-application/src/step/education/story.test.ts +87 -0
  39. package/dist/templates/examples/job-application/src/{view/education.ts → step/education/view.ts} +6 -10
  40. package/dist/templates/examples/job-application/src/step/personalInfo/index.ts +11 -0
  41. package/dist/templates/examples/job-application/src/step/{personalInfo.ts → personalInfo/personalInfo.ts} +2 -2
  42. package/dist/templates/examples/job-application/src/step/personalInfo/scene.test.ts +38 -0
  43. package/dist/templates/examples/job-application/src/step/personalInfo/story.test.ts +87 -0
  44. package/dist/templates/examples/job-application/src/{view/personalInfo.ts → step/personalInfo/view.ts} +24 -34
  45. package/dist/templates/examples/job-application/src/step/skills/{entry.ts → entry/entry.ts} +2 -2
  46. package/dist/templates/examples/job-application/src/step/skills/entry/index.ts +11 -0
  47. package/dist/templates/examples/job-application/src/step/skills/entry/story.test.ts +27 -0
  48. package/dist/templates/examples/job-application/src/{view/skillEntry.ts → step/skills/entry/view.ts} +9 -12
  49. package/dist/templates/examples/job-application/src/step/skills/index.ts +11 -1
  50. package/dist/templates/examples/job-application/src/step/skills/scene.test.ts +16 -0
  51. package/dist/templates/examples/job-application/src/step/skills/story.test.ts +85 -0
  52. package/dist/templates/examples/job-application/src/step/skills/view.ts +47 -0
  53. package/dist/templates/examples/job-application/src/step/workHistory/{entry.ts → entry/entry.ts} +1 -1
  54. package/dist/templates/examples/job-application/src/step/workHistory/entry/index.ts +11 -0
  55. package/dist/templates/examples/job-application/src/step/workHistory/entry/story.test.ts +42 -0
  56. package/dist/templates/examples/job-application/src/{view/workEntry.ts → step/workHistory/entry/view.ts} +27 -42
  57. package/dist/templates/examples/job-application/src/step/workHistory/index.ts +11 -1
  58. package/dist/templates/examples/job-application/src/step/workHistory/scene.test.ts +20 -0
  59. package/dist/templates/examples/job-application/src/step/workHistory/story.test.ts +87 -0
  60. package/dist/templates/examples/job-application/src/{view/workHistory.ts → step/workHistory/view.ts} +4 -8
  61. package/dist/templates/examples/job-application/src/story.test.ts +28 -419
  62. package/dist/templates/examples/job-application/src/view/datePicker.ts +4 -4
  63. package/dist/templates/examples/job-application/src/view/field.ts +3 -3
  64. package/dist/templates/examples/job-application/src/view/index.ts +3 -358
  65. package/dist/templates/examples/job-application/src/view/stepNav.ts +2 -2
  66. package/dist/templates/examples/job-application/src/view/view.ts +359 -0
  67. package/dist/templates/examples/kanban/package.json +0 -2
  68. package/dist/templates/examples/kanban/src/scene.test.ts +1 -1
  69. package/dist/templates/examples/kanban/src/story.test.ts +22 -23
  70. package/dist/templates/examples/kanban/src/view/index.ts +1 -71
  71. package/dist/templates/examples/kanban/src/view/view.ts +71 -0
  72. package/dist/templates/examples/managed-resource-layer/package.json +0 -2
  73. package/dist/templates/examples/map/package.json +0 -2
  74. package/dist/templates/examples/pixel-art/package.json +0 -2
  75. package/dist/templates/examples/pixel-art/src/scene.test.ts +16 -9
  76. package/dist/templates/examples/pixel-art/src/story.test.ts +12 -7
  77. package/dist/templates/examples/query-sync/package.json +0 -2
  78. package/dist/templates/examples/query-sync/src/story.test.ts +4 -5
  79. package/dist/templates/examples/route-transitions/package.json +0 -2
  80. package/dist/templates/examples/routing/package.json +0 -2
  81. package/dist/templates/examples/routing/src/page/people.scene.test.ts +41 -0
  82. package/dist/templates/examples/routing/src/page/people.story.test.ts +73 -0
  83. package/dist/templates/examples/routing/src/scene.test.ts +2 -17
  84. package/dist/templates/examples/routing/src/story.test.ts +4 -34
  85. package/dist/templates/examples/shopping-cart/package.json +0 -2
  86. package/dist/templates/examples/shopping-cart/src/page/products.scene.test.ts +20 -0
  87. package/dist/templates/examples/shopping-cart/src/page/products.story.test.ts +56 -0
  88. package/dist/templates/examples/shopping-cart/src/page/products.ts +1 -1
  89. package/dist/templates/examples/shopping-cart/src/scene.test.ts +1 -4
  90. package/dist/templates/examples/shopping-cart/src/story.test.ts +3 -4
  91. package/dist/templates/examples/slow-warnings/package.json +0 -2
  92. package/dist/templates/examples/snake/package.json +0 -2
  93. package/dist/templates/examples/ssg/package.json +0 -2
  94. package/dist/templates/examples/ssr/package.json +0 -2
  95. package/dist/templates/examples/state-machine/package.json +0 -2
  96. package/dist/templates/examples/state-machine/src/main.ts +79 -87
  97. package/dist/templates/examples/state-machine/src/scene.test.ts +1 -2
  98. package/dist/templates/examples/stopwatch/package.json +0 -2
  99. package/dist/templates/examples/todo/package.json +0 -2
  100. package/dist/templates/examples/ui-showcase/package.json +0 -2
  101. package/dist/templates/examples/ui-showcase/src/story.test.ts +1 -2
  102. package/dist/templates/examples/view-transitions/package.json +0 -2
  103. package/dist/templates/examples/view-transitions/src/styles.css +3 -3
  104. package/dist/templates/examples/weather/package.json +0 -2
  105. package/dist/templates/examples/web-components/package.json +0 -2
  106. package/dist/templates/examples/websocket-chat/package.json +0 -2
  107. package/dist/templates/release.json +9 -10
  108. package/dist/templates/rendering/ssg/package.json +1 -1
  109. package/dist/templates/rendering/ssr/package.json +1 -1
  110. package/dist/utils/packages.js +1 -2
  111. package/package.json +3 -3
  112. package/dist/templates/base/.prettierignore +0 -5
  113. package/dist/templates/base/.prettierrc +0 -13
  114. package/dist/templates/examples/job-application/src/step/coverLetter.ts +0 -34
  115. package/dist/templates/examples/job-application/src/view/educationEntry.ts +0 -165
  116. package/dist/templates/examples/job-application/src/view/skills.ts +0 -49
  117. /package/dist/templates/examples/job-application/src/step/{attachments.ts → attachments/attachments.ts} +0 -0
@@ -0,0 +1,19 @@
1
+ import { given, message, model, story } from 'foldkit/story'
2
+ import { describe, expect, test } from 'vitest'
3
+
4
+ import { Message, init, update } from './coverLetter'
5
+
6
+ describe('coverLetter', () => {
7
+ test('UpdatedContent replaces the letter text', () => {
8
+ story(
9
+ update,
10
+ given(init()),
11
+ message(
12
+ Message.UpdatedContent({ value: 'I love the Elm Architecture.' }),
13
+ ),
14
+ model(model => {
15
+ expect(model.content).toBe('I love the Elm Architecture.')
16
+ }),
17
+ )
18
+ })
19
+ })
@@ -13,7 +13,7 @@ import { evo } from 'foldkit/struct'
13
13
 
14
14
  import { Listbox } from '@foldkit/ui'
15
15
 
16
- import { revealFieldErrors } from '../validation'
16
+ import { revealFieldErrors } from '../../validation'
17
17
 
18
18
  // FIELD VALIDATION
19
19
 
@@ -46,7 +46,7 @@ export const Model = Schema.Struct({
46
46
  })
47
47
  export type Model = typeof Model.Type
48
48
 
49
- const GraduationYearListbox = Listbox.create<string>()
49
+ export const GraduationYearListbox = Listbox.create<string>()
50
50
 
51
51
  // MESSAGE
52
52
 
@@ -0,0 +1,11 @@
1
+ export {
2
+ Model,
3
+ Message,
4
+ OutMessage,
5
+ init,
6
+ update,
7
+ hasErrors,
8
+ isComplete,
9
+ revealErrors,
10
+ } from './entry'
11
+ export { type ViewInputs, view } from './view'
@@ -0,0 +1,39 @@
1
+ import { expectOutMessage, given, message, model, story } from 'foldkit/story'
2
+ import { describe, expect, test } from 'vitest'
3
+
4
+ import { Message, OutMessage, init, update } from './entry'
5
+
6
+ describe('education entry', () => {
7
+ test('UpdatedSchool stores a valid school', () => {
8
+ story(
9
+ update,
10
+ given(init('entry-1')),
11
+ message(Message.UpdatedSchool({ value: 'MIT' })),
12
+ model(model => {
13
+ expect(model.school.value).toBe('MIT')
14
+ expect(model.school._tag).toBe('Valid')
15
+ }),
16
+ )
17
+ })
18
+
19
+ test('clearing school after a value makes the field Invalid', () => {
20
+ story(
21
+ update,
22
+ given(init('entry-1')),
23
+ message(Message.UpdatedSchool({ value: 'MIT' })),
24
+ message(Message.UpdatedSchool({ value: '' })),
25
+ model(model => {
26
+ expect(model.school._tag).toBe('Invalid')
27
+ }),
28
+ )
29
+ })
30
+
31
+ test('ClickedRemoveSelf emits Removed', () => {
32
+ story(
33
+ update,
34
+ given(init('entry-1')),
35
+ message(Message.ClickedRemoveSelf()),
36
+ expectOutMessage(OutMessage.Removed()),
37
+ )
38
+ })
39
+ })
@@ -0,0 +1,160 @@
1
+ import clsx from 'clsx'
2
+ import { Array, Option } from 'effect'
3
+ import { Submodel } from 'foldkit'
4
+ import { type CalendarDate } from 'foldkit/calendar'
5
+ import type { Html } from 'foldkit/html'
6
+
7
+ import { Button } from '@foldkit/ui'
8
+
9
+ import { Field, Icon } from '../../../view'
10
+ import { GraduationYearListbox, Message, type Model } from './entry'
11
+
12
+ const GRADUATION_YEAR_WINDOW_SIZE = 30
13
+ const GRADUATION_YEAR_FORWARD_OFFSET = 6
14
+
15
+ const graduationYears = (today: CalendarDate): ReadonlyArray<string> =>
16
+ Array.makeBy(GRADUATION_YEAR_WINDOW_SIZE, index =>
17
+ String(today.year + GRADUATION_YEAR_FORWARD_OFFSET - index),
18
+ )
19
+
20
+ export type ViewInputs = Readonly<{
21
+ today: CalendarDate
22
+ }>
23
+
24
+ export const view = Submodel.defineView<Model, Message, ViewInputs>(
25
+ (model, viewInputs, h): Html => {
26
+ const { today } = viewInputs
27
+
28
+ const showGraduationYear = !model.isCurrentlyEnrolled
29
+
30
+ const graduationYearField = h.keyed('div')(
31
+ `${model.id}-graduation-year`,
32
+ [h.Class('space-y-1')],
33
+ [
34
+ h.label(
35
+ [h.Class('block text-sm font-medium text-gray-700')],
36
+ ['Graduation Year'],
37
+ ),
38
+ h.submodel({
39
+ slotId: model.graduationYearListbox.id,
40
+ model: model.graduationYearListbox,
41
+ view: GraduationYearListbox.view,
42
+ viewInputs: {
43
+ items: graduationYears(today),
44
+ maybeSelectedValue: model.maybeGraduationYear,
45
+ buttonContent: h.div(
46
+ [h.Class('flex w-full items-center justify-between gap-2')],
47
+ [
48
+ Option.match(model.maybeGraduationYear, {
49
+ onNone: () =>
50
+ h.span([h.Class('text-gray-400')], ['Select year']),
51
+ onSome: graduationYear => h.span([], [graduationYear]),
52
+ }),
53
+ h.span(
54
+ [h.Class('text-gray-400 shrink-0')],
55
+ [Icon.chevronDown()],
56
+ ),
57
+ ],
58
+ ),
59
+ buttonClassName:
60
+ 'w-full rounded-lg border border-gray-300 bg-white px-3 py-2 text-sm text-left cursor-pointer focus:outline-none focus:ring-2 focus:ring-indigo-500',
61
+ itemsClassName:
62
+ 'rounded-lg border border-gray-200 bg-white shadow-lg py-1 max-h-64 overflow-y-auto w-(--button-width)',
63
+ itemToConfig: (year, { isActive, isSelected }) => ({
64
+ className: clsx(
65
+ 'flex items-center gap-2 px-4 py-2 text-sm cursor-pointer',
66
+ isActive && 'bg-gray-50',
67
+ isSelected && 'text-indigo-700 font-semibold',
68
+ ),
69
+ content: h.div(
70
+ [h.Class('flex items-center gap-2 w-full')],
71
+ [
72
+ isSelected ? h.span([], ['✓']) : h.span([h.Class('w-4')]),
73
+ h.span([], [year]),
74
+ ],
75
+ ),
76
+ }),
77
+ backdropClassName: 'fixed inset-0',
78
+ anchor: { placement: 'bottom-start', gap: 4, padding: 8 },
79
+ },
80
+ toParentMessage: message =>
81
+ Message.GotGraduationYearListboxMessage({ message }),
82
+ }),
83
+ ],
84
+ )
85
+
86
+ return h.keyed('div')(
87
+ model.id,
88
+ [h.Class('py-6 space-y-4 first:pt-0')],
89
+ [
90
+ h.div(
91
+ [h.Class('grid grid-cols-2 gap-3')],
92
+ [
93
+ Field.input(
94
+ {
95
+ id: `${model.id}-school`,
96
+ label: 'School',
97
+ field: model.school,
98
+ onInput: value => Message.UpdatedSchool({ value }),
99
+ placeholder: 'e.g. MIT',
100
+ },
101
+ h,
102
+ ),
103
+ Field.input(
104
+ {
105
+ id: `${model.id}-degree`,
106
+ label: 'Degree',
107
+ field: model.degree,
108
+ onInput: value => Message.UpdatedDegree({ value }),
109
+ placeholder: "e.g. Bachelor's, Master's",
110
+ },
111
+ h,
112
+ ),
113
+ ],
114
+ ),
115
+ Field.input(
116
+ {
117
+ id: `${model.id}-field`,
118
+ label: 'Field of Study',
119
+ field: model.fieldOfStudy,
120
+ onInput: value => Message.UpdatedFieldOfStudy({ value }),
121
+ placeholder: 'e.g. Computer Science',
122
+ },
123
+ h,
124
+ ),
125
+ Field.checkbox(
126
+ {
127
+ id: `${model.id}-enrolled`,
128
+ label: 'I’m currently enrolled',
129
+ isChecked: model.isCurrentlyEnrolled,
130
+ onToggle: isChecked =>
131
+ Message.ToggledCurrentlyEnrolled({ isChecked }),
132
+ },
133
+ h,
134
+ ),
135
+ ...(showGraduationYear ? [graduationYearField] : []),
136
+ h.div(
137
+ [h.Class('flex justify-end')],
138
+ [
139
+ Button.view(
140
+ {
141
+ onClick: Message.ClickedRemoveSelf(),
142
+ toView: attributes =>
143
+ h.button(
144
+ [
145
+ ...attributes.button,
146
+ h.Class(
147
+ 'text-sm text-gray-400 hover:text-red-500 transition cursor-pointer',
148
+ ),
149
+ ],
150
+ ['Remove education'],
151
+ ),
152
+ },
153
+ h,
154
+ ),
155
+ ],
156
+ ),
157
+ ],
158
+ )
159
+ },
160
+ )
@@ -1,2 +1,12 @@
1
- export * from './education'
1
+ export {
2
+ Model,
3
+ Message,
4
+ init,
5
+ update,
6
+ GenerateEntryId,
7
+ hasErrors,
8
+ isComplete,
9
+ revealErrors,
10
+ } from './education'
2
11
  export * as Entry from './entry'
12
+ export { view } from './view'
@@ -0,0 +1,20 @@
1
+ import { Calendar } from 'foldkit'
2
+ import { expect, given, label, role, scene } from 'foldkit/scene'
3
+ import { describe, test } from 'vitest'
4
+
5
+ import { init, update } from './education'
6
+ import { view } from './view'
7
+
8
+ const today = Calendar.make(2026, 4, 16)
9
+
10
+ describe('education', () => {
11
+ test('renders the first school fields and the add control', () => {
12
+ scene(
13
+ { update, view },
14
+ given(init(today, 'education-entry-1')),
15
+ expect(label('School')).toExist(),
16
+ expect(label('Degree')).toExist(),
17
+ expect(role('button', { name: '+ Add Education' })).toExist(),
18
+ )
19
+ })
20
+ })
@@ -0,0 +1,87 @@
1
+ import { Array, Option, pipe } from 'effect'
2
+ import { Calendar } from 'foldkit'
3
+ import { Command, given, message, model, story } from 'foldkit/story'
4
+ import { describe, expect, test } from 'vitest'
5
+
6
+ import { GenerateEntryId, Message, init, update } from './education'
7
+ import * as Entry from './entry'
8
+
9
+ const today = Calendar.make(2026, 4, 16)
10
+ const givenInitial = given(init(today, 'education-entry-1'))
11
+
12
+ describe('education', () => {
13
+ test('ClickedAddEntry generates an id and appends an entry', () => {
14
+ story(
15
+ update,
16
+ givenInitial,
17
+ message(Message.ClickedAddEntry()),
18
+ Command.expectHas(GenerateEntryId),
19
+ Command.resolve(
20
+ GenerateEntryId,
21
+ Message.SucceededGenerateEntryId({ entryId: 'test-edu-1' }),
22
+ ),
23
+ model(model => {
24
+ expect(model.entries).toHaveLength(2)
25
+ }),
26
+ )
27
+ })
28
+
29
+ test('FailedGenerateEntryId leaves the entries unchanged', () => {
30
+ story(
31
+ update,
32
+ givenInitial,
33
+ message(Message.FailedGenerateEntryId()),
34
+ Command.expectNone(),
35
+ model(model => {
36
+ expect(model).toEqual(init(today, 'education-entry-1'))
37
+ }),
38
+ )
39
+ })
40
+
41
+ test('an entry OutMessage.Removed removes that entry', () => {
42
+ const firstEntry = Option.getOrThrow(
43
+ Array.head(init(today, 'education-entry-1').entries),
44
+ )
45
+
46
+ story(
47
+ update,
48
+ givenInitial,
49
+ message(
50
+ Message.GotEntryMessage({
51
+ entryId: firstEntry.id,
52
+ message: Entry.Message.ClickedRemoveSelf(),
53
+ }),
54
+ ),
55
+ model(model => {
56
+ expect(model.entries).toHaveLength(0)
57
+ }),
58
+ )
59
+ })
60
+
61
+ test('GotEntryMessage folds a school update into the matching entry', () => {
62
+ const firstEntry = Option.getOrThrow(
63
+ Array.head(init(today, 'education-entry-1').entries),
64
+ )
65
+
66
+ story(
67
+ update,
68
+ givenInitial,
69
+ message(
70
+ Message.GotEntryMessage({
71
+ entryId: firstEntry.id,
72
+ message: Entry.Message.UpdatedSchool({ value: 'MIT' }),
73
+ }),
74
+ ),
75
+ model(model => {
76
+ expect(
77
+ pipe(
78
+ model.entries,
79
+ Array.head,
80
+ Option.map(entry => entry.school.value),
81
+ Option.getOrThrow,
82
+ ),
83
+ ).toBe('MIT')
84
+ }),
85
+ )
86
+ })
87
+ })
@@ -4,30 +4,26 @@ import { type Html, type HtmlBuilder, createKeyedLazy } from 'foldkit/html'
4
4
 
5
5
  import { Button } from '@foldkit/ui'
6
6
 
7
- import { Education } from '../step'
8
- import { Message } from '../step/education'
9
- import { educationEntryView } from './educationEntry'
7
+ import { Message, type Model } from './education'
8
+ import * as Entry from './entry'
10
9
 
11
10
  const lazyEntry = createKeyedLazy()
12
11
 
13
12
  const entryView = (
14
- entry: Education.Entry.Model,
13
+ entry: Entry.Model,
15
14
  today: CalendarDate,
16
- h: HtmlBuilder<Education.Message>,
15
+ h: HtmlBuilder<Message>,
17
16
  ): Html =>
18
17
  h.submodel({
19
18
  slotId: entry.id,
20
19
  model: entry,
21
- view: educationEntryView,
20
+ view: Entry.view,
22
21
  viewInputs: { today },
23
22
  toParentMessage: message =>
24
23
  Message.GotEntryMessage({ entryId: entry.id, message }),
25
24
  })
26
25
 
27
- export const educationView = Submodel.defineView<
28
- Education.Model,
29
- Education.Message
30
- >((model, h): Html =>
26
+ export const view = Submodel.defineView<Model, Message>((model, h): Html =>
31
27
  h.div(
32
28
  [h.Class('space-y-6')],
33
29
  [
@@ -0,0 +1,11 @@
1
+ export {
2
+ Model,
3
+ Message,
4
+ init,
5
+ update,
6
+ ValidateEmailAsync,
7
+ hasErrors,
8
+ isComplete,
9
+ revealErrors,
10
+ } from './personalInfo'
11
+ export { view } from './view'
@@ -18,11 +18,11 @@ import { evo } from 'foldkit/struct'
18
18
 
19
19
  import { DatePicker, Listbox } from '@foldkit/ui'
20
20
 
21
- import { revealFieldErrors } from './validation'
21
+ import { revealFieldErrors } from '../validation'
22
22
 
23
23
  // MODEL
24
24
 
25
- const PronounsListbox = Listbox.create<string>()
25
+ export const PronounsListbox = Listbox.create<string>()
26
26
 
27
27
  export const Model = Schema.Struct({
28
28
  firstName: Field(Schema.String),
@@ -0,0 +1,38 @@
1
+ import { Calendar } from 'foldkit'
2
+ import { expect, given, label, scene, text, type } from 'foldkit/scene'
3
+ import { describe, test } from 'vitest'
4
+
5
+ import { init, update } from './personalInfo'
6
+ import { view } from './view'
7
+
8
+ const today = Calendar.make(2026, 4, 16)
9
+
10
+ describe('personalInfo', () => {
11
+ test('renders the name and email fields', () => {
12
+ scene(
13
+ { update, view },
14
+ given(init(today)),
15
+ expect(label('First Name')).toExist(),
16
+ expect(label('Last Name')).toExist(),
17
+ expect(label('Email')).toExist(),
18
+ )
19
+ })
20
+
21
+ test('a valid first name shows a checkmark', () => {
22
+ scene(
23
+ { update, view },
24
+ given(init(today)),
25
+ type(label('First Name'), 'Jane'),
26
+ expect(text('✓')).toExist(),
27
+ )
28
+ })
29
+
30
+ test('a short first name shows the length error', () => {
31
+ scene(
32
+ { update, view },
33
+ given(init(today)),
34
+ type(label('First Name'), 'J'),
35
+ expect(text('First name must be at least 2 characters')).toExist(),
36
+ )
37
+ })
38
+ })
@@ -0,0 +1,87 @@
1
+ import { Calendar } from 'foldkit'
2
+ import { Valid, Validating } from 'foldkit/fieldValidation'
3
+ import { Command, given, message, model, story } from 'foldkit/story'
4
+ import { describe, expect, test } from 'vitest'
5
+
6
+ import { Message, ValidateEmailAsync, init, update } from './personalInfo'
7
+
8
+ const today = Calendar.make(2026, 4, 16)
9
+ const givenInitial = given(init(today))
10
+
11
+ describe('personalInfo', () => {
12
+ test('a valid first name is stored as Valid', () => {
13
+ story(
14
+ update,
15
+ givenInitial,
16
+ message(Message.UpdatedFirstName({ value: 'Jane' })),
17
+ model(model => {
18
+ expect(model.firstName.value).toBe('Jane')
19
+ expect(model.firstName._tag).toBe('Valid')
20
+ }),
21
+ )
22
+ })
23
+
24
+ test('a short first name is Invalid', () => {
25
+ story(
26
+ update,
27
+ givenInitial,
28
+ message(Message.UpdatedFirstName({ value: 'J' })),
29
+ model(model => {
30
+ expect(model.firstName._tag).toBe('Invalid')
31
+ }),
32
+ )
33
+ })
34
+
35
+ test('a well-formed email starts async uniqueness validation', () => {
36
+ story(
37
+ update,
38
+ givenInitial,
39
+ message(Message.UpdatedEmail({ value: 'jane@example.com' })),
40
+ Command.expectHas(ValidateEmailAsync),
41
+ Command.resolve(
42
+ ValidateEmailAsync,
43
+ Message.CompletedValidateEmailAsync({
44
+ validationId: 1,
45
+ field: Valid({ value: 'jane@example.com' }),
46
+ }),
47
+ ),
48
+ model(model => {
49
+ expect(model.email._tag).toBe('Valid')
50
+ }),
51
+ )
52
+ })
53
+
54
+ test('a malformed email fails sync validation without an async command', () => {
55
+ story(
56
+ update,
57
+ givenInitial,
58
+ message(Message.UpdatedEmail({ value: 'not-email' })),
59
+ Command.expectNone(),
60
+ model(model => {
61
+ expect(model.email._tag).toBe('Invalid')
62
+ }),
63
+ )
64
+ })
65
+
66
+ test('a stale email async result is discarded', () => {
67
+ story(
68
+ update,
69
+ given({
70
+ ...init(today),
71
+ email: Validating({ value: 'jane@example.com' }),
72
+ emailValidationId: 5,
73
+ }),
74
+ message(
75
+ Message.CompletedValidateEmailAsync({
76
+ validationId: 3,
77
+ field: Valid({ value: 'old@example.com' }),
78
+ }),
79
+ ),
80
+ model(model => {
81
+ expect(model.email._tag).toBe('Validating')
82
+ expect(model.email.value).toBe('jane@example.com')
83
+ expect(model.emailValidationId).toBe(5)
84
+ }),
85
+ )
86
+ })
87
+ })