berrypod/docs/plans/notification-overhaul.md

216 lines
6.4 KiB
Markdown
Raw Normal View History

# 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>
```