Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
607bba3
feat(template): port form extensions bundle from production
Guria Sep 7, 2026
5d981a8
docs: add a solved-problems pattern catalog (repo index + site section)
Guria Sep 7, 2026
aae9bd6
docs: surface patterns in site nav; drop migration stamps
Guria Sep 7, 2026
71973f3
docs: widen pattern page; add demo how-to steps
Guria Sep 7, 2026
eebb02b
feat: make the login form the full showcase of the form extensions
Guria Sep 7, 2026
12dce28
feat: port API validation error mapping; demo it through login
Guria Sep 7, 2026
6436c64
feat(demo): settings and article saves rewire onto withSavedState
Guria Sep 7, 2026
b33646d
docs: add server-states-at-the-boundary pattern entry
Guria Sep 7, 2026
f1aea2d
feat: port web-storage persistence adapter
Guria Sep 8, 2026
5dc1f87
test: cover every error-code and api-client branch
Guria Sep 12, 2026
0f62d7a
refactor: decompose extractErrorCode under the CRAP gate
Guria Sep 12, 2026
24e6dbd
refactor: extract SaveFooter to clear the CRAP gate on SettingsPage
Guria Sep 12, 2026
eb744c7
fix: article save rewire actually saves; settings save footer stays r…
Guria Sep 12, 2026
47452b0
test: wait for the chat filter to land before asserting the list
Guria Sep 12, 2026
e8e4c0e
fix: address code review findings on the login alert contract
Guria Sep 12, 2026
a2f3afe
chore: drop stale unused-type suppression now that tests consume the …
Guria Sep 13, 2026
cb2a808
fix: fall back to memory when the localStorage getter throws
Guria Sep 26, 2026
2fcd7e3
fix: keep article edit mode open when an in-flight edit is unsaved
Guria Sep 26, 2026
7f1dd83
fix: trim the login email and let Reatom own form validation
Guria Sep 26, 2026
11e7f77
test: retry the chat list absence check until the filter lands
Guria Sep 26, 2026
80102d8
fix(template): ship the 422 mock the login's field mapping expects
Guria Sep 26, 2026
b55551e
docs: rewrite the pattern entries against the shipped code
Guria Sep 26, 2026
1d4ca2c
chore: pin hk to 1.x
Guria Sep 26, 2026
0acfea8
fix: keep the form alert for a partially mapped 422
Guria Sep 26, 2026
a2ac508
feat: show unmapped 422 issue messages in the login alert
Guria Sep 26, 2026
ebb1be5
chore(template): pin hk to 1.x
Guria Sep 26, 2026
dabf748
site: hide the Patterns nav links outside dev
Guria Sep 26, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .config/mise/conf.d/_config.toml
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ min_version = "2026.4.18" # required for [deps] (renamed from [prepare])
node = "lts"
jq = "latest"
vp = "latest"
hk = "latest"
hk = "1" # hk 2 cannot evaluate a config that amends the 1.x Config.pkl

