create-foldkit-app 0.37.0 → 0.38.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.
@@ -0,0 +1,124 @@
1
+ import { Result } from 'effect'
2
+ import {
3
+ Command,
4
+ click,
5
+ expect,
6
+ given,
7
+ inside,
8
+ role,
9
+ scene,
10
+ text,
11
+ } from 'foldkit/scene'
12
+ import { describe, test } from 'vitest'
13
+
14
+ import { Tabs } from '@foldkit/ui'
15
+
16
+ import { postQuery, statsQuery, update, view } from './main'
17
+ import {
18
+ FETCHED_AT,
19
+ cachedFirstPostModel,
20
+ firstPostDetail,
21
+ fixtureStats,
22
+ loadedPostsModel,
23
+ loadingPostsModel,
24
+ } from './main.fixture'
25
+
26
+ const FIRST_REQUEST_GENERATION = 1
27
+
28
+ const resolveFocusTab = Command.resolve(
29
+ Tabs.FocusTab,
30
+ Tabs.Message.CompletedFocusTab(),
31
+ )
32
+
33
+ describe('view', () => {
34
+ test('the posts tab renders its loading state and navigation', () => {
35
+ scene(
36
+ { update, view },
37
+ given(loadingPostsModel),
38
+ expect(text('Loading posts…')).toExist(),
39
+ expect(role('button', { name: 'Refresh' })).toExist(),
40
+ expect(role('tab', { name: 'Posts' })).toExist(),
41
+ expect(role('tab', { name: 'Stats' })).toExist(),
42
+ )
43
+ })
44
+
45
+ test('clicking a post fetches its detail and renders it', () => {
46
+ scene(
47
+ { update, view },
48
+ given(loadedPostsModel),
49
+ click(role('button', { name: /First Post/ })),
50
+ expect(text('Loading post…')).toExist(),
51
+ Command.expectExact(postQuery.Fetch),
52
+ Command.resolve(
53
+ postQuery.Fetch,
54
+ postQuery.Message.CompletedFetch({
55
+ args: { postId: 'first-post' },
56
+ generation: FIRST_REQUEST_GENERATION,
57
+ result: Result.succeed({
58
+ post: firstPostDetail,
59
+ fetchedAt: FETCHED_AT,
60
+ }),
61
+ }),
62
+ ),
63
+ inside(
64
+ role('article'),
65
+ expect(text('By Grace Hopper')).toExist(),
66
+ expect(text('The whole body of the first fixture post.')).toExist(),
67
+ ),
68
+ expect(role('button', { name: 'Back to posts' })).toExist(),
69
+ )
70
+ })
71
+
72
+ test('opening a cached post skips the fetch', () => {
73
+ scene(
74
+ { update, view },
75
+ given(cachedFirstPostModel),
76
+ expect(text('Cached')).toExist(),
77
+ click(role('button', { name: /First Post/ })),
78
+ Command.expectNone(),
79
+ expect(text('By Grace Hopper')).toExist(),
80
+ )
81
+ })
82
+
83
+ test('a failed post fetch shows the error with a Retry button', () => {
84
+ scene(
85
+ { update, view },
86
+ given(loadedPostsModel),
87
+ click(role('button', { name: /First Post/ })),
88
+ Command.resolve(
89
+ postQuery.Fetch,
90
+ postQuery.Message.CompletedFetch({
91
+ args: { postId: 'first-post' },
92
+ generation: FIRST_REQUEST_GENERATION,
93
+ result: Result.fail('The connection dropped.'),
94
+ }),
95
+ ),
96
+ expect(text('The connection dropped.')).toExist(),
97
+ expect(role('button', { name: 'Retry' })).toExist(),
98
+ )
99
+ })
100
+
101
+ test('switching to the Stats tab fetches and renders stats', () => {
102
+ scene(
103
+ { update, view },
104
+ given(loadedPostsModel),
105
+ click(role('tab', { name: 'Stats' })),
106
+ expect(text('Loading stats…')).toExist(),
107
+ resolveFocusTab,
108
+ Command.expectExact(statsQuery.Fetch),
109
+ Command.resolve(
110
+ statsQuery.Fetch,
111
+ statsQuery.Message.CompletedFetch({
112
+ generation: FIRST_REQUEST_GENERATION,
113
+ result: Result.succeed({
114
+ stats: fixtureStats,
115
+ fetchedAt: FETCHED_AT,
116
+ }),
117
+ }),
118
+ ),
119
+ expect(text('Active users')).toExist(),
120
+ expect(text('97%')).toExist(),
121
+ expect(text('Updated at', { exact: false })).toExist(),
122
+ )
123
+ })
124
+ })
@@ -0,0 +1,297 @@
1
+ import { Option, Result } from 'effect'
2
+ import { Command, given, message, model, story } from 'foldkit/story'
3
+ import { modifyFields } from 'foldkit/struct'
4
+ import { expect, test } from 'vitest'
5
+
6
+ import { Tabs } from '@foldkit/ui'
7
+
8
+ import { Message, postQuery, postsQuery, statsQuery, update } from './main'
9
+ import {
10
+ FETCHED_AT,
11
+ failedFirstPostModel,
12
+ failedPostsModel,
13
+ firstPostDetail,
14
+ fixturePosts,
15
+ fixtureStats,
16
+ loadedPostsModel,
17
+ loadedStatsModel,
18
+ loadingStatsModel,
19
+ refreshingStatsModel,
20
+ } from './main.fixture'
21
+
22
+ const FIRST_REQUEST_GENERATION = 1
23
+ const SECOND_REQUEST_GENERATION = 2
24
+
25
+ const postAsyncDataTag = (
26
+ model: typeof loadedPostsModel,
27
+ postId: string,
28
+ ): string => postQuery.read(model.postDetails, { postId })._tag
29
+
30
+ const selectedPostsTab = Message.GotTabsMessage({
31
+ message: Tabs.Message.SelectedTab({ index: 0, value: 'Posts' }),
32
+ })
33
+
34
+ const selectedStatsTab = Message.GotTabsMessage({
35
+ message: Tabs.Message.SelectedTab({ index: 1, value: 'Stats' }),
36
+ })
37
+
38
+ const resolveFocusTab = Command.resolve(
39
+ Tabs.FocusTab,
40
+ Tabs.Message.CompletedFocusTab(),
41
+ )
42
+
43
+ test('first visit to the Stats tab fetches stats', () => {
44
+ story(
45
+ update,
46
+ given(loadedPostsModel),
47
+ message(selectedStatsTab),
48
+ model(model => {
49
+ expect(model.activeTab).toBe('Stats')
50
+ expect(statsQuery.read(model.stats)._tag).toBe('Loading')
51
+ }),
52
+ resolveFocusTab,
53
+ Command.resolve(
54
+ statsQuery.Fetch,
55
+ statsQuery.Message.CompletedFetch({
56
+ generation: FIRST_REQUEST_GENERATION,
57
+ result: Result.succeed({
58
+ stats: fixtureStats,
59
+ fetchedAt: FETCHED_AT,
60
+ }),
61
+ }),
62
+ ),
63
+ model(model => {
64
+ expect(statsQuery.read(model.stats)._tag).toBe('Success')
65
+ }),
66
+ )
67
+ })
68
+
69
+ test('returning to a tab with cached data does not refetch', () => {
70
+ story(
71
+ update,
72
+ given(loadedStatsModel),
73
+ message(selectedPostsTab),
74
+ resolveFocusTab,
75
+ Command.expectNone(),
76
+ message(selectedStatsTab),
77
+ resolveFocusTab,
78
+ Command.expectNone(),
79
+ model(model => {
80
+ expect(statsQuery.read(model.stats)._tag).toBe('Success')
81
+ }),
82
+ )
83
+ })
84
+
85
+ test('a revalidation tick keeps stale stats on screen while refetching', () => {
86
+ story(
87
+ update,
88
+ given(loadedStatsModel),
89
+ message(Message.TickedStatsRefreshInterval()),
90
+ model(model => {
91
+ const statsAsyncData = statsQuery.read(model.stats)
92
+ expect(statsAsyncData._tag).toBe('Refreshing')
93
+ if (statsAsyncData._tag === 'Refreshing') {
94
+ expect(statsAsyncData.data.stats).toEqual(fixtureStats)
95
+ }
96
+ }),
97
+ Command.resolve(
98
+ statsQuery.Fetch,
99
+ statsQuery.Message.CompletedFetch({
100
+ generation: SECOND_REQUEST_GENERATION,
101
+ result: Result.succeed({
102
+ stats: modifyFields(fixtureStats, { activeUsers: () => 99 }),
103
+ fetchedAt: FETCHED_AT + 5000,
104
+ }),
105
+ }),
106
+ ),
107
+ model(model => {
108
+ const statsAsyncData = statsQuery.read(model.stats)
109
+ expect(statsAsyncData._tag).toBe('Success')
110
+ if (statsAsyncData._tag === 'Success') {
111
+ expect(statsAsyncData.data.stats.activeUsers).toBe(99)
112
+ }
113
+ }),
114
+ )
115
+ })
116
+
117
+ test('a failed refresh keeps the stale stats on screen with the error', () => {
118
+ story(
119
+ update,
120
+ given(loadedStatsModel),
121
+ message(Message.TickedStatsRefreshInterval()),
122
+ Command.resolve(
123
+ statsQuery.Fetch,
124
+ statsQuery.Message.CompletedFetch({
125
+ generation: SECOND_REQUEST_GENERATION,
126
+ result: Result.fail('The server is down.'),
127
+ }),
128
+ ),
129
+ model(model => {
130
+ const statsAsyncData = statsQuery.read(model.stats)
131
+ expect(statsAsyncData._tag).toBe('Stale')
132
+ if (statsAsyncData._tag === 'Stale') {
133
+ expect(statsAsyncData.data.stats).toEqual(fixtureStats)
134
+ expect(statsAsyncData.error).toBe('The server is down.')
135
+ }
136
+ }),
137
+ )
138
+ })
139
+
140
+ test('refresh clicks during an in-flight fetch are deduplicated', () => {
141
+ story(
142
+ update,
143
+ given(loadingStatsModel),
144
+ message(Message.ClickedRefreshStats()),
145
+ Command.expectNone(),
146
+ )
147
+ })
148
+
149
+ test('a revalidation tick during a refresh is deduplicated', () => {
150
+ story(
151
+ update,
152
+ given(refreshingStatsModel),
153
+ message(Message.TickedStatsRefreshInterval()),
154
+ Command.expectNone(),
155
+ )
156
+ })
157
+
158
+ test('refreshing posts refetches while keeping the current list', () => {
159
+ story(
160
+ update,
161
+ given(loadedPostsModel),
162
+ message(Message.ClickedRefreshPosts()),
163
+ model(model => {
164
+ const postsAsyncData = postsQuery.read(model.posts)
165
+ expect(postsAsyncData._tag).toBe('Refreshing')
166
+ if (postsAsyncData._tag === 'Refreshing') {
167
+ expect(postsAsyncData.data.posts).toEqual(fixturePosts)
168
+ }
169
+ }),
170
+ Command.resolve(
171
+ postsQuery.Fetch,
172
+ postsQuery.Message.CompletedFetch({
173
+ generation: SECOND_REQUEST_GENERATION,
174
+ result: Result.succeed({
175
+ posts: fixturePosts,
176
+ fetchedAt: FETCHED_AT + 1000,
177
+ }),
178
+ }),
179
+ ),
180
+ model(model => {
181
+ expect(postsQuery.read(model.posts)._tag).toBe('Success')
182
+ }),
183
+ )
184
+ })
185
+
186
+ test('retrying failed posts shows the loading state and refetches', () => {
187
+ story(
188
+ update,
189
+ given(failedPostsModel),
190
+ message(Message.ClickedRetryPosts()),
191
+ model(model => {
192
+ expect(postsQuery.read(model.posts)._tag).toBe('Loading')
193
+ }),
194
+ Command.resolve(
195
+ postsQuery.Fetch,
196
+ postsQuery.Message.CompletedFetch({
197
+ generation: SECOND_REQUEST_GENERATION,
198
+ result: Result.succeed({
199
+ posts: fixturePosts,
200
+ fetchedAt: FETCHED_AT,
201
+ }),
202
+ }),
203
+ ),
204
+ model(model => {
205
+ expect(postsQuery.read(model.posts)._tag).toBe('Success')
206
+ }),
207
+ )
208
+ })
209
+
210
+ test('opening a post once reuses its retained data on later visits', () => {
211
+ story(
212
+ update,
213
+ given(loadedPostsModel),
214
+ message(Message.ClickedPost({ postId: 'first-post' })),
215
+ model(model => {
216
+ expect(postAsyncDataTag(model, 'first-post')).toBe('Loading')
217
+ }),
218
+ Command.resolve(
219
+ postQuery.Fetch,
220
+ postQuery.Message.CompletedFetch({
221
+ args: { postId: 'first-post' },
222
+ generation: FIRST_REQUEST_GENERATION,
223
+ result: Result.succeed({
224
+ post: firstPostDetail,
225
+ fetchedAt: FETCHED_AT,
226
+ }),
227
+ }),
228
+ ),
229
+ message(Message.ClickedBackToPosts()),
230
+ message(Message.ClickedPost({ postId: 'first-post' })),
231
+ Command.expectNone(),
232
+ model(model => {
233
+ expect(postAsyncDataTag(model, 'first-post')).toBe('Success')
234
+ expect(model.maybeSelectedPostId).toEqual(Option.some('first-post'))
235
+ }),
236
+ )
237
+ })
238
+
239
+ test('a failed post fetch enters Failure and retry fetches it again', () => {
240
+ story(
241
+ update,
242
+ given(loadedPostsModel),
243
+ message(Message.ClickedPost({ postId: 'first-post' })),
244
+ Command.resolve(
245
+ postQuery.Fetch,
246
+ postQuery.Message.CompletedFetch({
247
+ args: { postId: 'first-post' },
248
+ generation: FIRST_REQUEST_GENERATION,
249
+ result: Result.fail('The connection dropped.'),
250
+ }),
251
+ ),
252
+ model(model => {
253
+ expect(postAsyncDataTag(model, 'first-post')).toBe('Failure')
254
+ }),
255
+ message(Message.ClickedRetryPost({ postId: 'first-post' })),
256
+ model(model => {
257
+ expect(postAsyncDataTag(model, 'first-post')).toBe('Loading')
258
+ }),
259
+ Command.resolve(
260
+ postQuery.Fetch,
261
+ postQuery.Message.CompletedFetch({
262
+ args: { postId: 'first-post' },
263
+ generation: SECOND_REQUEST_GENERATION,
264
+ result: Result.succeed({
265
+ post: firstPostDetail,
266
+ fetchedAt: FETCHED_AT,
267
+ }),
268
+ }),
269
+ ),
270
+ model(model => {
271
+ expect(postAsyncDataTag(model, 'first-post')).toBe('Success')
272
+ }),
273
+ )
274
+ })
275
+
276
+ test('revisiting a post with a cached failure loads it again', () => {
277
+ story(
278
+ update,
279
+ given(failedFirstPostModel),
280
+ message(Message.ClickedPost({ postId: 'first-post' })),
281
+ model(model => {
282
+ expect(postAsyncDataTag(model, 'first-post')).toBe('Loading')
283
+ expect(model.maybeSelectedPostId).toEqual(Option.some('first-post'))
284
+ }),
285
+ Command.resolve(
286
+ postQuery.Fetch,
287
+ postQuery.Message.CompletedFetch({
288
+ args: { postId: 'first-post' },
289
+ generation: SECOND_REQUEST_GENERATION,
290
+ result: Result.fail('The connection dropped.'),
291
+ }),
292
+ ),
293
+ model(model => {
294
+ expect(postAsyncDataTag(model, 'first-post')).toBe('Failure')
295
+ }),
296
+ )
297
+ })
@@ -0,0 +1 @@
1
+ @import 'tailwindcss';
@@ -0,0 +1,3 @@
1
+ import { setup } from 'foldkit/test/vitest'
2
+
3
+ setup()
@@ -1,3 +1,4 @@
1
+ import { Option } from 'effect'
1
2
  import { Subscription } from 'foldkit'
