feat(newsletters): native block-composer editor with template selection - #1201
feat(newsletters): native block-composer editor with template selection#1201dealako wants to merge 3 commits into
Conversation
…on (#1133) * feat(newsletters): add shared layout and manifest types Add NewsletterLayout, block/brick instances, and template-manifest interfaces, plus an optional body_layout on the newsletter DTOs, for the spec-004 editor. LFXV2-2377 Signed-off-by: Dan Baker <me@danb.co> * feat(newsletters): add block-composer editor shell + manifest First increment of the native-Angular Puck-style block editor: a block-composer component (palette + CDK drag-drop canvas with recursive container nesting) emitting a NewsletterLayout; a block manifest generated from the real templates (scripts/build-newsletter-manifest.mjs -> public/assets/newsletter-block-manifest.json, 16 blocks); a manifest loader service; and a viewable /newsletters/composer-preview route. Additive — the existing content-step/bodyHtml wizard flow is untouched. Phase-1 blocks carry empty content (per-block field editing is a later ticket). LFXV2-2381 Signed-off-by: Dan Baker <me@danb.co> * feat(newsletters): add per-block field editing to the composer Adds block selection + a Fields panel that renders one control per field from the block's manifest schema (text/textarea/richtext via Tiptap/number/array), two-way bound to the block content; edits patch the block immutably and re-emit the layout. Typed the field-schema interfaces and added a humanizeFieldKey util. LFXV2-2382 Signed-off-by: Dan Baker <me@danb.co> * feat(newsletters): render composer blocks visually (client-side) First-pass in-canvas visual rendering: each block renders styled like the real email (gatewaze block styles ported from gatewaze-modules _shared.ts), inside the wrapper chrome, with a floating per-block toolbar (select/drag/remove) and reactive re-render on Fields-panel edits. New newsletter-renderer.service parses + binds the declarative templates (now bundled into the manifest); content injected via bypassSecurityTrustHtml (authenticated author's own content, allowlisted inert HTML subset). Refinements pending: banner image, rich-text body styling, responsive card width. LFXV2-2381 Signed-off-by: Dan Baker <me@danb.co> * feat(newsletters): editor rail, Blocks/Fields auto-switch, banner block Reworks the composer toward Gatewaze-Puck parity: a left icon rail (Blocks/Fields/Outline) where selecting a block auto-switches to the Fields editor with a 'Page > <block>' breadcrumb; an Outline tab; per-block padding/margin spacing controls applied to the rendered preview; and a logo_header banner block (real MLOps logo + brand label). Also fixes rendered-block styling: the preview classes now pierce view encapsulation (:host ::ng-deep) so the [innerHTML]-injected blocks pick up the card border/eyebrow/title/body styles. LFXV2-2381 Signed-off-by: Dan Baker <me@danb.co> * feat(newsletters): inline block editing + persistent fields sidebar Inline-edit blocks directly on the canvas and keep the Blocks library open while editing: - contentEditable inline editing with a floating toolbar (B/I/U/link), cursor-stable via a per-block render-freeze + commit-on-blur. - Empty inline-editable fields render a clickable placeholder (data-nl-placeholder + a CSS :empty rule) so a freshly-dragged block is editable without opening the panel. - Per-item editing inside each= arrays (e.g. job-of-week): markers carry the indexed path (jobs.0.company) and commit writes into the nested array. - Two-way canvas<->fields-panel sync: an inline edit patches the panel form in place (not a rebuild) so the Tiptap rich-editor reflects external changes. - Move the Fields editor out of the left rail into a persistent right sidebar that swaps on selection, so the Blocks library stays open alongside it. Signed-off-by: Dan Baker <me@danb.co> * fix(review): harden setAtPath paths and inline-link URLs Address general code-review findings on the inline-editing commit: - setAtPath rejects reserved path segments (__proto__/constructor/prototype) so a crafted field/each key can't pollute Object.prototype. - setAtPath now creates a missing container as an array when the next segment is numeric (was always an object), matching its documented array indexing. - applyLink gates the entered URL through the shared isValidUrl, blocking javascript:/data:/vbscript: schemes from being stored in the richtext. Signed-off-by: Dan Baker <me@danb.co> * fix(review): validate passthrough URLs, drop template fn calls Address the full-branch reviewer-trio sweep findings: - CRITICAL (learnings): the renderer's passthrough() now gates href/src through the shared isValidUrl, dropping javascript:/data: values bound from content fields. Only the inline-link path was validated before; this output is bypassSecurityTrustHtml'd, so Angular's URL sanitizer never runs on it. - conventions: replace template method calls renderedBlock()/blockSpacingStyle() with computed-map reads (renderedBlocks().get(id) / blockSpacingStyles().get(id)) per the frontend checklist's no-template-functions rule. - tests: add an isValidUrl unit spec (the shared gate both URL paths rely on). - remove the dead 'fields' member from NewsletterComposerTab (Fields moved to the persistent right sidebar). - document the deliberate effect() in the fields panel (imperative form/Tiptap orchestration, the checklist's allowed exception). Signed-off-by: Dan Baker <me@danb.co> * feat(newsletters): wire block composer into creation wizard Replace the rich-text editor in the wizard Content step with the block composer (LFXV2-2381), so the ED composes a structured body_layout instead of raw HTML. body_layout is now the authored source of truth; body_html is derived server-side (render-on-write) and synced back on save so the preview drawer and test-send use the authoritative MJML render. - Add a bodyLayout form control; persist body_layout in create/update and restore it when editing a draft. - Dedup autosave on the serialized body_layout (body_html no longer changes until save, so hashing it would suppress composer edits). - Content-step now hosts the composer and drops the AI generate drawer; AI returns in a later phase once it emits blocks rather than HTML. - Remove the /newsletters/composer-preview dev route and component, plus its dedicated e2e (it drove the now-removed preview surface). A focused composer-in-wizard e2e lands as a follow-up. LFXV2-2385 Signed-off-by: Dan Baker <me@danb.co> * fix(review): guard body_layout save and rendered-html surfaces Address post-commit review findings on the composer wizard integration. Critical: - The BFF newsletter controller hard-required a non-empty body_html on create/update, so a composer-only draft (empty body_html, content in body_layout) was rejected before reaching the service that renders body_layout to body_html. Accept a non-empty body_layout as satisfying the body requirement. - Preview, test-send, and send read body_html, which is server-derived and only current after a save round-trip. Gate those surfaces on the rendered body_html being present AND the draft not being dirty, so none acts on stale or empty HTML (a test email could otherwise go out empty). Seed the saved snapshot on draft load so reopened drafts read as clean. Also: - bodyFilled accepts raw body_html too, so drafts authored before the composer landed stay sendable. - Associate the composer's Body caption via aria-labelledby. - Note serializeLayout's stable-key-order assumption. - Add a wizard-integration e2e (reopen hydrates the composer; adding a block persists body_layout on save), restoring composer coverage after the dev-surface spec was removed. LFXV2-2385 Signed-off-by: Dan Baker <me@danb.co> * fix(review): canonicalize body_layout serialization for dirty check isDirty and the autosave dedup compare serialized layouts across the composer-to-server boundary, where key order can differ. Sort object keys recursively before stringifying so a content-equal layout serializes identically regardless of key order, removing the unenforced stable-key-order assumption (a reopened draft could otherwise read dirty and gate preview/send off until the first autosave). Array order is preserved — block order is meaningful. LFXV2-2385 Signed-off-by: Dan Baker <me@danb.co> * fix(review): gate send on rendered body + cap body_layout size - canSend now requires bodyRendered() (server-derived body_html present), matching canSendTest/canPreview — closes the edge where a save returning empty body_html left full-send enabled while the test-send safety net was blocked. - Bound the serialized body_layout size in the BFF create/update validator (body_html already had a cap); an empty body_html plus an oversized body_layout otherwise bypassed the only body size guard at the proxy. LFXV2-2385 Signed-off-by: Dan Baker <me@danb.co> * fix(review): drop tenant-specific defaults from logo_header block The logo_header platform block shipped a specific project's masthead as its field defaults: a Customer.io CDN image, an mlops.community link, and an 'MLOps Community x The Linux Foundation' brand label. Because new blocks seed these scalar defaults into content, every LFX newsletter with a fresh Logo Header block would carry that third-party asset unless the author overrode it. Clear the defaults to empty — this is a generic platform block, so the author supplies the banner, link, and label per edition. The template already guards each field with if=, so an unfilled block renders nothing. Tenant-specific assets belong in per-project/template config, not the shared platform palette. LFXV2-2385 Signed-off-by: Dan Baker <me@danb.co> * feat(newsletters): load the editor palette from service templates Switch the block composer's manifest source from the build-time static asset to the newsletter service's embedded template sets, making the service the single source of truth for the palette and the render: - Shared: NewsletterTemplateInfo/NewsletterTemplatesResponse types and a NEWSLETTER_DEFAULT_TEMPLATE_KEY constant (the full AAIF set, matching the service's render superset). - BFF: proxy GET .../newsletters/templates and .../templates/:templateKey/manifest (client + service + controller + routes, static segments registered before /:newsletterUid). - Frontend: NewsletterManifestService fetches per template key through the BFF (per-key cached streams, project uid from ProjectContextService, browser-only as before); the composer takes a templateKey input defaulting to the shared constant. - e2e: manifest stub targets the new API URL. The static asset and its generator script are now unconsumed; removing them is a follow-up once the service path has soaked. Depends on the lfx-v2-newsletter-service branch feat/newsletter-embedded-templates (embedded per-key template sets + manifest endpoints + Heimdall viewer rules). LFXV2-2386 Signed-off-by: Dan Baker <me@danb.co> * fix(review): log manifest load failures before fallback Both failure branches in NewsletterManifestService.load() set the error signal silently. Log the missing-project-context skip (console.warn) and the HTTP failure (console.error with the template key) before falling back, per the frontend checklist's no-silent-catchError rule. Also note that the wizard flow resolves project context before the composer mounts, so the no-context branch is a guard, not an expected path. LFXV2-2386 Signed-off-by: Dan Baker <me@danb.co> * feat(newsletters): editor blocks search + outline reorder, remove AI tab Editor parity with the current Gatewaze composer: - Remove the AI copilot tab (not needed for now); drop 'ai' from the composer tab type. - Add a blocks search box that collapses the palette into a flat list of blocks matching label or type (Gatewaze block-search parity). - Make the Outline tab drag-to-reorder top-level blocks (Gatewaze DraggableOutline parity — root blocks only; children reorder on the canvas as before). LFXV2-2381 Signed-off-by: Dan Baker <me@danb.co> * fix(review): lfx-input-text for blocks search, correct outline reorder Addresses the post-commit reviewer findings on the editor left-panel work: - The blocks search box now uses lfx-input-text (FormControl-backed, mirrored to a signal) instead of a raw <input>, per the frontend wrapper convention. - The outline reorder previously mis-computed the target index for downward drags (dropping a block one slot short). The outline now renders only top-level blocks as draggable rows with container children nested and static, so CDK's indices map 1:1 onto the blocks array and the reorder is a plain moveItemInArray. LFXV2-2381 Signed-off-by: Dan Baker <me@danb.co> * feat(newsletters): select field type in the block-composer fields panel Add a 'select' field type so schema-driven blocks can expose a dropdown. The field's schema 'options' (label/value pairs) drive an lfx-select in both the top-level and array-item field switches. Gatewaze field-type parity (Gatewaze has select in its Puck fields). LFXV2-2382 Signed-off-by: Dan Baker <me@danb.co> * feat(newsletters): preview viewport switch + light/dark backdrop Add preview-frame chrome to the block composer (Gatewaze parity): - A desktop/mobile viewport toggle that constrains the email column to 682px / 375px, matching Gatewaze's preview widths. - A light/dark backdrop toggle behind the email card, for previewing how the edition reads on each. LFXV2-2384 Signed-off-by: Dan Baker <me@danb.co> * fix(review): drop orphaned NewsletterOutlineEntry type The outline now iterates top-level blocks directly (children nested), so the flattened-outline computed and its NewsletterOutlineEntry view-model were removed. This deletes the now-unreferenced shared interface the full-branch sweep flagged. LFXV2-2381 Signed-off-by: Dan Baker <me@danb.co> * feat(newsletters): HTML-source view + email-size indicator in the composer Two more editor-parity items (Gatewaze parity): - An HTML-source toggle that swaps the live canvas for a read-only view of the full rendered email HTML (assembled the same way a send is, via renderNewsletter with editMode off). - A live email-size indicator below the preview showing the rendered byte size, warning as it approaches and passes Gmail's ~102 KB clipping limit (warn at 90 KB, clip at 102 KB). LFXV2-2384 Signed-off-by: Dan Baker <me@danb.co> * fix(review): guard email-size indicator while the manifest loads The size indicator showed a misleading 0.0 KB before the manifest loaded (fullHtml is empty until then). Show a placeholder while loading, per the frontend loading-state convention. LFXV2-2384 Signed-off-by: Dan Baker <me@danb.co> * feat(newsletters): inline-sync, single toolbar, collapsible panels Three block-composer editor fixes for LFXV2-2386: - Inline edits now commit on every `input`, not just on blur, so the Fields panel mirrors typing in the live preview instantly (the panel->canvas direction already updated live). Safe under the caret: the edited block stays frozen, so its re-render reuses cached HTML while the separate sidebar form patches its controls in place. - Removes the duplicate block toolbar. The floating dark action bar (label + duplicate / delete / rich-text controls) is the single selected-block chrome; the white per-block chip is reduced to just the CDK drag handle, which must live inside the cdkDrag element. - Side panels collapse to widen the squeezed center preview: a second click on the active rail tab hides the Blocks/Outline panel (the icon rail stays), and the Fields sidebar has a minimize control that shrinks it to a thin re-open strip. Signed-off-by: Dan Baker <me@danb.co> * fix(review): drop hover-only tooltips on the drag-handle icons The drag-handle icons are non-focusable `<i>` hosts, so a `title` tooltip is unreachable by keyboard. Each handle already carries an `aria-label` for its accessible name and a grip is self-evident, so the redundant `title` is removed rather than adding tabindex/role to a control CDK doesn't make keyboard-operable here. Signed-off-by: Dan Baker <me@danb.co> * feat(newsletters): block-library picker in the composer Adds a "Library" selector to the composer's Blocks panel so an author can choose which embedded template set (block library) drives the palette and the emitted layout, for LFXV2-2386. - NewsletterLayout gains an optional `template_key`, emitted in the layout and re-seeded on draft reopen so a newsletter remembers its library. Optional for back-compat; consumers fall back to the default. - NewsletterManifestService.loadTemplates() fetches the library catalog (GET .../newsletters/templates) with graceful degradation: on an empty/absent catalog the picker synthesizes a single entry for the active key, so it always renders the current library. - Switching libraries confirms first and clears the canvas, since block types can differ between sets and the renderer hard-fails on an unknown type; cancelling reverts the picker. Note: a real multi-library experience still needs upstream newsletter-service work — today the service embeds a single set and renders off wrapper_key only (template_key is not yet honored server side), so the picker currently offers one working library. Signed-off-by: Dan Baker <me@danb.co> * test(newsletters): composer coverage for picker, collapse, and fields - Fixes the active-tab regression: the add-block test clicked the already-active Blocks tab, which now toggles the panel collapsed and hid the palette. It asserts palette visibility instead. - Adds a template-catalog stub (two libraries) alongside the manifest stub so the picker is deterministic. - Covers the new behaviors: rail-tab collapse/re-open, Fields sidebar minimize/restore, and library-switch clearing the canvas after confirmation. Signed-off-by: Dan Baker <me@danb.co> * fix(review): container-draft data loss, cached library switch-back, array track Addresses full-branch review findings on the composer work: - Critical: `hydrate()` decided container-ness from the manifest, but it runs in ngOnInit before the manifest resolves, so a reopened container draft hydrated as a leaf and dropped its nested children on the next save. Container-ness is now derived from the persisted `blocks` array, independent of manifest timing. Adds an e2e regression test. - Important: the manifest signal wasn't refreshed when switching BACK to an already-loaded library — the signal write sat upstream of `shareReplay`, so a cached replay skipped it, leaving the palette on the wrong library. Moved the write downstream of `shareReplay`. - Important: the fields panel's array-item `@for` tracked by `$index`; removing a mid-list item then rebound the rich-editor to the wrong item. Tracks by the item's stable FormGroup identity instead. - Moves the composer's form declarations into the documented Forms slot. Signed-off-by: Dan Baker <me@danb.co> * fix(newsletters): stop emitting template_key so saves don't 400 The upstream newsletter-service decodes body_layout with DisallowUnknownFields and its struct is { wrapper_key, blocks } only, so the composer emitting template_key made every draft save and send fail with `400 unknown field "template_key"`. Drop template_key from the emitted layout; the library picker keeps the selected set as client-side session state. The interface field stays as a documented reservation for when upstream accepts a per-newsletter template_key. Signed-off-by: Dan Baker <me@danb.co> * feat(newsletters): explain why a draft can't be saved yet The Save-as-Draft button was silently disabled until every requirement was met, giving no feedback. It's now clickable, and a click while incomplete shows a toast naming exactly what's still missing (audience, subject, content, or reply-to email). The button still shows its loading state while a save is in flight. Signed-off-by: Dan Baker <me@danb.co> * feat(newsletters): toggle between the block composer and a simple editor Re-introduces the pre-composer body editor (Tiptap rich-text + AI generation) alongside the block composer, switchable per newsletter from the Content step. Requested so authors who prefer the simpler body-field + AI flow aren't forced into blocks. - Content step hosts both editors behind a Blocks/Simple toggle. The two are mutually exclusive (blocks author body_layout, simple authors body_html); switching clears the other representation so only one body source is ever authoritative, with a confirm when the outgoing editor holds content. Mode is inferred on load from the saved draft. - Re-wires the AI generate drawer + onGenerated handler into the manage component (context inputs, generated output). - Autosave now tracks body_html as well as body_layout so simple-editor edits persist. The snapshot's body_html is taken from the save RESPONSE (the server-rendered value in blocks mode), so the post-save state compares equal and never re-triggers. Signed-off-by: Dan Baker <me@danb.co> * fix(review): editor-toggle data loss, stale gates, and mid-edit revert Addresses the reviewer trio's findings on the Blocks/Simple toggle: - Critical: switching Blocks->Simple read `initialLayout()`, a computed frozen at the draft's initial layout (the FormGroup identity never changes), so blocks built in-session counted as zero — the discard confirm was skipped and the work was silently cleared. setMode now reads the live control, and initialLayout re-reads it on toggle via a version bump, so the composer re-seeds from the real current layout (fixing the symmetric stale-resurrection on switch back). - body_html is now mirrored from the form control in initFormMirrors, so the simple editor's live typing registers as content/dirtiness (it was only set from server responses, leaving canSend/canSaveDraft stale until an autosave round-trip). - syncDerivedBodyHtml no longer overwrites body_html on simple-mode saves (the response only echoes the request-time value, so it reverted keystrokes typed during an in-flight save); it now runs only when the save rendered a layout. - generateDrawerVisible is a plain signal (internal-only), not model(). Signed-off-by: Dan Baker <me@danb.co> * test(newsletters): editor-toggle discards blocks only after confirming Regression test for the frozen initial-layout bug: opens a draft with no saved body (empty initial layout), builds a block in-session, then switches to the simple editor and asserts the discard confirm appears before the blocks are cleared — the exact case a frozen initial-layout read would silently drop. Signed-off-by: Dan Baker <me@danb.co> * feat(newsletters): persist the selected block library via template_key Now that the newsletter-service accepts and renders from a per-newsletter template_key (LFXV2-2747), the composer emits the selected library in the saved layout, so the email renders from that library server-side and a reopened draft returns to the same library. An empty/omitted key still renders from the default library. This reverses the earlier client-only stopgap (template_key was stripped from the payload to avoid the upstream DisallowUnknownFields 400); the service now treats it as a known field. Requires the newsletter-service template_key change to be deployed first. Signed-off-by: Dan Baker <me@danb.co> * test(newsletters): assert the emitted template_key equals the default Strengthen the persist assertion (all three reviewers noted it): check the emitted template_key equals NEWSLETTER_DEFAULT_TEMPLATE_KEY rather than just truthy, so it guards against emitting the wrong library, not only against the field being absent. Signed-off-by: Dan Baker <me@danb.co> * feat(newsletters): surface the template picker and show it on review The block-library picker lived inside the composer's collapsible Blocks panel, so it vanished when the panel was collapsed or the Outline tab was active — authors couldn't find how to choose a template. - Move the picker to an always-visible bar at the top of the editor (outside the collapsible panel) and relabel it "Template". Selection logic is unchanged; only the markup moved. - Show the selected template's name on the review screen's Content card (blocks-mode drafts only; html-only drafts have no template). Signed-off-by: Dan Baker <me@danb.co> * fix(review): show the review template name from the catalog label All three reviewers flagged that the review screen humanized template_key while the composer picker shows the catalog's curated label, so the two could differ if the backend returns a custom label. The review now looks the label up from the same catalog (NewsletterManifestService.templates), humanizing the key only as a fallback when the catalog hasn't loaded. Signed-off-by: Dan Baker <me@danb.co> * fix(newsletters): keep supported blocks when switching templates Switching the template previously wiped the whole canvas, and an empty newsletter can't be saved — so a switch never persisted and the preview kept showing the old template. Authors couldn't actually change template. Now a switch loads the new library's manifest and keeps the blocks it supports (they re-render in the new template's styling), dropping only the blocks the new library doesn't define, with a note of how many were removed. Compatible content carries over and autosaves, so the preview reflects the chosen template. Updates the e2e to assert keep + drop (a key-specific mock manifest makes one block incompatible). Signed-off-by: Dan Baker <me@danb.co> * fix(review): guard template switch against a failed manifest load The general reviewer flagged that retainSupportedBlocks read the shared manifest signal, which stays on the OLD library when the new library's manifest fails to load (ensureLoaded emits null, the service tap skips the signal on null). The canvas would then filter against the stale library, drop nothing, and silently autosave orphaned blocks under the new template_key — the 422 case this is meant to prevent. - onLibraryChange now uses the manifest value the stream emits and bails on null: it reverts the picker to the current library, alerts the author, and emits nothing. selectedTemplateKey is only set on success. - retainSupportedBlocks takes the manifest as a parameter (no shared- signal re-read), which also closes the rapid-successive-switch race. - Moves the new private helpers into the Private Helpers section per component-organization §4. Signed-off-by: Dan Baker <me@danb.co> * fix(newsletters): let blocks nest into container drop zones Dragging a palette block onto a container's "nest" zone dropped it at the top level instead of inside the container. CDK routes a drop to the first connected drop list whose rect contains the pointer, and the canvas rect encloses every (nested) container — so with the canvas listed first in paletteConnectedTo / containerConnectedTo it always won, swallowing drops meant for a container. Order the container lists BEFORE the canvas in both, so a drop over a container's nest zone matches the container first and the canvas is only the fallback for drops outside every container. Signed-off-by: Dan Baker <me@danb.co> * fix(review): sanitize richtext and clear branch-sweep findings Address the pre-PR full-branch review sweep: - Sanitize the richtext field through DomSanitizer before it enters the bypassSecurityTrustHtml'd preview string. body_layout is server-loaded and shared across a project's privileged personas, so an unsanitized richtext field was a stored cross-user XSS vector, not just self-XSS. - Remove the unreferenced generated newsletter-block-manifest.json asset (the manifest is fetched from the API at runtime); the `yarn newsletter:manifest` generator stays for local use. - Precompute per-container drop-list wiring into a signal map and replace the isActiveTab / isSelected / containerConnectedTo / containerListId template method calls with signal reads - satisfies the no-template- functions rule and stops a fresh connected-to array allocating on every change-detection pass. - Add takeUntilDestroyed to the composer's manifest-load subscribes (loadTemplates + both ensureLoaded) for consistent lifecycle management. - Mark the block-fields effect() as a sanctioned exception to the no-effect() rule (imperative FormGroup/Tiptap orchestration a derived pipe can't express). - Move newsletter-manage's private helpers below the protected methods to satisfy member-ordering. - Normalize pre-existing prettier drift the sweep surfaced (mostly composer template re-indentation). The test-coverage finding is not actioned: apps/lfx-one has no unit-test harness (no angular.json test target, zero app specs - the app is e2e-only via Playwright), so a lone Karma spec would be dead code. The richtext sanitization leans on Angular's DomSanitizer. LFXV2-2386 Signed-off-by: Dan Baker <me@danb.co> * fix(review): address PR bot findings (correctness, test-send, email size, a11y) Post-open bot review (Copilot / CodeRabbit / cursor / CodeQL) on #1133: Correctness: - body_layout tri-state: emit explicit null (not undefined) when a blocks draft switches to the simple editor, so the service clears the stored layout instead of preserving it. Shared DTO widened to accept null. - Library-switch race: a monotonic token drops a stale manifest response so a slow earlier switch can't overwrite the palette/canvas/key; a failed switch re-activates the current library and clears the "could not load" error (a cache-hit now resets the manifest error signal). - Reconcile empty containers after the manifest loads (an empty container returns without a blocks array and hydrates before the manifest, so it would otherwise render without its drop zone). - Review step counts body_layout blocks in hasBody, so a blocks draft isn't shown as empty before render-on-write syncs body_html. Test-send: - Send is_layout: true for block-composer drafts so the service dispatches the emitter email as-is instead of double-wrapping it in the legacy chrome (the upstream TestSend already honors the flag). Email size + source: - The email-size indicator and HTML-source view now use the server MJML render (new render-preview BFF proxy to the service's render-preview endpoint), so they reflect the ACTUAL sent email, not the client preview estimate. Debounced; falls back to the client render until the first response. A11y + housekeeping: - Associate block-fields labels with their controls (role=group + aria-labelledby; ariaLabelledBy on selects; wrapping labels on spacing). - Loop the manifest comment-strip until stable (CodeQL: incomplete multi-character sanitization). - Refresh the stale NEWSLETTER_DEFAULT_TEMPLATE_KEY comment. LFXV2-2386 Signed-off-by: Dan Baker <me@danb.co> * fix(review): coderabbit nits — scss empty comments, array-item test ids - Give the two empty `//` separator lines in the composer stylesheet content (scss/comment-no-empty). - Use the aliased array-item index (not the shadowed nested-field $index) in the nested field dataTest ids so they stay unique across array items. LFXV2-2386 Signed-off-by: Dan Baker <me@danb.co> * fix(review): address second-wave PR bot findings Deeper Copilot / CodeRabbit / cursor pass on the composer: Correctness: - Reject a present-but-empty body_layout: bodyFilled branches on layout presence (an empty layout is not content), validateCommonPayload rejects a present-but-empty layout, and the simple-body check strips markup - so a 0-block draft can't autosave/persist a wrapper-only email. - Clear body_html on the blocks->simple switch: it held the complete emitter document, which the send path would otherwise double-wrap in legacy chrome. - Library-switch race, service level: a stale manifest response can no longer overwrite the shared manifestSignal/loading/error - gated on the latest requested key (the component seq token only guarded local actions). - Send body_layout on test-send so the service recompiles with the unsubscribe footer suppressed (no dangling empty Unsubscribe row). UX / a11y: - Show per-block spacing controls only for top-level blocks (upstream applies spacing there only; child spacing would show in canvas but drop from email). - canPreview / canSendTest no longer require a save in the simple editor (both read the live form body_html); the real send stays strict. - Keyboard-accessible reorder: focusable move up/down controls in the Outline (drag was pointer-only). Housekeeping: - Log render-preview size as UTF-8 bytes (Buffer.byteLength). - Remove the obsolete newsletter-manifest generator + package script. Documented follow-up (not fixed): a container's live child drop zone renders below the container chrome rather than at a deeply-nested template slot. The SENT email renders children at the slot correctly - a canvas placement discrepancy only; a correct fix needs a fragile slot-split of the container render. LFXV2-2386 Signed-off-by: Dan Baker <me@danb.co> * fix(review): blocks-mode gate on layout presence + reconcile emits Two follow-up findings from the re-review: - isBlocksMode keys on layout PRESENCE, not block count: a present-but-empty layout (every block removed) is still blocks mode, so the preview/test gate keeps requiring a clean snapshot instead of treating it as simple HTML and showing stale server-rendered body_html. - reconcileContainers now emit()s after promoting a seeded block to a container, so the parent form layout matches the canvas. Empty-container blocks are omitted from the emitted layout (toLayoutBlock), matching the service's omitempty round-trip, so this emit produces a layout identical to the seed and does not spuriously mark the draft dirty. LFXV2-2386 Signed-off-by: Dan Baker <me@danb.co> * fix(review): third-wave regressions from the layout-authoritative fixes - Manifest service: clear loadingSignal on a cache-hit too, so switching to a cached library while another fetch is in flight no longer leaves the palette stuck on "Loading blocks…" (the superseded finalize is gated out by isLatest). - Review step: hasBody is now layout-authoritative (a present layout counts only with blocks), and templateLabel falls back to the default library when a blocks draft has a layout but no template_key (was hiding the Template row). - Content-step: infer Blocks mode from layout PRESENCE, not block count, so a saved blocks draft with its last block removed doesn't re-open as Simple. - Manage: syncDerivedBodyHtml skips the write when the form layout is now null (author switched to Simple mid-save), so a late blocks-save response can't clobber the freshly-cleared body_html. - Canvas: container children no longer render outer spacing (upstream applies _spacing_* only to top-level; the Fields panel already hides child spacing). LFXV2-2386 Signed-off-by: Dan Baker <me@danb.co> * fix(review): gate send on bodyFilled + refresh stale composer doc - canSend / canSendTest now also require bodyFilled, not just bodyRendered: the latter is a trim() check, so a markup-only simple draft (e.g. an empty rich-text "<p></p>") passed it; bodyFilled strips markup, so a visually-empty body can't be sent or test-sent. - Refresh the composer class doc: per-field editing (schema-driven Fields panel + inline canvas editing) is implemented — the "Phase-1: empty content, later ticket" note was stale. LFXV2-2386 Signed-off-by: Dan Baker <me@danb.co> * docs(newsletters): correct stale build-time-manifest comments Manifests are fetched at runtime from the newsletter service per selected block library — the build-time generator was removed. Update the composer class doc, the templateKey input doc (a saved layout's template_key takes precedence; this is the initial/fallback key), and the NewsletterTemplateManifest type doc. LFXV2-2386 Signed-off-by: Dan Baker <me@danb.co> * fix(review): sanitize pasted HTML in inline richtext editing commitInlineEdit read a richtext contenteditable's innerHTML straight into block.content with no sanitization at commit, and the renderer's sanitizer only runs on the next re-render (frozen while editing) — so pasted markup was parsed into the live DOM, executing embedded handlers like <img onerror> in the author's own session before anything sanitized it. Narrow (self-XSS, author's session only; other viewers load sanitized renderer output), but avoidable. Add a paste handler in wireEditable: richtext fields insert the pasted HTML run through the same Angular DomSanitizer the renderer uses; plain-text fields insert text only, so no pasted markup is parsed into the DOM at all. Mirrors the defense-in-depth the Tiptap editor already applies elsewhere in the composer. LFXV2-2386 Signed-off-by: Dan Baker <me@danb.co> * fix(review): clear inline-edit freeze when the edited block is deleted Removing a block cleared selection and the floating toolbar but never reset editingBlockId. If the block was deleted while one of its contenteditable fields still had focus, blur may not fire, leaving a stale inline-edit freeze id that keeps re-renders frozen until another field is focused. clearSelectionIfRemoved (the shared hook for both the toolbar delete and child removal) now also clears editingBlockId and the richtext toolbar flag when the edited block is removed. LFXV2-2386 Signed-off-by: Dan Baker <me@danb.co> * fix(review): persist emptied block canvas; sanitize dropped HTML Two cursor findings: - Cannot persist a cleared canvas: bodyFilled counts a present layout only when blocks.length > 0, and the save gates (canSaveDraft, hasAnythingToSave) reused it — so after removing every block the emptied layout never reached the server and reverted on reload. Add bodyPersistable (a present layout counts even with zero blocks; else fall back to body_html content) and use it in the save gates. Send gates keep bodyFilled, so an empty layout can be saved but not sent. - Inline richtext drop bypassed the sanitizer: the paste handler sanitized clipboard HTML, but dropped HTML was inserted raw and copied into body_layout. Route paste and drop through a shared insertSanitizedTransfer so both sanitize before anything reaches the contenteditable DOM (drag-drop is the same untrusted-HTML vector as paste). LFXV2-2386 Signed-off-by: Dan Baker <me@danb.co> * fix(review): keyless layout stays keyless; manifest race; interface docs Four review findings on the newsletter editor: - Keyless layout rebranded to the default library: toLayout always emitted template_key = selectedTemplateKey, so a keyless legacy (or new) layout was stamped with the default palette's key (aaif-user-community) and permanently rebranded with that chrome. Track hasExplicitTemplateKey (the saved layout had a key, or the author picked one) and emit template_key only when explicit; the default palette still drives editing, but a keyless layout persists keyless and the service renders it with neutral chrome over the block superset. - Manifest loader race: the cached branch cleared loadingSignal even when the cached stream was still in flight, so a rapid A->B->C->B switch could show a stale palette as ready and allow adding incompatible blocks. Track resolvedKeys and only clear loading for a resolved key; a still-pending cached key keeps the loading state until its shared stream lands. - Shared interfaces: mark NewsletterTestSendPayload.is_layout @deprecated/ignored (body_layout is the sole layout trigger upstream), and document that an omitted NewsletterLayout.template_key renders neutral chrome over the superset, not a specific library's branding. LFXV2-2386 Signed-off-by: Dan Baker <me@danb.co> * fix(review): align draft hint + wizard e2e with keyless/save behavior Follow-ups to the previous review commit: - missingDraftRequirements used bodyFilled to warn about missing content, but canSaveDraft now gates on bodyPersistable — so an author with an emptied block layout (saveable) but a missing audience was wrongly told to add content. Use bodyPersistable so the warning matches the save gate. - The wizard e2e asserted a keyless draft emits the default template_key; the composer now keeps a keyless layout keyless (omits template_key). Update the assertion to expect no template_key and drop the now-unused constant import. LFXV2-2386 Signed-off-by: Dan Baker <me@danb.co> * fix(review): allow saving an emptied block layout (blocks: []) The frontend now persists an emptied canvas (bodyPersistable), but the BFF's validateCommonPayload rejected any present-but-empty layout with 400 — so the save failed and the emptied draft reverted to its old content on reload. Relax draft validation to require a present layout be STRUCTURALLY valid (blocks is an array) while permitting blocks: []; a wrapper-only layout renders fine upstream and is a valid draft state. The non-empty requirement stays on the send gate (canSend / bodyFilled), not draft validation — so an empty newsletter can be saved but not sent. validateCommonPayload gates create/update only; send does not use it. LFXV2-2386 Signed-off-by: Dan Baker <me@danb.co> * fix(review): escape paste/drop text; layout-aware size caps; neutral label Four review findings: - Security: insertSanitizedTransfer passed the text/plain fallback (and the sanitizer-returned-null case) straight to insertHTML, so a plain-text <img onerror=...> payload was parsed into the live contenteditable DOM, defeating the sanitization. Only sanitized HTML now goes to insertHTML; all plain text goes through insertText (inserted as text, never markup). - Draft autosave: the 100k body_html cap was applied in blocks mode too, but upstream ignores request body_html and re-derives it from body_layout — so a large composed email the composer only WARNS about (the ~102 KB Gmail threshold) failed to autosave. Cap body_html only on the HTML-only path. - Test-send: same 100k body_html cap blocked test-sending a large layout. Make validateTestSendPayload layout-aware — require/cap body_html only when there's no valid layout; size-cap the layout itself otherwise. - Review screen: a keyless layout was labelled with the default library's name ("AAIF User Community"), misstating what is sent (keyless renders neutral). Show "Default (neutral)" for a keyless layout. LFXV2-2386 Signed-off-by: Dan Baker <me@danb.co> * style(newsletters): prettier-format test-send validation Signed-off-by: Dan Baker <me@danb.co> * fix(review): enforce container allowed_block_types; rebuild fields on schema swap Three review findings on the composer: - Dropping a block into a container (both palette drop-in and existing-block transfer) only rejected nested containers, ignoring the container's allowed_block_types allowlist — so an unsupported leaf could be placed into a restricted container, producing a layout the selected library can't render. Add childAllowedInContainer and gate both drop paths on it. - The block-fields panel rebuilt its FormGroup only when the selected block ID changed. Switching libraries keeps the same block ID but swaps the block's manifest schema, so the form kept the previous library's controls (stale added/removed fields). Track a field-set signature (key+type) and rebuild when it changes even if the ID does not. LFXV2-2386 Signed-off-by: Dan Baker <me@danb.co> * fix(review): sync emptied-layout body_html; clear stale manifest on load Two more review findings, both fallout from the empty-canvas and manifest work: - syncDerivedBodyHtml keyed on non-empty blocks, so after clearing the canvas and saving, the emptied-but-present layout skipped syncing its server-rendered (wrapper-only) body_html — the form kept the old HTML and the draft stayed dirty with preview disabled until reload. Detect layout PRESENCE instead. - The manifest loader left the previous library's manifest published while the latest uncached load was in flight, and the failure path only sets errorSignal — so renderers/field lookups kept using the prior library, breaking the 'null on failure' contract. Clear the active manifest when starting the latest uncached load (gated on isLatest). LFXV2-2386 Signed-off-by: Dan Baker <me@danb.co> * fix(review): send ed_reply_email on layout test-send; ack save during autosave Two review findings: - The layout test-send payload omitted ed_reply_email, so a layout test email lost the reply-to header and the wrapper's 'To reply, email …' row that the real send includes (the service recompiles the wrapper from it). Add ed_reply_email to NewsletterTestSendPayload and the composer's test-send call. - Clicking Save during a background autosave was silently ignored: the button's spinner binds to manualSaving (manual saves only), so during an autosave it looks idle yet the click does nothing. Acknowledge the click with a 'Saving…' info message instead of dropping it silently. LFXV2-2386 Signed-off-by: Dan Baker <me@danb.co> * test(newsletters): cover legacy html-only draft opening in simple editor Backward-compat regression guard for pre-blocks newsletters (raised in review): a draft authored before the block composer has body_html and no body_layout. The test seeds such a draft and asserts it reopens in the SIMPLE editor (rich-text visible, blocks composer absent, Simple toggle active), the authored body is present, and a save round-trips as html-only (PUT carries body_html with a null body_layout, so the service takes the legacy chrome path). LFXV2-2386 Signed-off-by: Dan Baker <me@danb.co> * fix(review): validate ed_reply_email on test-send; address round-4 findings Round-4 review follow-ups (David + Cursor/Copilot): - validateTestSendPayload now requires a valid ed_reply_email (non-empty, '@'), matching validateCommonPayload — the client sends it on every test send and a layout test recompiles the wrapper reply-to row from it (David, blocking). - renderPreview: moved requireProjectUid/startOperation inside the try so an Express-4 synchronous throw reaches next(error) instead of hanging. - Preview renderer: allow mailto: for href (src stays http(s)-only) so the wrapper's mailto:{{edition.reply_email}} link is not dropped from the preview. - Content step: entering Blocks from a null layout seeds a non-null empty layout so a confirmed discard of a simple body is persistable and does not revert on reload. - Dropped the deprecated, upstream-ignored is_layout flag from the test-send payload and corrected the comment (body_layout is the sole trigger). - Legacy-draft spec: assert body_layout toBeNull (the tri-state clear signal the form actually sends), not the ?? null coalesce. LFXV2-2386 Signed-off-by: Dan Baker <me@danb.co> --------- Signed-off-by: Dan Baker <me@danb.co> Signed-off-by: David Deal <ddeal@linuxfoundation.org> Co-authored-by: David Deal <ddeal@linuxfoundation.org> (cherry picked from commit d641613)
WalkthroughAdds a newsletter block-composer flow with structured layouts, template manifests, rendering APIs, Angular editing UI, persistence integration, and Playwright coverage for modern and legacy drafts. ChangesNewsletter block composer
Estimated code review effort: 5 (Critical) | ~120 minutes Sequence Diagram(s)sequenceDiagram
participant Editor as NewsletterBlockComposerComponent
participant Manifest as NewsletterManifestService
participant API as NewsletterService
participant Server as NewsletterController
participant Manage as NewsletterManageComponent
Editor->>Manifest: Load template manifest
Manifest->>API: Request manifest
API->>Server: GET template manifest
Editor->>API: Request render preview
API->>Server: POST body_layout
Server-->>API: Return rendered body_html
API-->>Editor: Return preview response
Editor-->>Manage: Emit layoutChange
Manage->>Server: Save body_layout and body_html
Possibly related PRs
Suggested labels: 🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
✨ Finishing Touches 💡 1📝 Generate docstrings 💡
🧪 Generate unit tests (beta)
Comment |
PR SummaryMedium Risk Overview The Content step now defaults to Blocks (
New UI includes manifest-driven palette/canvas (drag-drop, containers, template library switch with unsupported blocks dropped), schema-driven Fields panel, live preview/source/Gmail size hints, and a large Playwright spec for hydration, persistence, legacy drafts, and template switching. Reviewed by Cursor Bugbot for commit 2f8bc9c. Bugbot is set up for automated code reviews on this repo. Configure here. |
There was a problem hiding this comment.
Pull request overview
Restores the draft newsletter block-composer feature with runtime template selection and server-rendered layouts.
Changes:
- Adds structured newsletter layout contracts and BFF endpoints.
- Introduces block composition, inline editing, previews, and template switching.
- Updates draft persistence, validation, review UI, and E2E coverage.
Reviewed changes
Copilot reviewed 23 out of 23 changed files in this pull request and generated 2 comments.
Show a summary per file
| File | Description |
|---|---|
packages/shared/src/utils/url.utils.spec.ts |
Tests safe URL validation. |
packages/shared/src/utils/string.utils.ts |
Adds field-key humanization. |
packages/shared/src/interfaces/newsletter.interface.ts |
Defines layout and manifest contracts. |
packages/shared/src/constants/newsletter.constants.ts |
Adds spacing and template defaults. |
apps/lfx-one/src/server/services/newsletter.service.ts |
Exposes template and preview operations. |
apps/lfx-one/src/server/services/newsletter-service.client.ts |
Proxies new upstream endpoints. |
apps/lfx-one/src/server/routes/newsletters.route.ts |
Registers template and preview routes. |
apps/lfx-one/src/server/controllers/newsletter.controller.ts |
Validates and handles layout requests. |
apps/lfx-one/src/app/shared/services/newsletter.service.ts |
Adds client preview rendering. |
apps/lfx-one/src/app/shared/services/newsletter-manifest.service.ts |
Loads and caches runtime manifests. |
apps/lfx-one/src/app/modules/newsletters/services/newsletter-renderer.service.ts |
Renders declarative templates safely. |
apps/lfx-one/src/app/modules/newsletters/newsletter-manage/newsletter-manage.component.ts |
Integrates layout persistence and gating. |
apps/lfx-one/src/app/modules/newsletters/newsletter-manage/newsletter-manage.component.html |
Updates preview and draft actions. |
apps/lfx-one/src/app/modules/newsletters/components/newsletter-review/newsletter-review.component.ts |
Adds layout-aware review details. |
apps/lfx-one/src/app/modules/newsletters/components/newsletter-review/newsletter-review.component.html |
Displays template and preview state. |
apps/lfx-one/src/app/modules/newsletters/components/newsletter-content-step/newsletter-content-step.component.ts |
Implements editor-mode switching. |
apps/lfx-one/src/app/modules/newsletters/components/newsletter-content-step/newsletter-content-step.component.html |
Hosts composer and simple editors. |
apps/lfx-one/src/app/modules/newsletters/components/newsletter-block-fields/newsletter-block-fields.component.ts |
Builds schema-driven field forms. |
apps/lfx-one/src/app/modules/newsletters/components/newsletter-block-fields/newsletter-block-fields.component.html |
Renders block and spacing controls. |
apps/lfx-one/src/app/modules/newsletters/components/newsletter-block-composer/newsletter-block-composer.component.ts |
Implements composer behavior. |
apps/lfx-one/src/app/modules/newsletters/components/newsletter-block-composer/newsletter-block-composer.component.scss |
Styles rendered email previews. |
apps/lfx-one/src/app/modules/newsletters/components/newsletter-block-composer/newsletter-block-composer.component.html |
Defines composer palette, canvas, and sidebars. |
apps/lfx-one/e2e/newsletter-composer-wizard.spec.ts |
Covers hydration, persistence, and switching. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| if (!payload?.ed_reply_email || typeof payload.ed_reply_email !== 'string' || !payload.ed_reply_email.includes('@')) { | ||
| fieldErrors['ed_reply_email'] = 'A valid ed_reply_email is required'; | ||
| } |
| </lfx-input-text> | ||
| </label> | ||
| </div> | ||
| <p class="text-xs text-gray-400">CSS shorthand, e.g. <code>12px</code> or <code>8px 16px</code>.</p> |
There was a problem hiding this comment.
Actionable comments posted: 4
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (1)
apps/lfx-one/src/app/modules/newsletters/newsletter-manage/newsletter-manage.component.ts (1)
504-510: 🎯 Functional Correctness | 🟠 Major | ⚡ Quick winClose
formatMissingbefore the next method
Missing the closing}beforeprotected onAddAudienceEmailleavesnewsletter-manage.component.tsunparsable and blocks the TypeScript build.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@apps/lfx-one/src/app/modules/newsletters/newsletter-manage/newsletter-manage.component.ts` around lines 504 - 510, Close the private formatMissing method with a closing brace before protected onAddAudienceEmail, preserving its existing formatting and return behavior so the component parses correctly.Source: Linters/SAST tools
🧹 Nitpick comments (6)
apps/lfx-one/src/app/modules/newsletters/components/newsletter-block-fields/newsletter-block-fields.component.ts (1)
214-224: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low valueOrphaned doc comment.
The
Build the reactive form…one-liner at Line 214 now sits abovefieldsKey's own doc block, sobuildForm(Line 224) reads as undocumented andfieldsKeyappears to have two descriptions. Move it directly abovebuildForm.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@apps/lfx-one/src/app/modules/newsletters/components/newsletter-block-fields/newsletter-block-fields.component.ts` around lines 214 - 224, Move the “Build the reactive form for a block and wire its value-change emit” doc comment from above fieldsKey to directly above buildForm. Keep fieldsKey’s existing schema-signature documentation immediately above fieldsKey.apps/lfx-one/src/app/modules/newsletters/components/newsletter-block-composer/newsletter-block-composer.component.ts (2)
1017-1032: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low valueDoc block is attached to the wrong method.
The JSDoc at Lines 1017-1024 describes container reconciliation but sits above
moveTopLevelBlock;reconcileContainers(Line 1034) is left undocumented.♻️ Move the doc block
- /** - * After the manifest resolves, promote any top-level block the manifest - * declares a container but that hydrated as a leaf. An empty (childless) - * container comes back from the service without a `blocks` array (omitempty), - * and `hydrate` runs before the manifest is available, so it can't classify it - * — left unreconciled it would render without its drop zone and can't accept - * nested blocks. Containers never nest, so only the top level needs this. - */ + /** Move a top-level block from one index to another, then re-emit. */ private moveTopLevelBlock(from: number, to: number): void {and re-attach the container text above
reconcileContainers.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@apps/lfx-one/src/app/modules/newsletters/components/newsletter-block-composer/newsletter-block-composer.component.ts` around lines 1017 - 1032, Move the existing container-reconciliation JSDoc from above moveTopLevelBlock to directly above reconcileContainers, leaving moveTopLevelBlock without that documentation and preserving the text unchanged.
431-446: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚖️ Poor tradeoffPrefer the app's notification/dialog services over
window.alert/window.prompt.Native modals are blocking, unstyled, and inconsistent with the
MessageService/ConfirmationServicepattern used elsewhere in the newsletter module (seenewsletter-manage.component.ts). The link prompt in particular would be better as an inline popover so the contentEditable selection isn't disturbed.Also applies to: 577-595
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@apps/lfx-one/src/app/modules/newsletters/components/newsletter-block-composer/newsletter-block-composer.component.ts` around lines 431 - 446, Replace the native window.alert calls in the newsletter block composer, including the failure branch around ensureLoaded and the additional block-removal notification, with the newsletter module’s established MessageService or dialog notification pattern used by newsletter-manage.component.ts. Preserve the existing messages and control flow while avoiding blocking native modals; apply the same service-based approach to the link prompt range noted in the review, using an inline popover that does not disrupt the contentEditable selection.apps/lfx-one/e2e/newsletter-composer-wizard.spec.ts (1)
315-320: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick winExtract the repeated draft-GET override into a shared helper.
The same "override the per-newsletter GET route with a modified draft, fallback otherwise" block is repeated three times (container draft, empty-layout draft, legacy draft). Consider a small helper to keep the intent obvious and avoid drift as more scenarios are added.
♻️ Proposed helper extraction
+async function stubDraftGetOverride(page: Page, overrides: Partial<Newsletter> = {}): Promise<void> { + await page.route(`**/api/projects/${MOCK_FOUNDATION_UID}/newsletters/${MOCK_NEWSLETTER_ID}`, (route) => { + if (route.request().method() === 'GET') { + return route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(buildDraft(overrides)) }); + } + return route.fallback(); + }); +}Then each test body becomes, e.g.:
- await page.route(`**/api/projects/${MOCK_FOUNDATION_UID}/newsletters/${MOCK_NEWSLETTER_ID}`, (route) => { - if (route.request().method() === 'GET') { - return route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(buildDraft({ body_layout: CONTAINER_DRAFT_LAYOUT })) }); - } - return route.fallback(); - }); + await stubDraftGetOverride(page, { body_layout: CONTAINER_DRAFT_LAYOUT });Also applies to: 373-378, 406-415
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@apps/lfx-one/e2e/newsletter-composer-wizard.spec.ts` around lines 315 - 320, Extract the repeated newsletter draft GET route override into a shared helper near the existing test utilities, parameterized by the draft or layout needed for each scenario. Update the container, empty-layout, and legacy draft cases to call this helper while preserving route fallback for non-GET requests.packages/shared/src/utils/url.utils.spec.ts (1)
15-33: 🔒 Security & Privacy | 🔵 Trivial | ⚡ Quick winAdd the two inputs the renderer can actually hand this gate.
passthroughinapps/lfx-one/src/app/modules/newsletters/services/newsletter-renderer.service.ts(Line 604) passes raw resolved attribute values straight through, andisSafeUrlForAttrtolerates leading whitespace beforemailto:. Pinning whitespace-padded and protocol-relative inputs keeps that gate honest.💚 Suggested extra cases
expect(isValidUrl('javascript:alert(1)')).toBe(false); expect(isValidUrl('JavaScript:alert(1)')).toBe(false); // case-insensitive + expect(isValidUrl(' javascript:alert(1)')).toBe(false); // leading whitespace expect(isValidUrl('data:text/html,<script>alert(1)</script>')).toBe(false);expect(isValidUrl('/relative/path')).toBe(false); + expect(isValidUrl('//evil.example.com')).toBe(false); // protocol-relative expect(isValidUrl('example.com')).toBe(false); // no protocol🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@packages/shared/src/utils/url.utils.spec.ts` around lines 15 - 33, Add test cases in the URL validation spec for a whitespace-padded mailto value and a protocol-relative URL, asserting both are rejected by isValidUrl. Keep the coverage focused on inputs passed through the newsletter renderer’s isSafeUrlForAttr gate.apps/lfx-one/src/app/modules/newsletters/services/newsletter-renderer.service.ts (1)
123-150: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick winDuplicated editable-field rules between
collectEditableFieldsandselfEditableField.Lines 133-143 re-implement the exact anchor/
each=/richtext exclusion logic thatselfEditableField(Lines 493-504) already owns — the comment there even says it "mirrors" these rules. Two copies that must stay in sync will drift the first time a tag is added toTAG_MAP.♻️ Proposed refactor
const walk = (nodes: TemplateNode[]): void => { for (const node of nodes) { if (node.kind !== 'element') continue; - // Skip structural / repeated cases — those aren't single-field text. - if (node.attrs['each'] === undefined) { - if (node.tag === 'richtext') { - const field = node.attrs['field'] ?? bindingKeyFromChildren(node.children); - if (field) fields.add(field); - } else { - const field = singleTextField(node); - const mapped = TAG_MAP[node.tag]?.tag; - // Mirror renderNode's exclusion of anchors (link text owns an href). - if (field && mapped !== 'a' && node.tag !== 'a' && node.tag !== 'link' && node.tag !== 'gw-link' && node.tag !== 'button') { - fields.add(field); - } - } - } + // Single source of truth for the marking rules (richtext, `each=`, anchors). + const self = selfEditableField(node); + if (self) fields.add(self.field); walk(node.children); } };Note:
selfEditableFieldreturns a richtext field even undereach=, so confirm that difference is acceptable (or move theeach=guard ahead of the richtext branch there).🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@apps/lfx-one/src/app/modules/newsletters/services/newsletter-renderer.service.ts` around lines 123 - 150, Consolidate editable-field detection in collectEditableFields by reusing selfEditableField instead of duplicating its each=, richtext, and anchor-exclusion rules. Preserve traversal of child nodes and Set collection, and ensure the shared helper’s each= behavior matches the intended contract—move the guard before richtext handling if richtext fields must also be excluded under each=.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In
`@apps/lfx-one/src/app/modules/newsletters/components/newsletter-block-composer/newsletter-block-composer.component.html`:
- Around line 376-385: Update the block wrapper divs in the canvas, including
the corresponding instance around the second referenced section, to expose
button semantics with role="button" and tabindex="0". Add keyboard handling so
Enter and Space invoke selectBlock(block.id), while preserving the existing
click selection and aria-pressed state.
In
`@apps/lfx-one/src/app/modules/newsletters/components/newsletter-content-step/newsletter-content-step.component.ts`:
- Around line 165-190: Update applyMode’s simple branch so switching from Blocks
retains a persistable empty bodyLayout instead of setting it to null. Seed the
same empty NewsletterLayout shape used when entering Blocks, while continuing to
clear bodyHtml, so the discarded state can pass bodyPersistable and be saved.
In `@apps/lfx-one/src/server/controllers/newsletter.controller.ts`:
- Around line 565-571: The test-send reply-address contract must be consistent
across validation, documentation, and shared types. Keep the mandatory
ed_reply_email validation in testSend, update its JSDoc to state that a valid
reply address is required, and make ed_reply_email required in the shared
newsletter interface while removing the omission note.
- Around line 170-198: Move projectUid extraction and logger.startOperation
calls inside the try blocks of getTemplates and getTemplateManifest so
synchronous validation or logging errors are caught and passed to next(error),
matching the established handling in renderPreview and deleteOptOut.
---
Outside diff comments:
In
`@apps/lfx-one/src/app/modules/newsletters/newsletter-manage/newsletter-manage.component.ts`:
- Around line 504-510: Close the private formatMissing method with a closing
brace before protected onAddAudienceEmail, preserving its existing formatting
and return behavior so the component parses correctly.
---
Nitpick comments:
In `@apps/lfx-one/e2e/newsletter-composer-wizard.spec.ts`:
- Around line 315-320: Extract the repeated newsletter draft GET route override
into a shared helper near the existing test utilities, parameterized by the
draft or layout needed for each scenario. Update the container, empty-layout,
and legacy draft cases to call this helper while preserving route fallback for
non-GET requests.
In
`@apps/lfx-one/src/app/modules/newsletters/components/newsletter-block-composer/newsletter-block-composer.component.ts`:
- Around line 1017-1032: Move the existing container-reconciliation JSDoc from
above moveTopLevelBlock to directly above reconcileContainers, leaving
moveTopLevelBlock without that documentation and preserving the text unchanged.
- Around line 431-446: Replace the native window.alert calls in the newsletter
block composer, including the failure branch around ensureLoaded and the
additional block-removal notification, with the newsletter module’s established
MessageService or dialog notification pattern used by
newsletter-manage.component.ts. Preserve the existing messages and control flow
while avoiding blocking native modals; apply the same service-based approach to
the link prompt range noted in the review, using an inline popover that does not
disrupt the contentEditable selection.
In
`@apps/lfx-one/src/app/modules/newsletters/components/newsletter-block-fields/newsletter-block-fields.component.ts`:
- Around line 214-224: Move the “Build the reactive form for a block and wire
its value-change emit” doc comment from above fieldsKey to directly above
buildForm. Keep fieldsKey’s existing schema-signature documentation immediately
above fieldsKey.
In
`@apps/lfx-one/src/app/modules/newsletters/services/newsletter-renderer.service.ts`:
- Around line 123-150: Consolidate editable-field detection in
collectEditableFields by reusing selfEditableField instead of duplicating its
each=, richtext, and anchor-exclusion rules. Preserve traversal of child nodes
and Set collection, and ensure the shared helper’s each= behavior matches the
intended contract—move the guard before richtext handling if richtext fields
must also be excluded under each=.
In `@packages/shared/src/utils/url.utils.spec.ts`:
- Around line 15-33: Add test cases in the URL validation spec for a
whitespace-padded mailto value and a protocol-relative URL, asserting both are
rejected by isValidUrl. Keep the coverage focused on inputs passed through the
newsletter renderer’s isSafeUrlForAttr gate.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Organization UI
Review profile: CHILL
Plan: Pro
Run ID: 2a9a8c03-00fb-44d4-8c21-402d8f0ca933
📒 Files selected for processing (23)
apps/lfx-one/e2e/newsletter-composer-wizard.spec.tsapps/lfx-one/src/app/modules/newsletters/components/newsletter-block-composer/newsletter-block-composer.component.htmlapps/lfx-one/src/app/modules/newsletters/components/newsletter-block-composer/newsletter-block-composer.component.scssapps/lfx-one/src/app/modules/newsletters/components/newsletter-block-composer/newsletter-block-composer.component.tsapps/lfx-one/src/app/modules/newsletters/components/newsletter-block-fields/newsletter-block-fields.component.htmlapps/lfx-one/src/app/modules/newsletters/components/newsletter-block-fields/newsletter-block-fields.component.tsapps/lfx-one/src/app/modules/newsletters/components/newsletter-content-step/newsletter-content-step.component.htmlapps/lfx-one/src/app/modules/newsletters/components/newsletter-content-step/newsletter-content-step.component.tsapps/lfx-one/src/app/modules/newsletters/components/newsletter-review/newsletter-review.component.htmlapps/lfx-one/src/app/modules/newsletters/components/newsletter-review/newsletter-review.component.tsapps/lfx-one/src/app/modules/newsletters/newsletter-manage/newsletter-manage.component.htmlapps/lfx-one/src/app/modules/newsletters/newsletter-manage/newsletter-manage.component.tsapps/lfx-one/src/app/modules/newsletters/services/newsletter-renderer.service.tsapps/lfx-one/src/app/shared/services/newsletter-manifest.service.tsapps/lfx-one/src/app/shared/services/newsletter.service.tsapps/lfx-one/src/server/controllers/newsletter.controller.tsapps/lfx-one/src/server/routes/newsletters.route.tsapps/lfx-one/src/server/services/newsletter-service.client.tsapps/lfx-one/src/server/services/newsletter.service.tspackages/shared/src/constants/newsletter.constants.tspackages/shared/src/interfaces/newsletter.interface.tspackages/shared/src/utils/string.utils.tspackages/shared/src/utils/url.utils.spec.ts
| <div | ||
| class="group/block relative cursor-pointer rounded-md transition-shadow" | ||
| [class.ring-2]="selectedBlockId() === block.id" | ||
| [class.ring-blue-500]="selectedBlockId() === block.id" | ||
| [style]="blockSpacingStyles().get(block.id)" | ||
| cdkDrag | ||
| [cdkDragData]="block" | ||
| (click)="selectBlock(block.id)" | ||
| [attr.data-testid]="'newsletter-composer-block-' + block.block_type" | ||
| [attr.aria-pressed]="selectedBlockId() === block.id"> |
There was a problem hiding this comment.
📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win
aria-pressed on a plain div isn't exposed, and canvas selection is pointer-only.
These block wrappers carry (click) + aria-pressed but no role/tabindex, so screen readers ignore the pressed state and keyboard users can't select a block from the canvas. Adding role="button" + tabindex="0" (or relying solely on the Outline for keyboard selection and dropping aria-pressed here) would make the semantics honest.
Also applies to: 427-435
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In
`@apps/lfx-one/src/app/modules/newsletters/components/newsletter-block-composer/newsletter-block-composer.component.html`
around lines 376 - 385, Update the block wrapper divs in the canvas, including
the corresponding instance around the second referenced section, to expose
button semantics with role="button" and tabindex="0". Add keyboard handling so
Enter and Space invoke selectBlock(block.id), while preserving the existing
click selection and aria-pressed state.
| private applyMode(mode: NewsletterEditorMode): void { | ||
| if (mode === 'simple') { | ||
| // Drop the layout so the server uses the authored html rather than | ||
| // rendering blocks over it. | ||
| this.form().get('bodyLayout')?.setValue(null); | ||
| // Also clear the derived body_html: it holds the layout's COMPLETE | ||
| // server-rendered emitter email, which — kept as "simple" content with a | ||
| // null layout — the send path would double-wrap in the legacy chrome. The | ||
| // discard was already confirmed, so the simple editor starts empty. | ||
| this.form().get('bodyHtml')?.setValue(''); | ||
| } else { | ||
| // Drop the authored html; blocks become the source and the server | ||
| // re-derives body_html on save. | ||
| this.form().get('bodyHtml')?.setValue(''); | ||
| // Seed a non-null empty layout when entering Blocks from a null layout. | ||
| // Without it, a switch from a populated simple draft leaves bodyLayout null | ||
| // AND body_html empty, so bodyPersistable is false and neither autosave nor | ||
| // manual save can persist the confirmed discard — the old simple body then | ||
| // reverts on reload. The composer re-emits with its real manifest | ||
| // wrapper_key as soon as a block is added (mirrors its own 'default' | ||
| // fallback in toLayout()). | ||
| if (this.form().get('bodyLayout')?.value == null) { | ||
| const emptyLayout: NewsletterLayout = { wrapper_key: 'default', blocks: [] }; | ||
| this.form().get('bodyLayout')?.setValue(emptyLayout); | ||
| } | ||
| } |
There was a problem hiding this comment.
🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win
🧩 Analysis chain
🏁 Script executed:
#!/bin/bash
rg -n -C4 'bodyPersistable|hasAnythingToSave' apps/lfx-one/src/app/modules/newsletters/newsletter-manage/newsletter-manage.component.tsRepository: linuxfoundation/lfx-self-serve
Length of output: 3089
🏁 Script executed:
#!/bin/bash
set -euo pipefail
# Inspect the mode-switch logic and save gating around the relevant component.
sed -n '120,230p' apps/lfx-one/src/app/modules/newsletters/components/newsletter-content-step/newsletter-content-step.component.ts
printf '\n---\n'
sed -n '230,340p' apps/lfx-one/src/app/modules/newsletters/newsletter-manage/newsletter-manage.component.ts
printf '\n---\n'
sed -n '1048,1085p' apps/lfx-one/src/app/modules/newsletters/newsletter-manage/newsletter-manage.component.tsRepository: linuxfoundation/lfx-self-serve
Length of output: 11019
🏁 Script executed:
#!/bin/bash
set -euo pipefail
rg -n -C3 'canSaveDraft|saveDraft\(|hasAnythingToSave\(|saveTrigger\$|manual save|Save draft' apps/lfx-one/src/app/modules/newsletters -g '*.ts' -g '*.html'Repository: linuxfoundation/lfx-self-serve
Length of output: 13067
Blocks → Simple discard can’t be persisted Switching to simple sets bodyLayout = null and bodyHtml = '', but canSaveDraft() and autosave both require bodyPersistable(). The cleared state never reaches the server, so the discarded blocks layout comes back on reload. Seed a persistable empty layout here too, or delay clearing until the author makes a new change.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In
`@apps/lfx-one/src/app/modules/newsletters/components/newsletter-content-step/newsletter-content-step.component.ts`
around lines 165 - 190, Update applyMode’s simple branch so switching from
Blocks retains a persistable empty bodyLayout instead of setting it to null.
Seed the same empty NewsletterLayout shape used when entering Blocks, while
continuing to clear bodyHtml, so the discarded state can pass bodyPersistable
and be saved.
| public async getTemplates(req: Request, res: Response, next: NextFunction): Promise<void> { | ||
| const projectUid = this.requireProjectUid(req); | ||
| const startTime = logger.startOperation(req, 'newsletter_templates_list', { project_uid: projectUid }); | ||
|
|
||
| try { | ||
| const result = await this.newsletterService.getTemplates(req, projectUid); | ||
| logger.success(req, 'newsletter_templates_list', startTime, { count: result.templates.length }); | ||
| res.json(result); | ||
| } catch (error) { | ||
| next(error); | ||
| } | ||
| } | ||
|
|
||
| /** | ||
| * GET /api/projects/:projectUid/newsletters/templates/:templateKey/manifest | ||
| */ | ||
| public async getTemplateManifest(req: Request, res: Response, next: NextFunction): Promise<void> { | ||
| const projectUid = this.requireProjectUid(req); | ||
| const templateKey = String(req.params['templateKey'] || '').trim(); | ||
| const startTime = logger.startOperation(req, 'newsletter_template_manifest', { project_uid: projectUid, template_key: templateKey }); | ||
|
|
||
| try { | ||
| const manifest = await this.newsletterService.getTemplateManifest(req, projectUid, templateKey); | ||
| logger.success(req, 'newsletter_template_manifest', startTime, { template_key: templateKey, block_count: manifest.blocks.length }); | ||
| res.json(manifest); | ||
| } catch (error) { | ||
| next(error); | ||
| } | ||
| } |
There was a problem hiding this comment.
🩺 Stability & Availability | 🟠 Major | ⚡ Quick win
New GET handlers throw outside try, the exact hazard renderPreview documents two methods below.
requireProjectUid(req) (and logger.startOperation) run before the try in both getTemplates and getTemplateManifest. On a missing/blank projectUid the synchronous ServiceValidationError rejects the returned promise without reaching next(error) — under Express 4 that leaves the request hanging, which is precisely why renderPreview (Lines 208-211) and deleteOptOut (Lines 351-352) keep these calls inside the try.
🛡️ Proposed fix
public async getTemplates(req: Request, res: Response, next: NextFunction): Promise<void> {
- const projectUid = this.requireProjectUid(req);
- const startTime = logger.startOperation(req, 'newsletter_templates_list', { project_uid: projectUid });
-
try {
+ const projectUid = this.requireProjectUid(req);
+ const startTime = logger.startOperation(req, 'newsletter_templates_list', { project_uid: projectUid });
const result = await this.newsletterService.getTemplates(req, projectUid);
logger.success(req, 'newsletter_templates_list', startTime, { count: result.templates.length });
res.json(result);
} catch (error) {
next(error);
}
}
public async getTemplateManifest(req: Request, res: Response, next: NextFunction): Promise<void> {
- const projectUid = this.requireProjectUid(req);
- const templateKey = String(req.params['templateKey'] || '').trim();
- const startTime = logger.startOperation(req, 'newsletter_template_manifest', { project_uid: projectUid, template_key: templateKey });
-
try {
+ const projectUid = this.requireProjectUid(req);
+ const templateKey = String(req.params['templateKey'] || '').trim();
+ const startTime = logger.startOperation(req, 'newsletter_template_manifest', { project_uid: projectUid, template_key: templateKey });
const manifest = await this.newsletterService.getTemplateManifest(req, projectUid, templateKey);📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| public async getTemplates(req: Request, res: Response, next: NextFunction): Promise<void> { | |
| const projectUid = this.requireProjectUid(req); | |
| const startTime = logger.startOperation(req, 'newsletter_templates_list', { project_uid: projectUid }); | |
| try { | |
| const result = await this.newsletterService.getTemplates(req, projectUid); | |
| logger.success(req, 'newsletter_templates_list', startTime, { count: result.templates.length }); | |
| res.json(result); | |
| } catch (error) { | |
| next(error); | |
| } | |
| } | |
| /** | |
| * GET /api/projects/:projectUid/newsletters/templates/:templateKey/manifest | |
| */ | |
| public async getTemplateManifest(req: Request, res: Response, next: NextFunction): Promise<void> { | |
| const projectUid = this.requireProjectUid(req); | |
| const templateKey = String(req.params['templateKey'] || '').trim(); | |
| const startTime = logger.startOperation(req, 'newsletter_template_manifest', { project_uid: projectUid, template_key: templateKey }); | |
| try { | |
| const manifest = await this.newsletterService.getTemplateManifest(req, projectUid, templateKey); | |
| logger.success(req, 'newsletter_template_manifest', startTime, { template_key: templateKey, block_count: manifest.blocks.length }); | |
| res.json(manifest); | |
| } catch (error) { | |
| next(error); | |
| } | |
| } | |
| public async getTemplates(req: Request, res: Response, next: NextFunction): Promise<void> { | |
| try { | |
| const projectUid = this.requireProjectUid(req); | |
| const startTime = logger.startOperation(req, 'newsletter_templates_list', { project_uid: projectUid }); | |
| const result = await this.newsletterService.getTemplates(req, projectUid); | |
| logger.success(req, 'newsletter_templates_list', startTime, { count: result.templates.length }); | |
| res.json(result); | |
| } catch (error) { | |
| next(error); | |
| } | |
| } | |
| /** | |
| * GET /api/projects/:projectUid/newsletters/templates/:templateKey/manifest | |
| */ | |
| public async getTemplateManifest(req: Request, res: Response, next: NextFunction): Promise<void> { | |
| try { | |
| const projectUid = this.requireProjectUid(req); | |
| const templateKey = String(req.params['templateKey'] || '').trim(); | |
| const startTime = logger.startOperation(req, 'newsletter_template_manifest', { project_uid: projectUid, template_key: templateKey }); | |
| const manifest = await this.newsletterService.getTemplateManifest(req, projectUid, templateKey); | |
| logger.success(req, 'newsletter_template_manifest', startTime, { template_key: templateKey, block_count: manifest.blocks.length }); | |
| res.json(manifest); | |
| } catch (error) { | |
| next(error); | |
| } | |
| } |
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@apps/lfx-one/src/server/controllers/newsletter.controller.ts` around lines
170 - 198, Move projectUid extraction and logger.startOperation calls inside the
try blocks of getTemplates and getTemplateManifest so synchronous validation or
logging errors are caught and passed to next(error), matching the established
handling in renderPreview and deleteOptOut.
| // Parity with validateCommonPayload: the client sends ed_reply_email on every | ||
| // test send, and a layout test recompiles the wrapper's "To reply, email …" | ||
| // row (and the email's reply-to) from it, so require a valid address here too | ||
| // rather than silently accepting a missing/malformed one. | ||
| if (!payload?.ed_reply_email || typeof payload.ed_reply_email !== 'string' || !payload.ed_reply_email.includes('@')) { | ||
| fieldErrors['ed_reply_email'] = 'A valid ed_reply_email is required'; | ||
| } |
There was a problem hiding this comment.
🗄️ Data Integrity & Integration | 🟡 Minor | ⚡ Quick win
Test-send reply-address contract is inconsistent across three sites. The new validator makes ed_reply_email mandatory while the shared type and the route doc still describe it as omittable; one decision needs to propagate to all of them.
apps/lfx-one/src/server/controllers/newsletter.controller.ts#L565-L571: keep the requirement (and update thetestSendJSDoc at Lines 86-92) or gate it onlayoutValidso HTML-only test sends stay accepted without a reply address.packages/shared/src/interfaces/newsletter.interface.ts#L92-L95: makeed_reply_emailrequired and drop the "Omitted for a test send that carries no reply address" note if the server requirement stands.
📍 Affects 2 files
apps/lfx-one/src/server/controllers/newsletter.controller.ts#L565-L571(this comment)packages/shared/src/interfaces/newsletter.interface.ts#L92-L95
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@apps/lfx-one/src/server/controllers/newsletter.controller.ts` around lines
565 - 571, The test-send reply-address contract must be consistent across
validation, documentation, and shared types. Keep the mandatory ed_reply_email
validation in testSend, update its JSDoc to state that a valid reply address is
required, and make ed_reply_email required in the shared newsletter interface
while removing the omission note.
…etter-composer Signed-off-by: Dan Baker <me@danb.co> # Conflicts: # apps/lfx-one/src/server/controllers/newsletter.controller.ts
There was a problem hiding this comment.
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (1)
apps/lfx-one/src/server/controllers/newsletter.controller.ts (1)
570-580: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick winReject a malformed supplied layout before falling back to
body_html.If a request contains
body_layout: {}and validbody_html,layoutValidis false and this method accepts the request. This differs fromvalidateCommonPayload, which rejects a present layout without ablocksarray. Reject a present invalid layout so the test-send path cannot forward an invalid structured payload upstream.Proposed fix
- const layoutValid = - payload?.body_layout !== undefined && payload?.body_layout !== null && Array.isArray(payload.body_layout.blocks) && payload.body_layout.blocks.length > 0; - if (!layoutValid) { + const layoutPresent = payload?.body_layout !== undefined && payload?.body_layout !== null; + const layoutValid = layoutPresent && Array.isArray(payload.body_layout.blocks) && payload.body_layout.blocks.length > 0; + if (layoutPresent && !layoutValid) { + fieldErrors['body_layout'] = 'A block layout must have a non-empty blocks array'; + } else if (!layoutValid) {🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@apps/lfx-one/src/server/controllers/newsletter.controller.ts` around lines 570 - 580, Update the validation flow around layoutValid so any supplied body_layout that is not an object with a blocks array is rejected before body_html fallback, matching validateCommonPayload behavior. Preserve the existing body_html validation only when body_layout is absent, and continue applying the serialized size limit to valid layouts.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Outside diff comments:
In `@apps/lfx-one/src/server/controllers/newsletter.controller.ts`:
- Around line 570-580: Update the validation flow around layoutValid so any
supplied body_layout that is not an object with a blocks array is rejected
before body_html fallback, matching validateCommonPayload behavior. Preserve the
existing body_html validation only when body_layout is absent, and continue
applying the serialized size limit to valid layouts.
ℹ️ Review info
⚙️ Run configuration
Configuration used: Organization UI
Review profile: CHILL
Plan: Pro
Run ID: 4e8e2547-b1d7-4ec5-96a8-70a3807d9903
📒 Files selected for processing (7)
apps/lfx-one/src/app/modules/newsletters/newsletter-manage/newsletter-manage.component.tsapps/lfx-one/src/app/shared/services/newsletter.service.tsapps/lfx-one/src/server/controllers/newsletter.controller.tsapps/lfx-one/src/server/services/newsletter-service.client.tsapps/lfx-one/src/server/services/newsletter.service.tspackages/shared/src/constants/newsletter.constants.tspackages/shared/src/interfaces/newsletter.interface.ts
🚧 Files skipped from review as they are similar to previous changes (5)
- apps/lfx-one/src/app/shared/services/newsletter.service.ts
- apps/lfx-one/src/server/services/newsletter-service.client.ts
- apps/lfx-one/src/server/services/newsletter.service.ts
- packages/shared/src/interfaces/newsletter.interface.ts
- apps/lfx-one/src/app/modules/newsletters/newsletter-manage/newsletter-manage.component.ts
| }) | ||
| ), | ||
| { initialValue: '' } | ||
| ); |
There was a problem hiding this comment.
Spurious preview fetch emissions
Medium Severity
initServerHtml feeds toObservable from a computed that returns a fresh { layout, wrapperContent } object on every recompute, with no value-based distinctUntilChanged before switchMap. Angular compares computed results with Object.is, so each upstream notification retriggers the debounced renderPreview request even when the layout payload is unchanged.
Triggered by learned rule: computed() returning objects causes spurious toObservable() emissions — use primitive keys
Reviewed by Cursor Bugbot for commit 6ae82e6. Configure here.
🚀 Deployment StatusYour branch has been deployed to: https://ui-pr-1201.dev.v2.cluster.linuxfound.info Deployment Details:
The deployment will be automatically removed when this PR is closed. |
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 23 out of 23 changed files in this pull request and generated no new comments.
Suppressed comments (2)
apps/lfx-one/src/app/modules/newsletters/newsletter-manage/newsletter-manage.component.ts:281
- Blocks-mode test sends are disabled until the draft has been saved:
bodyUsable()requires renderedbody_htmland a clean snapshot. However, this request sends the currentbody_layout, and the newsletter service recompiles that layout for the test send. A new block draft cannot autosave before an audience is selected, so users cannot test it even though test-send has no audience requirement. Gate this action on the live subject/layout content instead.
() => this.subjectFilled() && this.bodyUsable() && this.bodyFilled() && this.hasContext() && this.edEmail().length > 0 && !this.testSending()
apps/lfx-one/src/server/controllers/newsletter.controller.ts:580
- A present but malformed or empty
body_layoutfalls through to the HTML-only branch whenbody_htmlis valid, yet the unchanged layout is still forwarded. The upstream contract treats any presentbody_layoutas the sole layout trigger, so this either bypasses the intended non-empty check or produces an upstream render error instead of a local validation response. Reject invalid present layouts; only fall back tobody_htmlwhen the field is absent/null.
const layoutValid =
payload?.body_layout !== undefined && payload?.body_layout !== null && Array.isArray(payload.body_layout.blocks) && payload.body_layout.blocks.length > 0;
if (!layoutValid) {
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes and found 1 potential issue.
There are 2 total unresolved issues (including 1 from previous review).
❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, have a team admin enable autofix in the Cursor dashboard.
Reviewed by Cursor Bugbot for commit 2f8bc9c. Configure here.
| // the blur commit re-renders and repositions anyway). | ||
| if (!this.editingBlockId()) this.repositionToolbar(); | ||
| }); | ||
| }); |
There was a problem hiding this comment.
Stale inline editors after deselection
Medium Severity
The inline-editing effect only adds contenteditable on the newly selected block and never clears it from the previously selected one. Because renderedBlocks does not depend on selection, a pure selection change leaves the old block editable until some later content-driven re-render replaces its DOM, so more than one block can stay inline-editable at once contrary to the selected-only gating.
Reviewed by Cursor Bugbot for commit 2f8bc9c. Configure here.
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 23 out of 23 changed files in this pull request and generated 4 comments.
Suppressed comments (3)
apps/lfx-one/src/server/controllers/newsletter.controller.ts:210
- The project UID extraction and logger setup are outside the
try, so either synchronous throw bypassesnext(error)under Express 4 and can leave the request unresolved. Keep the whole handler setup in the guarded block, matchingrenderPreview.
const projectUid = this.requireProjectUid(req);
const templateKey = String(req.params['templateKey'] || '').trim();
const startTime = logger.startOperation(req, 'newsletter_template_manifest', { project_uid: projectUid, template_key: templateKey });
apps/lfx-one/src/server/controllers/newsletter.controller.ts:592
- This unconditional check contradicts the new shared contract, which makes
ed_reply_emailoptional and explicitly permits omission when there is no reply address; the upstream test-send DTO is optional too. As written, valid clients following that contract receive a BFF 400. Validate the address only when it is supplied (or make it required consistently across the interface and upstream contract).
// Parity with validateCommonPayload: the client sends ed_reply_email on every
// test send, and a layout test recompiles the wrapper's "To reply, email …"
// row (and the email's reply-to) from it, so require a valid address here too
// rather than silently accepting a missing/malformed one.
if (!payload?.ed_reply_email || typeof payload.ed_reply_email !== 'string' || !payload.ed_reply_email.includes('@')) {
fieldErrors['ed_reply_email'] = 'A valid ed_reply_email is required';
}
apps/lfx-one/src/app/modules/newsletters/components/newsletter-block-composer/newsletter-block-composer.component.ts:1353
- The form/test-send path carries a reply address, but preview wrapper content never supplies
edition.reply_email. Both restored upstream wrappers guard their “To reply, email …” row on that value, so the canvas and authoritative server preview omit content that appears in real/test sends; the reported size/source is therefore understated. Pass the current reply address into the composer and include it here.
return {
edition: {
date: 'Newsletter preview',
view_online_link: '',
unsubscribe_url: '#',
manage_subscriptions_url: '#',
},
| public async getTemplates(req: Request, res: Response, next: NextFunction): Promise<void> { | ||
| const projectUid = this.requireProjectUid(req); | ||
| const startTime = logger.startOperation(req, 'newsletter_templates_list', { project_uid: projectUid }); | ||
|
|
||
| try { | ||
| const result = await this.newsletterService.getTemplates(req, projectUid); | ||
| logger.success(req, 'newsletter_templates_list', startTime, { count: result.templates.length }); | ||
| res.json(result); | ||
| } catch (error) { | ||
| next(error); | ||
| } | ||
| } |
| const layoutValid = | ||
| payload?.body_layout !== undefined && payload?.body_layout !== null && Array.isArray(payload.body_layout.blocks) && payload.body_layout.blocks.length > 0; | ||
| if (!layoutValid) { | ||
| if (!payload?.body_html || typeof payload.body_html !== 'string' || payload.body_html.trim().length === 0) { | ||
| fieldErrors['body_html'] = 'Body is required'; | ||
| } else if (payload.body_html.length > NEWSLETTER_BODY_MAX_LENGTH) { | ||
| fieldErrors['body_html'] = `Body must be ${NEWSLETTER_BODY_MAX_LENGTH} characters or fewer`; | ||
| } | ||
| } else if (JSON.stringify(payload.body_layout).length > NEWSLETTER_BODY_LAYOUT_MAX_LENGTH) { | ||
| fieldErrors['body_layout'] = `Layout must be ${NEWSLETTER_BODY_LAYOUT_MAX_LENGTH} characters or fewer when serialized`; | ||
| } |
| this.form().get('bodyLayout')?.setValue(null); | ||
| // Also clear the derived body_html: it holds the layout's COMPLETE | ||
| // server-rendered emitter email, which — kept as "simple" content with a | ||
| // null layout — the send path would double-wrap in the legacy chrome. The | ||
| // discard was already confirmed, so the simple editor starts empty. | ||
| this.form().get('bodyHtml')?.setValue(''); |
| public async getTemplates(req: Request, projectUid: string): Promise<NewsletterTemplatesResponse> { | ||
| return this.microserviceProxy.proxyRequest<NewsletterTemplatesResponse>(req, 'LFX_V2_SERVICE', `/projects/${projectUid}/newsletters/templates`, 'GET'); |


Restores #1133 (reverted from
mainvia #1200 to unblock scheduled releases).This is a draft — stays off the release path until testing completes on this branch. Content is identical to the original #1133 (cherry-picked, authorship preserved).
Refs: LFXV2-2386