[task_config]
dir = "{{ config_root }}"
Expand Down
1 change: 0 additions & 1 deletion apps/demo/messages/en/articles.json
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,6 @@
"article_search_placeholder": "Search articles...",
"article_loading_detail": "Loading article detail",
"article_saving": "Saving…",
"article_saved": "Article saved",
"article_save_error": "Couldn't save the article. Try again.",
"article_cancel": "Cancel",
"article_save": "Save",
Expand Down
3 changes: 3 additions & 0 deletions apps/demo/messages/en/auth.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,9 @@
"login_description": "Use your workspace credentials to continue.",
"login_email": "Email",
"login_password": "Password",
"login_email_required": "Email is required",
"login_email_invalid": "Email doesn't look right",
"login_password_required": "Password is required",
"login_submit": "Sign in",
"login_signing_in": "Signing in",
"login_error_title": "Could not sign in",
Expand Down
2 changes: 0 additions & 2 deletions apps/demo/messages/en/settings.json
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,5 @@
"settings_error_title": "Could not load settings",
"settings_error_description": "We couldn't load your settings. Try again in a moment.",
"settings_saving": "Saving…",
"settings_profile_saved": "Profile saved",
"settings_notifications_saved": "Notification preferences saved",
"settings_save_error": "Couldn't save. Try again."
}
1 change: 0 additions & 1 deletion apps/demo/messages/es/articles.json
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,6 @@
"article_search_placeholder": "Buscar artículos...",
"article_loading_detail": "Cargando detalle del artículo",
"article_saving": "Guardando…",
"article_saved": "Artículo guardado",
"article_save_error": "No se pudo guardar el artículo. Inténtalo de nuevo.",
"article_cancel": "Cancelar",
"article_save": "Guardar",
Expand Down
3 changes: 3 additions & 0 deletions apps/demo/messages/es/auth.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,9 @@
"login_description": "Usa tus credenciales del espacio de trabajo para continuar.",
"login_email": "Correo electrónico",
"login_password": "Contraseña",
"login_email_required": "El correo electrónico es obligatorio",
"login_email_invalid": "El correo no parece válido",
"login_password_required": "La contraseña es obligatoria",
"login_submit": "Iniciar sesión",
"login_signing_in": "Iniciando sesión",
"login_error_title": "No se pudo iniciar sesión",
Expand Down
2 changes: 0 additions & 2 deletions apps/demo/messages/es/settings.json
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,5 @@
"settings_error_title": "No se pudieron cargar los ajustes",
"settings_error_description": "No pudimos cargar tus ajustes. Inténtalo de nuevo en un momento.",
"settings_saving": "Guardando…",
"settings_profile_saved": "Perfil guardado",
"settings_notifications_saved": "Preferencias de notificaciones guardadas",
"settings_save_error": "No se pudo guardar. Inténtalo de nuevo."
}
14 changes: 6 additions & 8 deletions apps/demo/src/app/integration/Articles.detail.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -174,7 +174,7 @@ EditArticle.test('saving updates the title and returns to read mode', async () =
await I.openEdit()
await I.fill(role('textbox', 'Title'), 'Updated quarterly report')
await I.saveArticle()
await I.seeArticleSavedToast()
await I.seeArticleSaved()
await I.see(button('Edit'))
await I.see(heading('Updated quarterly report'))
})
Expand All @@ -185,11 +185,9 @@ EditArticle.test('changing status persists and reflects on return', async () =>
await I.openEdit()
await I.selectOption(role('combobox'), 'In Progress')
await I.saveArticle()
await I.seeArticleSavedToast()
// A prior save test in this story leaves a stale "Article saved" toast
// in the global toaster, so `seeArticleSavedToast` can resolve against it
// before this save transitions to read mode. Wait for the real transition.
await I.retryTo(() => I.see(button('Edit')), 25)
// The edit panel collapsing to the summary rows is what confirms the
// save — there is no toast to observe.
await I.seeArticleSaved()
await I.seeArticleDetailStatus('In Progress')
})
})
Expand All @@ -204,13 +202,13 @@ export const EditArticleServerError = meta.story({
})