2
3
 
3
4
  import { Slider } from '@foldkit/ui'
@@ -9,7 +10,7 @@ const flowStrengthSliderSubscriptions = Subscription.lift({
9
10
  flowStrengthSliderPointer: Slider.subscriptions.dragPointer,
10
11
  flowStrengthSliderEscape: Slider.subscriptions.dragEscape,
11
12
  })<Model, Message>({
12
- toChildModel: model => model.flowStrengthSlider,
13
+ read: model => Option.some(model.flowStrengthSlider),
13
14
  toParentMessage: message => Message.GotFlowStrengthSliderMessage({ message }),
14
15
  })
15
16
 
@@ -17,7 +18,7 @@ const noiseScaleSliderSubscriptions = Subscription.lift({
17
18
  noiseScaleSliderPointer: Slider.subscriptions.dragPointer,
18
19
  noiseScaleSliderEscape: Slider.subscriptions.dragEscape,
19
20
  })<Model, Message>({
20
- toChildModel: model => model.noiseScaleSlider,
21
+ read: model => Option.some(model.noiseScaleSlider),
21
22
  toParentMessage: message => Message.GotNoiseScaleSliderMessage({ message }),
22
23
  })
23
24
 
@@ -1,3 +1,4 @@
1
+ import { Option } from 'effect'
1
2
  import { Subscription } from 'foldkit'
