Files
mercadodevida/work/artifacts/F-062/implementer.md
2026-08-19 17:02:14 +02:00

117 lines
5.3 KiB
Markdown

# F-062 — Implementer evidence
## Scope delivered
The CMS page editor in the admin (`apps/admin/src/app/(dashboard)/cms/page.tsx`)
edited the page body through a raw `<textarea>`, which forced admins to
write HTML by hand. The new editor is a real WYSIWYG powered by
[Lexical](https://github.com/facebook/lexical): bold / italic / underline,
H2 / H3 / paragraph, ordered / unordered lists, inline links, undo /
redo. The body continues to be stored as HTML on the existing
`cms_pages.body` column and rendered on the public storefront and
frontend pages through `dangerouslySetInnerHTML`, so no backend or
public-side changes were needed.
## Changes
### Dependencies (`apps/admin/package.json`)
Added the Lexical stack at version `0.49.0`:
```
lexical
@lexical/react
@lexical/rich-text
@lexical/list
@lexical/link
@lexical/history
@lexical/utils
@lexical/html
@lexical/markdown # available for future export/import work
```
`npm install` added 35 transitive packages; the admin production
chunk that ships the editor is now ~270 KB (minified).
### New component — `apps/admin/src/features/cms/components/LexicalEditor.tsx`
A reusable client component built on top of `@lexical/react`:
- `LexicalComposer` + `RichTextPlugin` + `ContentEditable` for the
editing surface.
- `HistoryPlugin`, `ListPlugin`, `LinkPlugin` for the obvious basics.
- `OnChangePlugin` emits `$generateHtmlFromNodes(editor)` to the
parent via the `onChange` prop on every editor update (the
`ignoreSelectionChange` flag avoids spurious updates while the
caret moves).
- `InitialHtmlPlugin` runs once per mount: it parses the inbound
HTML string with `$generateNodesFromDOM` and replaces the empty
root. Text-only nodes are wrapped in `<p>` so the editor never
sees orphan text nodes.
- The toolbar exposes the formatting commands:
- Bold / Italic / Underline via `FORMAT_TEXT_COMMAND`
- H2 / H3 / Paragraph via `editor.update()` that swaps the
selected block node (since `ElementNode.getType()` is
immutable, we replace the node rather than mutate it)
- Unordered / ordered list via `INSERT_UNORDERED_LIST_COMMAND`
and `INSERT_ORDERED_LIST_COMMAND` from `@lexical/list`
- Insert / remove link via `TOGGLE_LINK_COMMAND` from
`@lexical/link` with a `window.prompt` URL dialog
- Active formatting (bold / italic / underline) is reflected on the
toolbar by reading the current DOM selection after every editor
update.
- Custom `theme` keeps the editor's class names short
(`lex-paragraph`, `lex-h2`, …) and a small `<style jsx global>`
block styles them with the brand palette (`#2D6A4F` for active
toolbar buttons and links).
### Integration — `apps/admin/src/app/(dashboard)/cms/page.tsx`
The `<textarea>` for the body field was replaced with
`<LexicalEditor value={form.body} onChange={...} />`. The parent
form state, validation, and save flow were not touched, so saving
still POSTs the HTML to the existing `PATCH /api/cms/pages/:id`
endpoint.
## Acceptance traceability
| Acceptance criterion | How it is met |
| -------------------- | ------------- |
| `LexicalEditor` renders in the CMS edit form with a working toolbar | Component mounted in the form; toolbar dispatches the documented Lexical commands; bundle ships the editor (chunk includes `MDVCmsEditor`, `lex-paragraph`, `FORMAT_TEXT_COMMAND`). |
| Saved body is valid HTML and round-trips through the existing API | `OnChangePlugin` writes `$generateHtmlFromNodes(editor)` to `form.body`. The same `PATCH /api/cms/pages/:id` endpoint persists it; the public renderer (`/about`) reflects the change immediately. |
| Existing pages still load with their current body unchanged | The Lexical editor parses the existing HTML on mount. Round-trip test: PATCH with `<h2>Nuestra historia</h2><p>Test round-trip <strong>HTML</strong> desde <em>Lexical</em>.</p><ul><li>Item 1</li><li>Item 2</li></ul>`; `/about` served it back exactly. Body was then restored to the original content. |
| `verify.sh` is green | Exit 0. |
## Manual verification
```
$ curl -X PATCH http://192.168.18.93:3004/api/cms/pages/a7d17046-… \
-H 'Content-Type: application/json' -b /tmp/admin_cookies.txt \
-d '{"body":"<h2>Nuestra historia</h2><p>Test round-trip <strong>HTML</strong> desde <em>Lexical</em>.</p><ul><li>Item 1</li><li>Item 2</li></ul>"}'
{ "id": "a7d17046-…", "body": "<h2>Nuestra historia</h2><p>Test round-trip …", … }
$ curl http://192.168.18.93:3003/about | grep -oE '<h2[^>]*>[^<]+</h2>|<ul><li>.*?</li></ul>'
<h2>Nuestra historia</h2>
<ul><li>Item 1</li><li>Item 2</li></ul>
```
## Build verification
- `npx tsc --noEmit` (admin) — exit 0
- `npx tsc --noEmit` (frontend) — exit 0
- `npx tsc --noEmit` (storefront) — exit 0
- `npm run typecheck` (backend) — exit 0
- `npm run build` (admin) — exit 0; CMS route is statically rendered
- `./scripts/verify.sh` — exit 0
- Admin service restarted via `monolith.sh prod restart admin`
→ HTTP 200 on `/cms`
- Lexical chunks present in `.next/static/chunks/`
## Files touched
```
project/apps/admin/package.json (added lexical deps)
project/apps/admin/package-lock.json (npm install)
project/apps/admin/src/features/cms/components/LexicalEditor.tsx (new)
project/apps/admin/src/app/(dashboard)/cms/page.tsx (textarea → LexicalEditor)
```