berrypod/docs/plans/notification-overhaul.md
jamey 48eb7a9d9c
All checks were successful
deploy / deploy (push) Successful in 1m22s
update notification system docs as implementation reference
Rewrote planning doc as a reference guide with:
- decision tree for choosing feedback type
- implementation patterns with code examples
- accessibility requirements
- common mistakes to avoid

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-03-08 07:55:55 +00:00

216 lines
6.4 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# Notification system
Status: Complete
This document describes the notification and feedback patterns used throughout the app. Follow these rules when implementing new features or modifying existing ones.
## Core principles
1. **No floating toasts** — fixed-position overlays are removed entirely
2. **Inline feedback for form saves** — contextual, next to the action
3. **Banners for page-level outcomes** — document flow, persistent until dismissed
4. **Progressive enhancement** — works without JS, enhanced with LiveView
## Components
### `<.inline_feedback>` — contextual save status
Used for form saves that stay on the same page. Shows status next to the save button.
```heex
<.button type="submit">Save</.button>
<.inline_feedback status={@save_status} />
```
**States:**
- `:idle` — hidden
- `:saving` — spinner + "Saving..."
- `:saved` — green tick + "Saved"
- `:error` — red icon + "Something went wrong" (or custom message)
**Implementation pattern:**
```elixir
# Mount
assign(socket, :save_status, :idle)
# Validate event — clear status on form change
def handle_event("validate", params, socket) do
{:noreply, assign(socket, save_status: :idle)}
end
# Save event
def handle_event("save", params, socket) do
case Context.save(params) do
{:ok, _} -> {:noreply, assign(socket, :save_status, :saved)}
{:error, _} -> {:noreply, assign(socket, :save_status, :error)}
end
end
```
**When to use:** Theme editor, page editor, settings forms, media metadata, product details, navigation editor, newsletter drafts.
### `<.flash>` / `<.shop_flash_group>` — page-level banners
Used for outcomes that affect the whole page or result from navigation. Renders in document flow (pushes content down), not fixed overlay.
```elixir
socket
|> put_flash(:info, "Product sync started")
|> push_navigate(to: ~p"/admin/products")
```
**Behaviour:**
- Persistent until dismissed (close button)
- Clears automatically on next navigation
- Info messages use `role="status"` + `aria-live="polite"`
- Error messages use `role="alert"` + `aria-live="assertive"`
**When to use:**
- Operation outcomes: "Sync started", "Page deleted", "Campaign sent"
- Auth messages: "Welcome back!", "Logged out"
- Connection/provider status changes
- Any action that navigates to a new page
## Decision tree
```
Is this feedback for a form save that stays on the page?
├─ YES → Use inline_feedback
│ - Add :save_status assign
│ - Clear to :idle on validate
│ - Set to :saved/:error on submit
└─ NO → Does the action navigate away or affect the whole page?
├─ YES → Use put_flash (banner)
│ - :info for success
│ - :error for failures
└─ NO → Use inline_feedback anyway
(contextual is usually better)
```
## Progressive enhancement
Forms should work without JavaScript. The pattern:
1. Form has both `action` (controller POST) and `phx-submit` (LiveView)
2. With JS: LiveView handles the event, shows inline feedback
3. Without JS: Form POSTs to controller, redirects with flash
```heex
<.form
for={@form}
action={~p"/admin/settings/email"}
method="post"
phx-change="validate"
phx-submit="save"
>
```
The controller sets a flash and redirects:
```elixir
def update(conn, params) do
case Context.save(params) do
{:ok, _} ->
conn
|> put_flash(:info, "Settings saved")
|> redirect(to: ~p"/admin/settings")
{:error, changeset} ->
render(conn, :edit, form: to_form(changeset))
end
end
```
**Result:** With JS, user sees instant inline feedback. Without JS, user gets a banner after redirect.
## Accessibility requirements
All notification components must include:
1. **Appropriate role:**
- `role="status"` for info/success (non-urgent)
- `role="alert"` for errors (urgent)
2. **aria-live attribute:**
- `aria-live="polite"` for info/success
- `aria-live="assertive"` for errors
3. **Dismiss buttons:**
- Must be actual `<button>` elements
- Include `aria-label="Close"`
- Icon must have `aria-hidden="true"`
## Files
- **Component:** `lib/berrypod_web/components/core_components.ex``inline_feedback/1`, `flash/1`
- **Shop flash:** `lib/berrypod_web/components/shop_components/content.ex``shop_flash_group/1`
- **Admin CSS:** `assets/css/admin/core.css``.admin-inline-feedback`, `.admin-banner`, `.admin-alert`
- **Shop CSS:** `assets/css/shop/components.css``.shop-flash`
## Examples in codebase
| Page | Feedback type | File |
|------|--------------|------|
| Theme editor | inline_feedback | `lib/berrypod_web/live/admin/theme/index.ex` |
| Page editor | inline_feedback | `lib/berrypod_web/live/admin/pages/editor.ex` |
| Settings | inline_feedback | `lib/berrypod_web/live/admin/settings.ex` |
| Email settings | inline_feedback | `lib/berrypod_web/live/admin/email_settings.ex` |
| Media library | inline_feedback | `lib/berrypod_web/live/admin/media.ex` |
| Product show | inline_feedback | `lib/berrypod_web/live/admin/product_show.ex` |
| Navigation | inline_feedback | `lib/berrypod_web/live/admin/navigation.ex` |
| Newsletter | inline_feedback | `lib/berrypod_web/live/admin/newsletter/campaign_form.ex` |
| Provider form | flash (navigates) | `lib/berrypod_web/live/admin/providers/form.ex` |
| Contact | flash (navigates) | `lib/berrypod_web/live/shop/contact.ex` |
## Common mistakes
**Wrong:** Using `put_flash` for a form save that stays on the page
```elixir
# Don't do this
def handle_event("save", params, socket) do
{:ok, _} = Context.save(params)
{:noreply, put_flash(socket, :info, "Saved")}
end
```
**Right:** Using inline feedback
```elixir
def handle_event("save", params, socket) do
{:ok, _} = Context.save(params)
{:noreply, assign(socket, :save_status, :saved)}
end
```
**Wrong:** Forgetting to clear status on validate
```elixir
# Status stays "Saved" even while user is editing
def handle_event("validate", params, socket) do
{:noreply, assign(socket, :form, to_form(params))}
end
```
**Right:** Clear status when form changes
```elixir
def handle_event("validate", params, socket) do
{:noreply, assign(socket, form: to_form(params), save_status: :idle)}
end
```
**Wrong:** Making flash the whole-div clickable
```heex
<div phx-click={dismiss()}>
<p>{msg}</p>
<button>×</button> <!-- button does nothing -->
</div>
```
**Right:** Click handler on the close button
```heex
<div>
<p>{msg}</p>
<button phx-click={dismiss()} aria-label="Close">×</button>
</div>
```