2
3
 
3
4
  import { DragAndDrop } from '@foldkit/ui'
@@ -11,6 +12,6 @@ export const subscriptions = Subscription.lift({
11
12
  dragKeyboard: DragAndDrop.subscriptions.documentKeyboard,
12
13
  autoScroll: DragAndDrop.subscriptions.autoScroll,
13
14
  })<Model, Message>({
14
- toChildModel: model => model.dragAndDrop,
15
+ read: model => Option.some(model.dragAndDrop),
15
16
  toParentMessage: message => Message.GotDragAndDropMessage({ message }),
16
17
  })
@@ -671,6 +671,6 @@ export const subscriptions = Subscription.lift(UiSubscriptions.subscriptions)<
671
671
  Model,
672
672
  Message
673
673
  >({
674
- toChildModel: model => model.uiModel,
674
+ read: model => Option.some(model.uiModel),
675
675
  toParentMessage: message => Message.GotUiMessage({ message }),
676
676
  })
@@ -1,3 +1,4 @@
1
+ import { Option } from 'effect'
1
2
  import { Subscription } from 'foldkit'
2
3
 
3
4
  import { DragAndDrop, Slider, VirtualList } from '@foldkit/ui'
@@ -12,7 +13,7 @@ const dragAndDropSubscriptions = Subscription.lift({
12
13
  dragKeyboard: DragAndDrop.subscriptions.documentKeyboard,
13
14
  autoScroll: DragAndDrop.subscriptions.autoScroll,
14
15
  })<UiModel, UiMessage>({
15
- toChildModel: model => model.dragAndDropDemo,
16
+ read: model => Option.some(model.dragAndDropDemo),
16
17
  toParentMessage: message => UiMessage.GotDragAndDropDemoMessage({ message }),
17
18
  })