EditArticleServerError.test(
'save failure shows an error toast and stays in edit mode',
'save failure shows an inline alert and stays in edit mode',
async () => {
await I.scope(role('main'), async () => {
await I.openEdit()
await I.fill(role('textbox', 'Title'), 'Will not save')
await I.saveArticle()
await I.seeArticleSaveErrorToast()
await I.seeArticleSaveError()
await I.see(role('textbox', 'Title'))
})
},
Expand Down
28 changes: 11 additions & 17 deletions apps/demo/src/app/integration/Settings.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -92,8 +92,7 @@ EditProfileShowsSave.test('save button disappears after saving', async () => {
await I.fill(role('textbox', 'Display name'), 'Jane Doe')
await I.see(button('Save changes'))
await I.saveProfile()
await I.seeProfileSavedToast()
await I.dontSee(button('Save changes'))
await I.savedProfileClearsDirty(loc.profileForm)
})

export const ToggleSwitches = meta.story({ name: 'Toggle Switches', play: waitForLoad })
Expand Down Expand Up @@ -246,12 +245,11 @@ RetrySuccess.test('loads settings after retry succeeds', async () => {

export const SaveProfileSuccess = meta.story({ name: 'Save Profile', play: waitForLoad })

SaveProfileSuccess.test('saving profile shows success toast and clears dirty', async () => {
SaveProfileSuccess.test('saving profile clears dirty and keeps the saved values', async () => {
await I.fill(role('textbox', 'Display name'), 'Jane Doe')
await I.see(button('Save changes'))
await I.saveProfile()
await I.seeProfileSavedToast()
await I.dontSee(button('Save changes'))
await I.savedProfileClearsDirty(loc.profileForm)
await I.seeInField(role('textbox', 'Display name'), 'Jane Doe')
})

Expand All @@ -260,16 +258,12 @@ export const SaveNotificationsSuccess = meta.story({
play: waitForLoad,
})

SaveNotificationsSuccess.test(
'saving notifications shows success toast and clears dirty',
async () => {
await I.selectOption(role('combobox', 'Email notifications'), 'Important only')
await I.see(button('Save changes'))
await I.saveNotifications()
await I.seeNotificationsSavedToast()
await I.dontSee(button('Save changes'))
},
)
SaveNotificationsSuccess.test('saving notifications clears dirty', async () => {
await I.selectOption(role('combobox', 'Email notifications'), 'Important only')
await I.see(button('Save changes'))
await I.saveNotifications()
await I.savedProfileClearsDirty(loc.notificationsForm)
})

export const SaveProfileError = meta.story({
name: 'Save Profile Error',
Expand All @@ -280,9 +274,9 @@ export const SaveProfileError = meta.story({
},
})

SaveProfileError.test('save server error shows error toast and keeps dirty', async () => {
SaveProfileError.test('save server error shows an inline alert and keeps dirty', async () => {
await I.fill(role('textbox', 'Display name'), 'Jane Doe')
await I.saveProfile()
await I.seeSaveErrorToast()
await I.seeSaveError(loc.profileForm)
await I.see(button('Save changes'))
})
8 changes: 7 additions & 1 deletion apps/demo/src/entities/auth/mocks/handlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { HttpResponse, delay, http } from 'msw'

import { AUTH_LOGIN_API_PATH, AUTH_LOGOUT_API_PATH } from '#entities/auth/api/authApi'
import { composeApiUrl } from '#shared/api'
import { to400, to500 } from '#shared/mocks/utils'
import { to400, to422, to500 } from '#shared/mocks/utils'

import { authMockSession } from './data'

Expand All @@ -13,6 +13,12 @@ export const authHandlers = {
login: http.post(loginUrl, async ({ request }) => {
await delay()
const body = (await request.json()) as { email?: string; password?: string }
// A server-side uniqueness check is the one credential rule the client
// cannot pre-validate — it demonstrates a validation error that maps
// onto a form field.
if (body.email === 'taken@example.com') {
to422([{ loc: ['body', 'email'], msg: 'This email is already registered' }])
}
if (body.email !== authMockSession.user.email || body.password !== 'password') {
return to400('Invalid email or password')
}
Expand Down
8 changes: 1 addition & 7 deletions apps/demo/src/entities/setting/index.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,2 @@
export { fetchSettings, updateNotifications, updateProfile } from './api/settingsApi'
export type {
DesktopNotification,
EmailNotification,
NotificationSettings,
ProfileSettings,
SettingsData,
} from './model/types'
export type { DesktopNotification, EmailNotification, SettingsData } from './model/types'
67 changes: 67 additions & 0 deletions apps/demo/src/pages/articles/model/articleDetailModel.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
import type { Article } from '#entities/article'

import { context } from '@reatom/core'
import { afterEach, expect, test, vi } from 'vite-plus/test'

import { updateArticle } from '#entities/article'

import { reatomArticleDetailModel } from './articleDetailModel'

vi.mock('#entities/article', () => ({ updateArticle: vi.fn() }))

afterEach(() => {
context.reset()
vi.mocked(updateArticle).mockReset()
})

// `submit.onFulfill` hooks run in the next cleanup-queue tick.
const flush = () => new Promise<void>((resolve) => setTimeout(resolve, 0))

const article: Article = {
id: 'a1',
title: 'Original',
description: 'Description',
status: 'draft',
content: [],
}

const respondWith = (release: Promise<void>) =>
vi.mocked(updateArticle).mockImplementation(async (id, values) => {
await release
return { id, ...values }
})

test('a save with nothing typed during the request closes edit mode', async () => {
respondWith(Promise.resolve())
const model = reatomArticleDetailModel(article)
model.startEdit()

model.form.fields.title.change('Saved')
await model.form.submit()
await flush()

expect(model.current().title).toBe('Saved')
expect(model.isEditing()).toBe(false)
})

test('an edit typed while the save is in flight keeps edit mode open', async () => {
let release: (() => void) | undefined
respondWith(
new Promise<void>((resolve) => {
release = resolve
}),
)
const model = reatomArticleDetailModel(article)
model.startEdit()

model.form.fields.title.change('Sent')
const submitted = model.form.submit()
model.form.fields.title.change('Typed during save')
release?.()
await submitted
await flush()

expect(model.current().title).toBe('Sent')
expect(model.form.fields.title.value()).toBe('Typed during save')
expect(model.isEditing()).toBe(true)
})
73 changes: 23 additions & 50 deletions apps/demo/src/pages/articles/model/articleDetailModel.ts
Original file line number Diff line number Diff line change
@@ -1,38 +1,9 @@
import type { Article } from '#entities/article'

import {
abortVar,
action,
atom,
framePromise,
reatomForm,
sleep,
withAbort,
withAsync,
wrap,
} from '@reatom/core'
import { abortVar, action, atom, reatomForm, wrap } from '@reatom/core'

import { updateArticle } from '#entities/article'
import { m } from '#paraglide/messages.js'
import { toaster } from '#shared/components'

const SAVE_DELAY_MS = 300

async function saveWithToast(articleId: string, values: Omit<Article, 'id'>) {
const id = toaster.create({ title: m.article_saving(), type: 'loading', closable: false })
void framePromise().catch(() => {})
try {
const updated = await wrap(
updateArticle(articleId, values, { signal: abortVar.require().signal }),
)
await wrap(sleep(SAVE_DELAY_MS))
toaster.update(id, { title: m.article_saved(), type: 'success' })
return updated
} catch (error) {
toaster.remove(id)
throw error
}
}
import { withSavedState } from '#shared/reatom'

export function reatomArticleDetailModel(article: Article) {
const id = article.id
Expand All @@ -48,8 +19,27 @@ export function reatomArticleDetailModel(article: Article) {
},
{
name: `article.${id}.editForm`,
onSubmit: async (values) => await wrap(saveWithToast(id, values)),
onSubmit: async (values) => {
const updated = await wrap(updateArticle(id, values, { signal: abortVar.require().signal }))
// The summary rows move when the request lands; withSavedState then
// rebaselines the form and its onSaved collapses back to them —
// seeing the new values on the rows is what confirms the save.
current.set(updated)
// withSavedState rebaselines through form.init, whose keys must be
// field names exactly: returning the full Article (with `id`) would
// throw `Field id not found in fields`.
const { id: _, ...fields } = updated
return fields
},
},
).extend((target) =>
withSavedState({
// An edit typed while the request was in flight keeps the form dirty;
// collapsing would hide it behind rows showing the saved values.
onSaved: () => {
if (!target.focus().dirty) isEditing.set(false)
},
})(target),
)

const startEdit = action(() => {
Expand All @@ -62,24 +52,7 @@ export function reatomArticleDetailModel(article: Article) {
isEditing.set(true)
}, `article.${id}.startEdit`)

const save = action(async () => {
if (!form.focus().dirty) return
try {
const updated = await wrap(form.submit())
current.set(updated)
form.init({
title: updated.title,
description: updated.description,
status: updated.status,
content: updated.content,
})
isEditing.set(false)
} catch {
toaster.create({ title: m.article_save_error(), type: 'error' })
}
}, `article.${id}.save`).extend(withAbort(), withAsync())

return { id, current, isEditing, form, startEdit, save }
return { id, current, isEditing, form, startEdit }
}

export type ArticleDetailModel = ReturnType<typeof reatomArticleDetailModel>
19 changes: 8 additions & 11 deletions apps/demo/src/pages/articles/testing.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ const ARTICLE_LINKS = [
] as const

const editLoc = {
editForm: role('form', m.article_detail()),
editButton: button('Edit'),
saveButton: button('Save'),
cancelButton: button('Cancel'),
Expand Down Expand Up @@ -130,17 +131,13 @@ export const articlesActor = createActor()
saveArticle: async () => {
await I.click(editLoc.saveButton)
},
seeArticleSavedToast: async () => {
// Require the current save's loading toast before accepting success, so a
// stale global success toast from another story/test cannot satisfy this helper.
await I.retryTo(() => I.see(role('status', 'Saving…').within('global')), 25)
await I.retryTo(() => I.see(role('status', 'Article saved').within('global')), 25)
},
seeArticleSaveErrorToast: async () => {
await I.retryTo(
() => I.see(text("Couldn't save the article. Try again.").within('global')),
25,
)
// A save is confirmed by the state change itself: the edit panel collapses
// to the summary rows, which now state the saved values — there is no toast.
seeArticleSaved: async () => {
await I.retryTo(() => I.see(editLoc.editButton), 25)
},
seeArticleSaveError: async () => {
await I.retryTo(() => I.see(role('alert').within(editLoc.editForm)), 25)
},
seeTitleIs: async (value: string) => {
await I.retryTo(() => I.seeInField(editLoc.titleField, value), 25)
Expand Down
Loading
Loading