|
1 | 1 | import { type FC } from 'react'; |
2 | 2 | import { useDispatch } from 'react-redux'; |
3 | | -import { Layout, type UsecaseDescription } from '~usecases/lib/components/todo/Layout'; |
4 | 3 |
|
| 4 | +import type { TransitionAction } from '~transitions'; |
| 5 | + |
| 6 | +import { ActivityFeed } from '~usecases/lib/components/activity/ActivityFeed'; |
| 7 | +import { ProfileCard } from '~usecases/lib/components/profile/ProfileCard'; |
| 8 | +import { ProjectBoard } from '~usecases/lib/components/projects/ProjectBoard'; |
| 9 | +import { Layout, type UsecaseDescription } from '~usecases/lib/components/todo/Layout'; |
5 | 10 | import { TodoApp } from '~usecases/lib/components/todo/TodoApp'; |
6 | | -import { createTodo, deleteTodo, editTodo } from '~usecases/lib/store/actions'; |
7 | | -import type { Todo } from '~usecases/lib/store/types'; |
| 11 | +import { useAutoRetry } from '~usecases/lib/hooks/useAutoRetry'; |
| 12 | +import { dismissActivity, editActivity, logActivity } from '~usecases/lib/store/activity/actions'; |
| 13 | +import { createEpic, deleteEpic, editEpic } from '~usecases/lib/store/epics/actions'; |
| 14 | +import { updateProfile } from '~usecases/lib/store/profile/actions'; |
| 15 | +import { createProjectTodo, deleteProjectTodo, editProjectTodo } from '~usecases/lib/store/projects/actions'; |
| 16 | +import type { ActivityEntry, Epic, Profile, ProjectTodo } from '~usecases/lib/store/types'; |
8 | 17 | import { generateId, simulateAPIRequest } from '~usecases/lib/utils/mock-api'; |
9 | 18 |
|
10 | | -import { C, F, O, X } from '~usecases/lib/components/todo/CodeTags'; |
| 19 | +import { C, F, O } from '~usecases/lib/components/todo/CodeTags'; |
11 | 20 |
|
12 | 21 | const description: UsecaseDescription = { |
13 | | - subtitle: 'Component-level async — the simplest optimistic pattern.', |
| 22 | + subtitle: 'Component-level async — full transition lifecycle managed in the component.', |
14 | 23 | howItWorks: [ |
15 | | - <>Component dispatches <O>stage</O>, then awaits the API call.</>, |
16 | | - <>On success: <O>amend</O> (server ID) → <C>commit</C>. On failure: <F>fail</F>.</>, |
17 | | - <>Optimistic state is computed at the selector level — no state copies.</>, |
18 | | - ], |
19 | | - tryIt: [ |
20 | | - <>Add a todo online — appears instantly (<O>opt</O>), then <C>commits</C>.</>, |
21 | | - <>Toggle offline, add a todo — it <F>fails</F> with a jiggle.</>, |
22 | | - <>Toggle back online — <F>failed</F> todos auto-retry.</>, |
23 | | - <>Click "Sync API" — observe <X>conflict</X> detection.</>, |
| 24 | + <>Component dispatches <O>stage</O>, awaits the API, then <O>amend</O>s / <C>commit</C>s or <F>fail</F>s directly.</>, |
| 25 | + <>The full lifecycle (<O>stage</O> → API → <O>amend</O> → <C>commit</C> / <F>fail</F>) lives in the handler function — maximum visibility, minimum indirection.</>, |
| 26 | + <>Optimistic state is computed at the selector level via <code className="text-gray-400 text-[11px]">selectOptimistic</code> — no state copies, no checkpoints.</>, |
| 27 | + <>Retry routes the failed stage action back through the same handler — pattern matching on <code className="text-gray-400 text-[11px]">action.match()</code>.</>, |
24 | 28 | ], |
25 | 29 | }; |
26 | 30 |
|
27 | 31 | export const App: FC = () => { |
28 | 32 | const dispatch = useDispatch(); |
29 | 33 |
|
30 | | - const handleCreate = async (todo: Todo) => { |
31 | | - const transitionId = todo.id; |
| 34 | + const handleCreateEpic = async (epic: Epic) => { |
| 35 | + const transitionId = epic.id; |
| 36 | + try { |
| 37 | + dispatch(createEpic.stage(epic)); |
| 38 | + await simulateAPIRequest(); |
| 39 | + dispatch(createEpic.amend(transitionId, { ...epic, id: generateId() })); |
| 40 | + dispatch(createEpic.commit(transitionId)); |
| 41 | + } catch (error) { |
| 42 | + dispatch(createEpic.fail(transitionId, error)); |
| 43 | + } |
| 44 | + }; |
| 45 | + |
| 46 | + const handleEditEpic = async (epic: Epic) => { |
| 47 | + const transitionId = epic.id; |
| 48 | + try { |
| 49 | + dispatch(editEpic.stage(epic.id, epic)); |
| 50 | + await simulateAPIRequest(); |
| 51 | + dispatch(editEpic.commit(transitionId)); |
| 52 | + } catch (error) { |
| 53 | + dispatch(editEpic.fail(transitionId, error)); |
| 54 | + } |
| 55 | + }; |
| 56 | + |
| 57 | + const handleDeleteEpic = async (epic: Epic) => { |
| 58 | + const transitionId = epic.id; |
| 59 | + try { |
| 60 | + dispatch(deleteEpic.stage(epic.id)); |
| 61 | + await simulateAPIRequest(); |
| 62 | + dispatch(deleteEpic.commit(transitionId)); |
| 63 | + } catch (error) { |
| 64 | + dispatch(deleteEpic.stash(transitionId)); |
| 65 | + } |
| 66 | + }; |
| 67 | + |
| 68 | + const handleUpdateProfile = async (update: Partial<Profile>) => { |
| 69 | + try { |
| 70 | + dispatch(updateProfile.stage(update)); |
| 71 | + await simulateAPIRequest(); |
| 72 | + dispatch(updateProfile.commit('profile')); |
| 73 | + } catch (error) { |
| 74 | + dispatch(updateProfile.fail('profile', error)); |
| 75 | + } |
| 76 | + }; |
| 77 | + |
| 78 | + const handleCreateProjectTodo = async (todo: ProjectTodo) => { |
| 79 | + const transitionId = `${todo.projectId}/${todo.id}`; |
| 80 | + try { |
| 81 | + dispatch(createProjectTodo.stage(todo)); |
| 82 | + await simulateAPIRequest(); |
| 83 | + dispatch(createProjectTodo.amend(transitionId, { ...todo, id: generateId() })); |
| 84 | + dispatch(createProjectTodo.commit(transitionId)); |
| 85 | + } catch (error) { |
| 86 | + dispatch(createProjectTodo.fail(transitionId, error)); |
| 87 | + } |
| 88 | + }; |
32 | 89 |
|
| 90 | + const handleEditProjectTodo = async (todo: ProjectTodo) => { |
| 91 | + const transitionId = `${todo.projectId}/${todo.id}`; |
33 | 92 | try { |
34 | | - dispatch(createTodo.stage(todo)); |
| 93 | + dispatch(editProjectTodo.stage(todo.projectId, todo.id, todo)); |
35 | 94 | await simulateAPIRequest(); |
| 95 | + dispatch(editProjectTodo.commit(transitionId)); |
| 96 | + } catch (error) { |
| 97 | + dispatch(editProjectTodo.fail(transitionId, error)); |
| 98 | + } |
| 99 | + }; |
36 | 100 |
|
37 | | - dispatch(createTodo.amend(transitionId, { ...todo, id: generateId() })); |
38 | | - dispatch(createTodo.commit(transitionId)); |
| 101 | + const handleDeleteProjectTodo = async (todo: ProjectTodo) => { |
| 102 | + const transitionId = `${todo.projectId}/${todo.id}`; |
| 103 | + try { |
| 104 | + dispatch(deleteProjectTodo.stage(todo.projectId, todo.id)); |
| 105 | + await simulateAPIRequest(); |
| 106 | + dispatch(deleteProjectTodo.commit(transitionId)); |
39 | 107 | } catch (error) { |
40 | | - dispatch(createTodo.fail(transitionId, error)); |
| 108 | + dispatch(deleteProjectTodo.stash(transitionId)); |
41 | 109 | } |
42 | 110 | }; |
43 | 111 |
|
44 | | - const handleEdit = async (todo: Todo) => { |
45 | | - const transitionId = todo.id; |
| 112 | + const handleLogActivity = async (entry: ActivityEntry) => { |
| 113 | + const transitionId = entry.id; |
| 114 | + try { |
| 115 | + dispatch(logActivity.stage(entry)); |
| 116 | + await simulateAPIRequest(); |
| 117 | + dispatch(logActivity.amend(transitionId, { ...entry, id: generateId() })); |
| 118 | + dispatch(logActivity.commit(transitionId)); |
| 119 | + } catch (error) { |
| 120 | + dispatch(logActivity.fail(transitionId, error)); |
| 121 | + } |
| 122 | + }; |
46 | 123 |
|
| 124 | + const handleEditActivity = async (entry: ActivityEntry) => { |
| 125 | + const transitionId = entry.id; |
47 | 126 | try { |
48 | | - dispatch(editTodo.stage(todo.id, todo)); |
| 127 | + dispatch(editActivity.stage(entry.id, entry)); |
49 | 128 | await simulateAPIRequest(); |
50 | | - dispatch(editTodo.commit(transitionId)); |
| 129 | + dispatch(editActivity.commit(transitionId)); |
51 | 130 | } catch (error) { |
52 | | - dispatch(editTodo.fail(transitionId, error)); |
| 131 | + dispatch(editActivity.fail(transitionId, error)); |
53 | 132 | } |
54 | 133 | }; |
55 | 134 |
|
56 | | - const handleDelete = async (todo: Todo) => { |
57 | | - const transitionId = todo.id; |
| 135 | + const handleDismissActivity = async (entry: ActivityEntry) => { |
| 136 | + const transitionId = entry.id; |
58 | 137 | try { |
59 | | - dispatch(deleteTodo.stage(todo.id)); |
| 138 | + dispatch(dismissActivity.stage(entry.id)); |
60 | 139 | await simulateAPIRequest(); |
61 | | - dispatch(deleteTodo.commit(transitionId)); |
| 140 | + dispatch(dismissActivity.commit(transitionId)); |
62 | 141 | } catch (error) { |
63 | | - dispatch(deleteTodo.stash(transitionId)); |
| 142 | + dispatch(dismissActivity.stash(transitionId)); |
64 | 143 | } |
65 | 144 | }; |
66 | 145 |
|
| 146 | + /** Route a failed stage action through the correct lifecycle handler */ |
| 147 | + const retryTransition = (action: TransitionAction) => { |
| 148 | + if (createEpic.stage.match(action)) return handleCreateEpic(action.payload.item); |
| 149 | + if (editEpic.stage.match(action)) return handleEditEpic(action.payload.item as Epic); |
| 150 | + if (updateProfile.stage.match(action)) return handleUpdateProfile(action.payload.item); |
| 151 | + if (createProjectTodo.stage.match(action)) return handleCreateProjectTodo(action.payload.item); |
| 152 | + if (editProjectTodo.stage.match(action)) return handleEditProjectTodo(action.payload.item as ProjectTodo); |
| 153 | + if (logActivity.stage.match(action)) return handleLogActivity(action.payload.item); |
| 154 | + if (editActivity.stage.match(action)) return handleEditActivity(action.payload.item as ActivityEntry); |
| 155 | + }; |
| 156 | + |
| 157 | + useAutoRetry(retryTransition); |
| 158 | + |
67 | 159 | return ( |
68 | 160 | <Layout title="Basic" description={description}> |
69 | | - <TodoApp onCreateTodo={handleCreate} onEditTodo={handleEdit} onDeleteTodo={handleDelete} /> |
| 161 | + <ProfileCard onUpdate={handleUpdateProfile} /> |
| 162 | + <div className="grad-h my-1" /> |
| 163 | + <TodoApp onCreateTodo={handleCreateEpic} onEditTodo={handleEditEpic} onDeleteTodo={handleDeleteEpic} onRetry={retryTransition} /> |
| 164 | + <div className="grad-h my-1" /> |
| 165 | + <ProjectBoard |
| 166 | + onCreateTodo={handleCreateProjectTodo} |
| 167 | + onEditTodo={handleEditProjectTodo} |
| 168 | + onDeleteTodo={handleDeleteProjectTodo} |
| 169 | + /> |
| 170 | + <div className="grad-h my-1" /> |
| 171 | + <ActivityFeed |
| 172 | + onLogActivity={handleLogActivity} |
| 173 | + onEditActivity={handleEditActivity} |
| 174 | + onDismissActivity={handleDismissActivity} |
| 175 | + onRetry={retryTransition} |
| 176 | + /> |
| 177 | + <div className="h-4" /> |
70 | 178 | </Layout> |
71 | 179 | ); |
72 | 180 | }; |
0 commit comments