18
19
 
@@ -20,7 +21,7 @@ const sliderRatingSubscriptions = Subscription.lift({
20
21
  sliderRatingPointer: Slider.subscriptions.dragPointer,
21
22
  sliderRatingEscape: Slider.subscriptions.dragEscape,
22
23
  })<UiModel, UiMessage>({
23
- toChildModel: model => model.sliderRatingDemo,
24
+ read: model => Option.some(model.sliderRatingDemo),
24
25
  toParentMessage: message => UiMessage.GotSliderRatingDemoMessage({ message }),
25
26
  })
26
27
 
@@ -28,21 +29,21 @@ const sliderVolumeSubscriptions = Subscription.lift({
28
29
  sliderVolumePointer: Slider.subscriptions.dragPointer,
29
30
  sliderVolumeEscape: Slider.subscriptions.dragEscape,
30
31
  })<UiModel, UiMessage>({
31
- toChildModel: model => model.sliderVolumeDemo,
32
+ read: model => Option.some(model.sliderVolumeDemo),
32
33
  toParentMessage: message => UiMessage.GotSliderVolumeDemoMessage({ message }),
33
34
  })
34
35
 
35
36
  const virtualListDemoSubscriptions = Subscription.lift({
36
37
  virtualListContainerEvents: VirtualList.subscriptions.containerEvents,
37
38
  })<UiModel, UiMessage>({
38
- toChildModel: model => model.virtualListDemo,
39
+ read: model => Option.some(model.virtualListDemo),
39
40
  toParentMessage: message => UiMessage.GotVirtualListDemoMessage({ message }),
40
41
  })
41
42
 
42
43
  const virtualListVariableDemoSubscriptions = Subscription.lift({
43
44
  virtualListVariableContainerEvents: VirtualList.subscriptions.containerEvents,
44
45
  })<UiModel, UiMessage>({
45
- toChildModel: model => model.virtualListVariableDemo,
46
+ read: model => Option.some(model.virtualListVariableDemo),
46
47
  toParentMessage: message =>
47
48
  UiMessage.GotVirtualListVariableDemoMessage({ message }),
48
49
  })
@@ -51,7 +52,7 @@ const toastDemoSubscriptions = Subscription.lift(Toast.subscriptions)<
51
52
  UiModel,
52
53
  UiMessage
53
54
  >({
54
- toChildModel: model => model.toastDemo,
55
+ read: model => Option.some(model.toastDemo),
55
56
  toParentMessage: message => UiMessage.GotToastDemoMessage({ message }),
56
57
  })
57
58
 
@@ -1,22 +1,22 @@
1
1
  {
2
2
  "schemaVersion": 1,
3
3
  "channel": "stable",
4
- "sourceCommit": "0b2a4fd04171afa8c8911d22aa9bec2f22faae52",
4
+ "sourceCommit": "7590156835c822a0aa135a5fde14c8e2009c9124",
5
5
  "packages": {
6
- "create-foldkit-app": "0.37.0",
7
- "@foldkit/devtools": "0.165.0",
8
- "@foldkit/devtools-mcp": "0.24.0",
9
- "foldkit": "0.165.0",
10
- "@foldkit/markdown": "0.14.0",
11
- "@foldkit/oxlint-plugin": "0.15.1",
12
- "@foldkit/ui": "0.165.0",
13
- "@foldkit/vite-plugin": "0.26.0"
6
+ "create-foldkit-app": "0.38.0",
7
+ "@foldkit/devtools": "0.166.0",
8
+ "@foldkit/devtools-mcp": "0.24.1",
9
+ "foldkit": "0.166.0",
10
+ "@foldkit/markdown": "0.14.1",
11
+ "@foldkit/oxlint-plugin": "0.15.2",
12
+ "@foldkit/ui": "0.166.0",
13
+ "@foldkit/vite-plugin": "0.26.1"
14
14
  },
15
15
  "dependencies": {
16
- "@foldkit/devtools": "0.165.0",
17
- "@foldkit/vite-plugin": "0.26.0",
18
- "@foldkit/devtools-mcp": "0.24.0",
19
- "@foldkit/oxlint-plugin": "0.15.1",
16
+ "@foldkit/devtools": "0.166.0",
17
+ "@foldkit/vite-plugin": "0.26.1",
18
+ "@foldkit/devtools-mcp": "0.24.1",
19
+ "@foldkit/oxlint-plugin": "0.15.2",
20
20
  "@types/node": "26.4.0",
21
21
  "happy-dom": "^20.14.5",
22
22
  "oxfmt": "^0.66.0",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-foldkit-app",
3
- "version": "0.37.0",
3
+ "version": "0.38.0",
4
4
  "description": "Create Foldkit applications",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",