Add CODE_GUIDELINES.md, database schema docs, and development-progress-log.md. Refactor README.md to eliminate redundant information by linking to detailed docs. Establish clear documentation hierarchy for better maintainability.
2576 lines
55 KiB
Markdown
2576 lines
55 KiB
Markdown
# Code Guidelines and Best Practices
|
|
|
|
## Purpose
|
|
|
|
This document serves as a foundational reference for our development team, ensuring consistency, maintainability, and quality in our codebase. It defines standards and best practices for building the Mila membership management application.
|
|
|
|
## Project Context
|
|
|
|
We are building a membership management system (Mila) using the following technology stack:
|
|
|
|
**Backend & Runtime:**
|
|
- Elixir `~> 1.15` (currently 1.18.3-otp-27)
|
|
- Erlang/OTP 27.3.4
|
|
- Phoenix Framework `~> 1.8.0`
|
|
- Ash Framework `~> 3.0`
|
|
- AshPostgres `~> 2.0`
|
|
- Ecto `~> 3.10`
|
|
- Postgrex `>= 0.0.0`
|
|
- AshAuthentication `~> 4.9`
|
|
- AshAuthenticationPhoenix `~> 2.10`
|
|
- bcrypt_elixir `~> 3.0`
|
|
|
|
**Frontend & UI:**
|
|
- Phoenix LiveView `~> 1.1.0`
|
|
- Phoenix HTML `~> 4.1`
|
|
- Tailwind CSS 4.0.9
|
|
- DaisyUI (as Tailwind plugin)
|
|
- Heroicons v2.2.0
|
|
- JavaScript (ES2022)
|
|
- esbuild `~> 0.9`
|
|
|
|
**Database:**
|
|
- PostgreSQL 17.6 (dev), 16 (prod)
|
|
|
|
**Testing:**
|
|
- ExUnit (built-in)
|
|
- Ecto.Adapters.SQL.Sandbox
|
|
|
|
**Development Tools:**
|
|
- asdf 0.16.5 (version management)
|
|
- Just 1.43.0 (task runner)
|
|
- Credo `~> 1.7` (code analysis)
|
|
- Sobelow `~> 0.14` (security analysis)
|
|
- mix_audit `~> 2.1` (dependency audit)
|
|
|
|
**Infrastructure:**
|
|
- Docker & Docker Compose
|
|
- Bandit `~> 1.5` (HTTP server)
|
|
|
|
---
|
|
|
|
## Table of Contents
|
|
|
|
1. [Setup and Architectural Conventions](#1-setup-and-architectural-conventions)
|
|
2. [Coding Standards and Style](#2-coding-standards-and-style)
|
|
3. [Tooling Guidelines](#3-tooling-guidelines)
|
|
4. [Testing Standards](#4-testing-standards)
|
|
5. [Security Guidelines](#5-security-guidelines)
|
|
6. [Performance Best Practices](#6-performance-best-practices)
|
|
7. [Documentation Standards](#7-documentation-standards)
|
|
8. [Accessibility Guidelines](#8-accessibility-guidelines)
|
|
|
|
---
|
|
|
|
## 1. Setup and Architectural Conventions
|
|
|
|
### 1.1 Project Structure
|
|
|
|
Our project follows a domain-driven design approach using Phoenix contexts and Ash domains:
|
|
|
|
```
|
|
lib/
|
|
├── accounts/ # Accounts domain (AshAuthentication)
|
|
│ ├── accounts.ex # Domain definition
|
|
│ ├── user.ex # User resource
|
|
│ ├── token.ex # Token resource
|
|
│ ├── user_identity.exs # User identity helpers
|
|
│ └── user/ # User-related modules
|
|
│ ├── changes/ # Ash changes for user
|
|
│ └── preparations/ # Ash preparations for user
|
|
├── membership/ # Membership domain
|
|
│ ├── membership.ex # Domain definition
|
|
│ ├── member.ex # Member resource
|
|
│ ├── property.ex # Custom property resource
|
|
│ ├── property_type.ex # Property type resource
|
|
│ └── email.ex # Email custom type
|
|
├── mv/ # Core application modules
|
|
│ ├── accounts/ # Domain-specific logic
|
|
│ │ └── user/
|
|
│ │ ├── senders/ # Email senders for user actions
|
|
│ │ └── validations/
|
|
│ ├── email_sync/ # Email synchronization logic
|
|
│ │ ├── changes/ # Sync changes
|
|
│ │ ├── helpers.ex # Sync helper functions
|
|
│ │ └── loader.ex # Data loaders
|
|
│ ├── membership/ # Domain-specific logic
|
|
│ │ └── member/
|
|
│ │ └── validations/
|
|
│ ├── application.ex # OTP application
|
|
│ ├── mailer.ex # Email mailer
|
|
│ ├── release.ex # Release tasks
|
|
│ ├── repo.ex # Database repository
|
|
│ └── secrets.ex # Secret management
|
|
├── mv_web/ # Web interface layer
|
|
│ ├── components/ # UI components
|
|
│ │ ├── core_components.ex
|
|
│ │ ├── table_components.ex
|
|
│ │ ├── layouts.ex
|
|
│ │ └── layouts/ # Layout templates
|
|
│ │ ├── navbar.ex
|
|
│ │ └── root.html.heex
|
|
│ ├── controllers/ # HTTP controllers
|
|
│ │ ├── auth_controller.ex
|
|
│ │ ├── page_controller.ex
|
|
│ │ ├── locale_controller.ex
|
|
│ │ ├── error_html.ex
|
|
│ │ ├── error_json.ex
|
|
│ │ └── page_html/
|
|
│ ├── live/ # LiveView modules
|
|
│ │ ├── components/ # LiveView-specific components
|
|
│ │ │ ├── search_bar_component.ex
|
|
│ │ │ └── sort_header_component.ex
|
|
│ │ ├── member_live/ # Member CRUD LiveViews
|
|
│ │ ├── property_live/ # Property CRUD LiveViews
|
|
│ │ ├── property_type_live/
|
|
│ │ └── user_live/ # User management LiveViews
|
|
│ ├── auth_overrides.ex # AshAuthentication overrides
|
|
│ ├── endpoint.ex # Phoenix endpoint
|
|
│ ├── gettext.ex # I18n configuration
|
|
│ ├── live_helpers.ex # LiveView helpers
|
|
│ ├── live_user_auth.ex # LiveView authentication
|
|
│ ├── router.ex # Application router
|
|
│ └── telemetry.ex # Telemetry configuration
|
|
├── mv_web.ex # Web module definition
|
|
└── mv.ex # Application module definition
|
|
|
|
test/
|
|
├── accounts/ # Accounts domain tests
|
|
│ ├── user_test.exs
|
|
│ ├── email_sync_edge_cases_test.exs
|
|
│ ├── email_uniqueness_test.exs
|
|
│ ├── user_email_sync_test.exs
|
|
│ ├── user_member_deletion_test.exs
|
|
│ └── user_member_relationship_test.exs
|
|
├── membership/ # Membership domain tests
|
|
│ ├── member_test.exs
|
|
│ └── member_email_sync_test.exs
|
|
├── mv_web/ # Web layer tests
|
|
│ ├── components/ # Component tests
|
|
│ │ ├── layouts/
|
|
│ │ │ └── navbar_test.exs
|
|
│ │ ├── search_bar_component_test.exs
|
|
│ │ └── sort_header_component_test.exs
|
|
│ ├── controllers/ # Controller tests
|
|
│ │ ├── auth_controller_test.exs
|
|
│ │ ├── error_html_test.exs
|
|
│ │ ├── error_json_test.exs
|
|
│ │ ├── oidc_integration_test.exs
|
|
│ │ └── page_controller_test.exs
|
|
│ ├── live/ # LiveView tests
|
|
│ │ └── profile_navigation_test.exs
|
|
│ ├── member_live/ # Member LiveView tests
|
|
│ │ └── index_test.exs
|
|
│ ├── user_live/ # User LiveView tests
|
|
│ │ ├── form_test.exs
|
|
│ │ └── index_test.exs
|
|
│ └── locale_test.exs
|
|
├── seeds_test.exs # Database seed tests
|
|
└── support/ # Test helpers
|
|
├── conn_case.ex # Controller test helpers
|
|
└── data_case.ex # Data layer test helpers
|
|
```
|
|
|
|
### 1.2 Module Organization
|
|
|
|
**Module Naming:**
|
|
|
|
- **Modules:** Use `PascalCase` with full namespace (e.g., `Mv.Accounts.User`)
|
|
- **Domains:** Top-level domains are `Mv.Accounts` and `Mv.Membership`
|
|
- **Resources:** Resource modules should be singular nouns (e.g., `Member`, not `Members`)
|
|
- **Context functions:** Use `snake_case` and verb-first naming (e.g., `create_user`, `list_members`)
|
|
|
|
**Module Structure:**
|
|
|
|
```elixir
|
|
defmodule Mv.Membership.Member do
|
|
@moduledoc """
|
|
Represents a club member with their personal information and membership status.
|
|
"""
|
|
|
|
use Ash.Resource,
|
|
domain: Mv.Membership,
|
|
data_layer: AshPostgres.DataLayer
|
|
|
|
# 1. Ash DSL sections in order (see Spark formatter config)
|
|
admin do
|
|
# ...
|
|
end
|
|
|
|
postgres do
|
|
# ...
|
|
end
|
|
|
|
resource do
|
|
# ...
|
|
end
|
|
|
|
code_interface do
|
|
# ...
|
|
end
|
|
|
|
actions do
|
|
# ...
|
|
end
|
|
|
|
policies do
|
|
# ...
|
|
end
|
|
|
|
attributes do
|
|
# ...
|
|
end
|
|
|
|
relationships do
|
|
# ...
|
|
end
|
|
|
|
# 2. Public functions
|
|
|
|
# 3. Private functions
|
|
end
|
|
```
|
|
|
|
### 1.3 Domain-Driven Design
|
|
|
|
**Use Ash Domains for Context Boundaries:**
|
|
|
|
Each domain should:
|
|
- Have a clear boundary and responsibility
|
|
- Define a public API through code interfaces
|
|
- Encapsulate business logic within resources
|
|
- Handle cross-domain communication explicitly
|
|
|
|
Example domain definition:
|
|
|
|
```elixir
|
|
defmodule Mv.Membership do
|
|
use Ash.Domain,
|
|
extensions: [AshAdmin.Domain, AshPhoenix]
|
|
|
|
admin do
|
|
show? true
|
|
end
|
|
|
|
resources do
|
|
resource Mv.Membership.Member do
|
|
define :create_member, action: :create_member
|
|
define :list_members, action: :read
|
|
define :update_member, action: :update_member
|
|
define :destroy_member, action: :destroy
|
|
end
|
|
end
|
|
end
|
|
```
|
|
|
|
### 1.4 Dependency Management
|
|
|
|
- **Use `mix.exs` for all dependencies:** Define versions explicitly
|
|
- **Keep dependencies up to date:** Use Renovate for automated updates
|
|
- **Version management:** Use `asdf` with `.tool-versions` for consistent environments
|
|
|
|
### 1.5 Scalability Considerations
|
|
|
|
- **Database indexing:** Add indexes for frequently queried fields
|
|
- **Pagination:** Use Ash's keyset pagination for large datasets (default configured)
|
|
- **Background jobs:** Plan for Oban or similar for async processing
|
|
- **Caching:** Consider caching strategies for expensive operations
|
|
- **Process design:** Use OTP principles (GenServers, Supervisors) for stateful components
|
|
|
|
---
|
|
|
|
## 2. Coding Standards and Style
|
|
|
|
### 2.1 Code Formatting
|
|
|
|
**Use `mix format` for all Elixir code:**
|
|
|
|
```bash
|
|
mix format
|
|
```
|
|
|
|
**Key formatting rules:**
|
|
- **Indentation:** 2 spaces (no tabs)
|
|
- **Line length:** Maximum 120 characters (configured in `.credo.exs`)
|
|
- **Trailing whitespace:** Not allowed
|
|
- **File endings:** Always include trailing newline
|
|
|
|
**Naming Conventions Summary:**
|
|
|
|
- **Elixir:** Use `snake_case` for functions/variables, `PascalCase` for modules
|
|
- **Phoenix:** Controllers end with `Controller`, LiveViews end with `Live`
|
|
- **Ash:** Resources are singular nouns, actions are verb-first (`:create_member`)
|
|
- **Files:** Match module names in `snake_case` (`user_controller.ex` for `UserController`)
|
|
|
|
### 2.2 Function Design
|
|
|
|
**Verb-First Function Names:**
|
|
|
|
```elixir
|
|
# Good
|
|
def create_user(attrs)
|
|
def list_members(query)
|
|
def send_email(recipient, content)
|
|
|
|
# Avoid
|
|
def user_create(attrs)
|
|
def members_list(query)
|
|
def email_send(recipient, content)
|
|
```
|
|
|
|
**Use Pattern Matching in Function Heads:**
|
|
|
|
```elixir
|
|
# Good - multiple clauses with pattern matching
|
|
def handle_result({:ok, user}), do: {:ok, user}
|
|
def handle_result({:error, reason}), do: log_and_return_error(reason)
|
|
|
|
# Avoid - case/cond when pattern matching suffices
|
|
def handle_result(result) do
|
|
case result do
|
|
{:ok, user} -> {:ok, user}
|
|
{:error, reason} -> log_and_return_error(reason)
|
|
end
|
|
end
|
|
```
|
|
|
|
**Keep Functions Small and Focused:**
|
|
|
|
- Aim for functions under 20 lines
|
|
- Each function should have a single responsibility
|
|
- Extract complex logic into private helper functions
|
|
|
|
**Use Guard Clauses for Early Returns:**
|
|
|
|
```elixir
|
|
def process_user(nil), do: {:error, :user_not_found}
|
|
def process_user(%{active: false}), do: {:error, :user_inactive}
|
|
def process_user(user), do: {:ok, perform_action(user)}
|
|
```
|
|
|
|
### 2.3 Error Handling
|
|
|
|
**Use Tagged Tuples:**
|
|
|
|
```elixir
|
|
# Standard pattern
|
|
{:ok, result} | {:error, reason}
|
|
|
|
# Examples
|
|
def create_member(attrs) do
|
|
case Ash.create(Member, attrs) do
|
|
{:ok, member} -> {:ok, member}
|
|
{:error, error} -> {:error, error}
|
|
end
|
|
end
|
|
```
|
|
|
|
**Use `with` for Complex Operations:**
|
|
|
|
```elixir
|
|
def register_user(params) do
|
|
with {:ok, validated} <- validate_params(params),
|
|
{:ok, user} <- create_user(validated),
|
|
{:ok, _email} <- send_welcome_email(user) do
|
|
{:ok, user}
|
|
else
|
|
{:error, reason} -> {:error, reason}
|
|
end
|
|
end
|
|
```
|
|
|
|
**Let It Crash (with Supervision):**
|
|
|
|
Don't defensively program against every possible error. Use supervisors to handle process failures:
|
|
|
|
```elixir
|
|
# In your application.ex
|
|
children = [
|
|
Mv.Repo,
|
|
MvWeb.Endpoint,
|
|
{Phoenix.PubSub, name: Mv.PubSub}
|
|
]
|
|
|
|
Supervisor.start_link(children, strategy: :one_for_one)
|
|
```
|
|
|
|
### 2.4 Functional Programming Principles
|
|
|
|
**Immutability:**
|
|
|
|
```elixir
|
|
# Good - return new data structures
|
|
def add_role(user, role) do
|
|
%{user | roles: [role | user.roles]}
|
|
end
|
|
|
|
# Avoid - mutation (not possible in Elixir anyway)
|
|
# This is just conceptual - Elixir prevents mutation
|
|
```
|
|
|
|
**Pure Functions:**
|
|
|
|
Write functions that:
|
|
- Return the same output for the same input
|
|
- Have no side effects
|
|
- Are easier to test and reason about
|
|
|
|
```elixir
|
|
# Pure function
|
|
def calculate_total(items) do
|
|
Enum.reduce(items, 0, fn item, acc -> acc + item.price end)
|
|
end
|
|
|
|
# Impure function (side effects)
|
|
def create_and_log_user(attrs) do
|
|
Logger.info("Creating user: #{inspect(attrs)}") # Side effect
|
|
Ash.create!(User, attrs) # Side effect
|
|
end
|
|
```
|
|
|
|
**Pipe Operator:**
|
|
|
|
Use the pipe operator `|>` for transformation chains:
|
|
|
|
```elixir
|
|
# Good
|
|
def process_members(query) do
|
|
query
|
|
|> filter_active()
|
|
|> sort_by_name()
|
|
|> limit_results(10)
|
|
end
|
|
|
|
# Avoid
|
|
def process_members(query) do
|
|
limit_results(sort_by_name(filter_active(query)), 10)
|
|
end
|
|
```
|
|
|
|
### 2.5 Elixir-Specific Patterns
|
|
|
|
**Avoid Using Else with Unless:**
|
|
|
|
```elixir
|
|
# Good
|
|
unless user.admin? do
|
|
{:error, :unauthorized}
|
|
end
|
|
|
|
# Avoid - confusing
|
|
unless user.admin? do
|
|
{:error, :unauthorized}
|
|
else
|
|
perform_admin_action()
|
|
end
|
|
```
|
|
|
|
**Use `Enum` over List Comprehensions for Clarity:**
|
|
|
|
```elixir
|
|
# Preferred for readability
|
|
users
|
|
|> Enum.filter(&(&1.active))
|
|
|> Enum.map(&(&1.name))
|
|
|
|
# List comprehension (use when more concise)
|
|
for user <- users, user.active, do: user.name
|
|
```
|
|
|
|
**String Concatenation:**
|
|
|
|
```elixir
|
|
# Good - interpolation
|
|
"Hello, #{user.name}!"
|
|
|
|
# Avoid - concatenation with <>
|
|
"Hello, " <> user.name <> "!"
|
|
```
|
|
|
|
---
|
|
|
|
## 3. Tooling Guidelines
|
|
|
|
### 3.1 Elixir & Erlang/OTP
|
|
|
|
**Version Management with asdf:**
|
|
|
|
Always use the versions specified in `.tool-versions`:
|
|
|
|
```bash
|
|
# Install correct versions
|
|
asdf install
|
|
|
|
# Verify versions
|
|
elixir --version # Should show 1.18.3
|
|
erl -version # Should show 27.3.4
|
|
```
|
|
|
|
**OTP Application Design:**
|
|
|
|
```elixir
|
|
defmodule Mv.Application do
|
|
use Application
|
|
|
|
def start(_type, _args) do
|
|
children = [
|
|
# Start the database repository
|
|
Mv.Repo,
|
|
# Start the Telemetry supervisor
|
|
MvWeb.Telemetry,
|
|
# Start the PubSub system
|
|
{Phoenix.PubSub, name: Mv.PubSub},
|
|
# Start the Endpoint
|
|
MvWeb.Endpoint
|
|
]
|
|
|
|
opts = [strategy: :one_for_one, name: Mv.Supervisor]
|
|
Supervisor.start_link(children, opts)
|
|
end
|
|
end
|
|
```
|
|
|
|
### 3.2 Phoenix Framework
|
|
|
|
**Context-Based Organization:**
|
|
|
|
- Use contexts to define API boundaries
|
|
- Keep controllers thin - delegate to contexts or Ash actions
|
|
- Avoid direct Repo/Ecto calls in controllers
|
|
|
|
```elixir
|
|
# Good - thin controller
|
|
defmodule MvWeb.MemberController do
|
|
use MvWeb, :controller
|
|
|
|
def create(conn, %{"member" => member_params}) do
|
|
case Mv.Membership.create_member(member_params) do
|
|
{:ok, member} ->
|
|
conn
|
|
|> put_flash(:info, "Member created successfully.")
|
|
|> redirect(to: ~p"/members/#{member}")
|
|
|
|
{:error, error} ->
|
|
conn
|
|
|> put_flash(:error, "Failed to create member.")
|
|
|> render(:new, error: error)
|
|
end
|
|
end
|
|
end
|
|
```
|
|
|
|
**Phoenix LiveView Best Practices:**
|
|
|
|
```elixir
|
|
defmodule MvWeb.MemberLive.Index do
|
|
use MvWeb, :live_view
|
|
|
|
# Use mount for initial setup
|
|
def mount(_params, _session, socket) do
|
|
{:ok, assign(socket, members: [], loading: true)}
|
|
end
|
|
|
|
# Use handle_params for URL parameter handling
|
|
def handle_params(params, _url, socket) do
|
|
{:noreply, apply_action(socket, socket.assigns.live_action, params)}
|
|
end
|
|
|
|
# Use handle_event for user interactions
|
|
def handle_event("delete", %{"id" => id}, socket) do
|
|
# Handle deletion
|
|
{:noreply, socket}
|
|
end
|
|
|
|
# Use handle_info for asynchronous messages
|
|
def handle_info({:member_updated, member}, socket) do
|
|
{:noreply, update_member_in_list(socket, member)}
|
|
end
|
|
end
|
|
```
|
|
|
|
**Component Design:**
|
|
|
|
```elixir
|
|
# Function components for stateless UI elements
|
|
def button(assigns) do
|
|
~H"""
|
|
<button class="btn btn-primary" {@rest}>
|
|
<%= render_slot(@inner_block) %>
|
|
</button>
|
|
"""
|
|
end
|
|
|
|
# Use attrs and slots for documentation
|
|
attr :id, :string, required: true
|
|
attr :title, :string, default: nil
|
|
slot :inner_block, required: true
|
|
|
|
def card(assigns) do
|
|
~H"""
|
|
<div id={@id} class="card">
|
|
<h2 :if={@title}><%= @title %></h2>
|
|
<%= render_slot(@inner_block) %>
|
|
</div>
|
|
"""
|
|
end
|
|
```
|
|
|
|
### 3.3 Ash Framework
|
|
|
|
**Resource Definition Best Practices:**
|
|
|
|
```elixir
|
|
defmodule Mv.Membership.Member do
|
|
use Ash.Resource,
|
|
domain: Mv.Membership,
|
|
data_layer: AshPostgres.DataLayer
|
|
|
|
# Follow section order from Spark formatter config
|
|
postgres do
|
|
table "members"
|
|
repo Mv.Repo
|
|
end
|
|
|
|
attributes do
|
|
uuid_primary_key :id
|
|
|
|
attribute :first_name, :string do
|
|
allow_nil? false
|
|
public? true
|
|
end
|
|
|
|
attribute :email, :string do
|
|
allow_nil? false
|
|
public? true
|
|
end
|
|
|
|
timestamps()
|
|
end
|
|
|
|
actions do
|
|
# Define specific actions instead of using defaults.accept_all
|
|
create :create_member do
|
|
accept [:first_name, :last_name, :email]
|
|
|
|
change fn changeset, _context ->
|
|
# Custom validation or transformation
|
|
changeset
|
|
end
|
|
end
|
|
|
|
read :read do
|
|
primary? true
|
|
end
|
|
|
|
update :update_member do
|
|
accept [:first_name, :last_name, :email]
|
|
end
|
|
|
|
destroy :destroy
|
|
end
|
|
|
|
code_interface do
|
|
define :create_member
|
|
define :list_members, action: :read
|
|
define :update_member
|
|
define :destroy_member, action: :destroy
|
|
end
|
|
|
|
identities do
|
|
identity :unique_email, [:email]
|
|
end
|
|
end
|
|
```
|
|
|
|
**Ash Policies:**
|
|
|
|
```elixir
|
|
policies do
|
|
# Admin can do everything
|
|
policy action_type([:read, :create, :update, :destroy]) do
|
|
authorize_if actor_attribute_equals(:role, :admin)
|
|
end
|
|
|
|
# Users can only read and update their own data
|
|
policy action_type([:read, :update]) do
|
|
authorize_if relates_to_actor_via(:user)
|
|
end
|
|
end
|
|
```
|
|
|
|
**Ash Validations:**
|
|
|
|
```elixir
|
|
validations do
|
|
validate present(:email), on: [:create, :update]
|
|
validate match(:email, ~r/@/), message: "must be a valid email"
|
|
validate string_length(:first_name, min: 2, max: 100)
|
|
end
|
|
```
|
|
|
|
### 3.4 AshPostgres & Ecto
|
|
|
|
**Migrations with Ash:**
|
|
|
|
```bash
|
|
# Generate migration for all changes
|
|
mix ash.codegen --name add_members_table
|
|
|
|
# Apply migrations
|
|
mix ash.setup
|
|
```
|
|
|
|
**Repository Configuration:**
|
|
|
|
```elixir
|
|
defmodule Mv.Repo do
|
|
use AshPostgres.Repo,
|
|
otp_app: :mv
|
|
|
|
# Install PostgreSQL extensions
|
|
def installed_extensions do
|
|
["citext", "uuid-ossp"]
|
|
end
|
|
end
|
|
```
|
|
|
|
**Avoid N+1 Queries:**
|
|
|
|
```elixir
|
|
# Good - preload relationships
|
|
members =
|
|
Member
|
|
|> Ash.Query.load(:properties)
|
|
|> Mv.Membership.list_members!()
|
|
|
|
# Avoid - causes N+1 queries
|
|
members = Mv.Membership.list_members!()
|
|
Enum.map(members, fn member ->
|
|
# This triggers a query for each member
|
|
Ash.load!(member, :properties)
|
|
end)
|
|
```
|
|
|
|
### 3.5 Authentication (AshAuthentication)
|
|
|
|
**Resource with Authentication:**
|
|
|
|
```elixir
|
|
defmodule Mv.Accounts.User do
|
|
use Ash.Resource,
|
|
domain: Mv.Accounts,
|
|
data_layer: AshPostgres.DataLayer,
|
|
extensions: [AshAuthentication]
|
|
|
|
authentication do
|
|
strategies do
|
|
password :password do
|
|
identity_field :email
|
|
hashed_password_field :hashed_password
|
|
end
|
|
|
|
oauth2 :rauthy do
|
|
client_id fn _, _ ->
|
|
Application.fetch_env!(:mv, :rauthy)[:client_id]
|
|
end
|
|
# ... other config
|
|
end
|
|
end
|
|
end
|
|
end
|
|
```
|
|
|
|
### 3.6 Frontend: Tailwind CSS & DaisyUI
|
|
|
|
**Utility-First Approach:**
|
|
|
|
```heex
|
|
<!-- Good - use utility classes -->
|
|
<div class="card bg-base-100 shadow-xl">
|
|
<div class="card-body">
|
|
<h2 class="card-title">Member Name</h2>
|
|
<p class="text-sm text-gray-600">Email: member@example.com</p>
|
|
<div class="card-actions justify-end">
|
|
<button class="btn btn-primary">Edit</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Avoid - custom CSS for standard patterns -->
|
|
<div class="custom-member-card">
|
|
<h2 class="custom-title">Member Name</h2>
|
|
<!-- ... -->
|
|
</div>
|
|
```
|
|
|
|
**Responsive Design:**
|
|
|
|
```heex
|
|
<!-- Use Tailwind's responsive prefixes -->
|
|
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
|
<%= for member <- @members do %>
|
|
<.member_card member={member} />
|
|
<% end %>
|
|
</div>
|
|
```
|
|
|
|
**DaisyUI Components:**
|
|
|
|
```heex
|
|
<!-- Leverage DaisyUI component classes -->
|
|
<div class="navbar bg-base-100">
|
|
<div class="navbar-start">
|
|
<a class="btn btn-ghost text-xl">Mila</a>
|
|
</div>
|
|
<div class="navbar-end">
|
|
<.link navigate={~p"/members"} class="btn btn-primary">
|
|
Members
|
|
</.link>
|
|
</div>
|
|
</div>
|
|
```
|
|
|
|
**Custom Tailwind Configuration:**
|
|
|
|
Update `assets/tailwind.config.js` for custom needs:
|
|
|
|
```javascript
|
|
module.exports = {
|
|
content: [
|
|
"../lib/mv_web.ex",
|
|
"../lib/mv_web/**/*.*ex"
|
|
],
|
|
theme: {
|
|
extend: {
|
|
colors: {
|
|
brand: "#FD4F00",
|
|
}
|
|
},
|
|
},
|
|
plugins: [
|
|
require("@tailwindcss/forms"),
|
|
// DaisyUI loaded from vendor
|
|
]
|
|
}
|
|
```
|
|
|
|
### 3.7 JavaScript & esbuild
|
|
|
|
**Minimal JavaScript Philosophy:**
|
|
|
|
Phoenix LiveView handles most interactivity. Use JavaScript only when necessary:
|
|
|
|
```javascript
|
|
// assets/js/app.js
|
|
import "phoenix_html"
|
|
import {Socket} from "phoenix"
|
|
import {LiveSocket} from "phoenix_live_view"
|
|
import topbar from "../vendor/topbar"
|
|
|
|
let csrfToken = document.querySelector("meta[name='csrf-token']").getAttribute("content")
|
|
let liveSocket = new LiveSocket("/live", Socket, {
|
|
longPollFallbackMs: 2500,
|
|
params: {_csrf_token: csrfToken}
|
|
})
|
|
|
|
// Show progress bar on live navigation
|
|
topbar.config({barColors: {0: "#29d"}, shadowColor: "rgba(0, 0, 0, .3)"})
|
|
window.addEventListener("phx:page-loading-start", _info => topbar.show(300))
|
|
window.addEventListener("phx:page-loading-stop", _info => topbar.hide())
|
|
|
|
liveSocket.connect()
|
|
```
|
|
|
|
**Custom Hooks (when needed):**
|
|
|
|
```javascript
|
|
let Hooks = {}
|
|
|
|
Hooks.DatePicker = {
|
|
mounted() {
|
|
// Initialize date picker
|
|
this.el.addEventListener("change", (e) => {
|
|
this.pushEvent("date_selected", {date: e.target.value})
|
|
})
|
|
}
|
|
}
|
|
|
|
let liveSocket = new LiveSocket("/live", Socket, {
|
|
params: {_csrf_token: csrfToken},
|
|
hooks: Hooks
|
|
})
|
|
```
|
|
|
|
### 3.8 Code Quality: Credo
|
|
|
|
**Run Credo Regularly:**
|
|
|
|
```bash
|
|
# Check code quality
|
|
mix credo
|
|
|
|
# Strict mode for CI
|
|
mix credo --strict
|
|
```
|
|
|
|
**Key Credo Checks Enabled:**
|
|
|
|
- Consistency checks (spacing, line endings, parameter patterns)
|
|
- Design checks (FIXME/TODO tags, alias usage)
|
|
- Readability checks (max line length: 120, module/function names)
|
|
- Refactoring opportunities (cyclomatic complexity, nesting)
|
|
- Warnings (unused operations, unsafe operations)
|
|
|
|
**Disabled Checks:**
|
|
|
|
- `Credo.Check.Readability.ModuleDoc` - Disabled by team decision
|
|
(Still encouraged to add module docs for public modules)
|
|
|
|
**Address Credo Issues:**
|
|
|
|
```elixir
|
|
# Before
|
|
def complex_function(user, data, opts) do
|
|
if user.admin? do
|
|
if data.valid? do
|
|
if opts[:force] do
|
|
# deeply nested logic
|
|
end
|
|
end
|
|
end
|
|
end
|
|
|
|
# After - flatten with guard clauses
|
|
def complex_function(user, _data, _opts) when not user.admin?,
|
|
do: {:error, :unauthorized}
|
|
|
|
def complex_function(_user, data, _opts) when not data.valid?,
|
|
do: {:error, :invalid_data}
|
|
|
|
def complex_function(_user, data, opts) do
|
|
if opts[:force] do
|
|
process_data(data)
|
|
else
|
|
validate_and_process(data)
|
|
end
|
|
end
|
|
```
|
|
|
|
### 3.9 Security: Sobelow
|
|
|
|
**Run Security Analysis:**
|
|
|
|
```bash
|
|
# Security audit
|
|
mix sobelow --config
|
|
|
|
# With verbose output
|
|
mix sobelow --config --verbose
|
|
```
|
|
|
|
**Security Best Practices:**
|
|
|
|
- Never commit secrets to version control
|
|
- Use environment variables for sensitive configuration
|
|
- Validate and sanitize all user inputs
|
|
- Use parameterized queries (Ecto handles this)
|
|
- Keep dependencies updated
|
|
|
|
### 3.10 Dependency Auditing & Updates
|
|
|
|
**Regular Security Audits:**
|
|
|
|
```bash
|
|
# Audit dependencies for security vulnerabilities
|
|
mix deps.audit
|
|
|
|
# Audit hex packages
|
|
mix hex.audit
|
|
|
|
# Security scan with Sobelow
|
|
mix sobelow --config
|
|
```
|
|
|
|
**Update Dependencies:**
|
|
|
|
```bash
|
|
# Update all dependencies
|
|
mix deps.update --all
|
|
|
|
# Update specific dependency
|
|
mix deps.update phoenix
|
|
|
|
# Check for outdated packages
|
|
mix hex.outdated
|
|
```
|
|
|
|
### 3.11 Email: Swoosh
|
|
|
|
**Mailer Configuration:**
|
|
|
|
```elixir
|
|
defmodule Mv.Mailer do
|
|
use Swoosh.Mailer, otp_app: :mv
|
|
end
|
|
```
|
|
|
|
**Sending Emails:**
|
|
|
|
```elixir
|
|
defmodule Mv.Accounts.WelcomeEmail do
|
|
use Phoenix.Swoosh, template_root: "lib/mv_web/templates"
|
|
import Swoosh.Email
|
|
|
|
def send(user) do
|
|
new()
|
|
|> to({user.name, user.email})
|
|
|> from({"Mila", "noreply@mila.example.com"})
|
|
|> subject("Welcome to Mila!")
|
|
|> render_body("welcome.html", %{user: user})
|
|
|> Mv.Mailer.deliver()
|
|
end
|
|
end
|
|
```
|
|
|
|
### 3.12 Internationalization: Gettext
|
|
|
|
**Define Translations:**
|
|
|
|
```elixir
|
|
# In LiveView or controller
|
|
gettext("Welcome to Mila")
|
|
|
|
# With interpolation
|
|
gettext("Hello, %{name}!", name: user.name)
|
|
|
|
# Domain-specific translations
|
|
dgettext("auth", "Sign in with email")
|
|
```
|
|
|
|
**Extract and Merge:**
|
|
|
|
```bash
|
|
# Extract new translatable strings
|
|
mix gettext.extract
|
|
|
|
# Merge into existing translations
|
|
mix gettext.merge priv/gettext --on-obsolete=mark_as_obsolete
|
|
```
|
|
|
|
### 3.13 Task Runner: Just
|
|
|
|
**Common Commands:**
|
|
|
|
```bash
|
|
# Start development environment
|
|
just run
|
|
|
|
# Run tests
|
|
just test
|
|
|
|
# Run linter
|
|
just lint
|
|
|
|
# Run security audit
|
|
just audit
|
|
|
|
# Reset database
|
|
just reset-database
|
|
|
|
# Format code
|
|
just format
|
|
|
|
# Regenerate migrations
|
|
just regen-migrations migration_name
|
|
```
|
|
|
|
**Define Custom Tasks:**
|
|
|
|
Edit `Justfile` for project-specific tasks:
|
|
|
|
```makefile
|
|
# Example custom task
|
|
setup-dev: install-dependencies start-database migrate-database
|
|
mix phx.gen.secret
|
|
@echo "Development environment ready!"
|
|
```
|
|
|
|
---
|
|
|
|
## 4. Testing Standards
|
|
|
|
### 4.1 Test Setup and Organization
|
|
|
|
**Test Directory Structure:**
|
|
|
|
Mirror the `lib/` directory structure in `test/`:
|
|
|
|
```
|
|
test/
|
|
├── accounts/ # Tests for Accounts domain
|
|
│ ├── user_test.exs
|
|
│ ├── email_sync_test.exs
|
|
│ └── ...
|
|
├── membership/ # Tests for Membership domain
|
|
│ ├── member_test.exs
|
|
│ └── ...
|
|
├── mv_web/ # Tests for Web layer
|
|
│ ├── controllers/
|
|
│ ├── live/
|
|
│ └── components/
|
|
└── support/ # Test helpers
|
|
├── conn_case.ex # Controller test setup
|
|
└── data_case.ex # Database test setup
|
|
```
|
|
|
|
**Test File Naming:**
|
|
|
|
- Use `_test.exs` suffix for all test files
|
|
- Match the module name: `user.ex` → `user_test.exs`
|
|
- Use descriptive names for integration tests: `user_member_relationship_test.exs`
|
|
|
|
### 4.2 ExUnit Basics
|
|
|
|
**Test Module Structure:**
|
|
|
|
```elixir
|
|
defmodule Mv.Membership.MemberTest do
|
|
use Mv.DataCase, async: true # async: true for parallel execution
|
|
|
|
alias Mv.Membership.Member
|
|
|
|
describe "create_member/1" do
|
|
test "creates a member with valid attributes" do
|
|
attrs = %{
|
|
first_name: "John",
|
|
last_name: "Doe",
|
|
email: "john@example.com"
|
|
}
|
|
|
|
assert {:ok, %Member{} = member} = Mv.Membership.create_member(attrs)
|
|
assert member.first_name == "John"
|
|
assert member.email == "john@example.com"
|
|
end
|
|
|
|
test "returns error with invalid attributes" do
|
|
attrs = %{first_name: nil}
|
|
|
|
assert {:error, _error} = Mv.Membership.create_member(attrs)
|
|
end
|
|
end
|
|
|
|
describe "list_members/0" do
|
|
setup do
|
|
# Setup code for this describe block
|
|
{:ok, member: create_test_member()}
|
|
end
|
|
|
|
test "returns all members", %{member: member} do
|
|
members = Mv.Membership.list_members()
|
|
assert length(members) == 1
|
|
assert List.first(members).id == member.id
|
|
end
|
|
end
|
|
end
|
|
```
|
|
|
|
### 4.3 Test Types
|
|
|
|
#### 4.3.1 Unit Tests
|
|
|
|
Test individual functions and modules in isolation:
|
|
|
|
```elixir
|
|
defmodule Mv.Membership.EmailTest do
|
|
use ExUnit.Case, async: true
|
|
|
|
alias Mv.Membership.Email
|
|
|
|
describe "valid?/1" do
|
|
test "returns true for valid email" do
|
|
assert Email.valid?("user@example.com")
|
|
end
|
|
|
|
test "returns false for invalid email" do
|
|
refute Email.valid?("invalid-email")
|
|
refute Email.valid?("missing-at-sign.com")
|
|
end
|
|
end
|
|
end
|
|
```
|
|
|
|
#### 4.3.2 Integration Tests
|
|
|
|
Test interactions between multiple modules or systems:
|
|
|
|
```elixir
|
|
defmodule Mv.Accounts.UserMemberRelationshipTest do
|
|
use Mv.DataCase, async: true
|
|
|
|
alias Mv.Accounts.User
|
|
alias Mv.Membership.Member
|
|
|
|
describe "user-member relationship" do
|
|
test "creating a user automatically creates a member" do
|
|
attrs = %{
|
|
email: "test@example.com",
|
|
password: "SecurePassword123"
|
|
}
|
|
|
|
assert {:ok, user} = Mv.Accounts.create_user(attrs)
|
|
assert {:ok, member} = Mv.Membership.get_member_by_user_id(user.id)
|
|
assert member.email == user.email
|
|
end
|
|
|
|
test "deleting a user cascades to member" do
|
|
{:ok, user} = create_user()
|
|
{:ok, member} = Mv.Membership.get_member_by_user_id(user.id)
|
|
|
|
assert :ok = Mv.Accounts.destroy_user(user)
|
|
assert {:error, :not_found} = Mv.Membership.get_member(member.id)
|
|
end
|
|
end
|
|
end
|
|
```
|
|
|
|
#### 4.3.3 Controller Tests
|
|
|
|
Test HTTP endpoints:
|
|
|
|
```elixir
|
|
defmodule MvWeb.PageControllerTest do
|
|
use MvWeb.ConnCase, async: true
|
|
|
|
test "GET /", %{conn: conn} do
|
|
conn = get(conn, ~p"/")
|
|
assert html_response(conn, 200) =~ "Welcome to Mila"
|
|
end
|
|
end
|
|
```
|
|
|
|
#### 4.3.4 LiveView Tests
|
|
|
|
Test LiveView interactions:
|
|
|
|
```elixir
|
|
defmodule MvWeb.MemberLive.IndexTest do
|
|
use MvWeb.ConnCase, async: true
|
|
|
|
import Phoenix.LiveViewTest
|
|
|
|
setup do
|
|
member = create_test_member()
|
|
%{member: member}
|
|
end
|
|
|
|
test "displays list of members", %{conn: conn, member: member} do
|
|
{:ok, view, html} = live(conn, ~p"/members")
|
|
|
|
assert html =~ "Members"
|
|
assert html =~ member.first_name
|
|
end
|
|
|
|
test "deletes member", %{conn: conn, member: member} do
|
|
{:ok, view, _html} = live(conn, ~p"/members")
|
|
|
|
assert view
|
|
|> element("#member-#{member.id} a", "Delete")
|
|
|> render_click()
|
|
|
|
refute has_element?(view, "#member-#{member.id}")
|
|
end
|
|
end
|
|
```
|
|
|
|
#### 4.3.5 Component Tests
|
|
|
|
Test function components:
|
|
|
|
```elixir
|
|
defmodule MvWeb.Components.SearchBarComponentTest do
|
|
use MvWeb.ConnCase, async: true
|
|
|
|
import Phoenix.LiveViewTest
|
|
import MvWeb.Components.SearchBarComponent
|
|
|
|
test "renders search input" do
|
|
assigns = %{search_query: "", id: "search"}
|
|
|
|
html =
|
|
render_component(&search_bar/1, assigns)
|
|
|
|
assert html =~ "input"
|
|
assert html =~ ~s(type="search")
|
|
end
|
|
end
|
|
```
|
|
|
|
### 4.4 Test Helpers and Fixtures
|
|
|
|
**Create Test Helpers:**
|
|
|
|
```elixir
|
|
# test/support/fixtures.ex
|
|
defmodule Mv.Fixtures do
|
|
def member_fixture(attrs \\ %{}) do
|
|
default_attrs = %{
|
|
first_name: "Test",
|
|
last_name: "User",
|
|
email: "test#{System.unique_integer()}@example.com"
|
|
}
|
|
|
|
{:ok, member} =
|
|
default_attrs
|
|
|> Map.merge(attrs)
|
|
|> Mv.Membership.create_member()
|
|
|
|
member
|
|
end
|
|
end
|
|
```
|
|
|
|
**Use Setup Blocks:**
|
|
|
|
```elixir
|
|
describe "with authenticated user" do
|
|
setup %{conn: conn} do
|
|
user = create_user()
|
|
conn = log_in_user(conn, user)
|
|
%{conn: conn, user: user}
|
|
end
|
|
|
|
test "can access protected page", %{conn: conn} do
|
|
conn = get(conn, ~p"/profile")
|
|
assert html_response(conn, 200) =~ "Profile"
|
|
end
|
|
end
|
|
```
|
|
|
|
### 4.5 Database Testing with Sandbox
|
|
|
|
**Use Ecto Sandbox for Isolation:**
|
|
|
|
```elixir
|
|
# test/test_helper.exs
|
|
ExUnit.start()
|
|
Ecto.Adapters.SQL.Sandbox.mode(Mv.Repo, :manual)
|
|
```
|
|
|
|
```elixir
|
|
# test/support/data_case.ex
|
|
defmodule Mv.DataCase do
|
|
use ExUnit.CaseTemplate
|
|
|
|
using do
|
|
quote do
|
|
import Ecto
|
|
import Ecto.Changeset
|
|
import Ecto.Query
|
|
import Mv.DataCase
|
|
|
|
alias Mv.Repo
|
|
end
|
|
end
|
|
|
|
setup tags do
|
|
pid = Ecto.Adapters.SQL.Sandbox.start_owner!(Mv.Repo, shared: not tags[:async])
|
|
on_exit(fn -> Ecto.Adapters.SQL.Sandbox.stop_owner(pid) end)
|
|
:ok
|
|
end
|
|
end
|
|
```
|
|
|
|
### 4.6 Test Coverage
|
|
|
|
**Run Tests with Coverage:**
|
|
|
|
```bash
|
|
# Run tests
|
|
mix test
|
|
|
|
# Run with coverage
|
|
mix test --cover
|
|
|
|
# Run specific test file
|
|
mix test test/membership/member_test.exs
|
|
|
|
# Run specific test line
|
|
mix test test/membership/member_test.exs:42
|
|
```
|
|
|
|
**Coverage Goals:**
|
|
|
|
- Aim for >80% overall coverage
|
|
- 100% coverage for critical business logic
|
|
- Focus on meaningful tests, not just coverage numbers
|
|
|
|
### 4.7 Testing Best Practices
|
|
|
|
**Descriptive Test Names:**
|
|
|
|
```elixir
|
|
# Good - describes what is being tested
|
|
test "creates a member with valid email address"
|
|
test "returns error when email is already taken"
|
|
test "sends welcome email after successful registration"
|
|
|
|
# Avoid - vague or generic
|
|
test "member creation"
|
|
test "error case"
|
|
test "test 1"
|
|
```
|
|
|
|
**Arrange-Act-Assert Pattern:**
|
|
|
|
```elixir
|
|
test "updates member email" do
|
|
# Arrange - set up test data
|
|
member = member_fixture()
|
|
new_email = "new@example.com"
|
|
|
|
# Act - perform the action
|
|
{:ok, updated_member} = Mv.Membership.update_member(member, %{email: new_email})
|
|
|
|
# Assert - verify results
|
|
assert updated_member.email == new_email
|
|
end
|
|
```
|
|
|
|
**Test One Thing Per Test:**
|
|
|
|
```elixir
|
|
# Good - focused test
|
|
test "validates email format" do
|
|
attrs = %{email: "invalid-email"}
|
|
assert {:error, _} = Mv.Membership.create_member(attrs)
|
|
end
|
|
|
|
test "requires email to be present" do
|
|
attrs = %{email: nil}
|
|
assert {:error, _} = Mv.Membership.create_member(attrs)
|
|
end
|
|
|
|
# Avoid - testing multiple things
|
|
test "validates email" do
|
|
# Tests both format and presence
|
|
assert {:error, _} = Mv.Membership.create_member(%{email: nil})
|
|
assert {:error, _} = Mv.Membership.create_member(%{email: "invalid"})
|
|
end
|
|
```
|
|
|
|
**Use describe Blocks for Organization:**
|
|
|
|
```elixir
|
|
describe "create_member/1" do
|
|
test "success case" do
|
|
# ...
|
|
end
|
|
|
|
test "error case" do
|
|
# ...
|
|
end
|
|
end
|
|
|
|
describe "update_member/2" do
|
|
test "success case" do
|
|
# ...
|
|
end
|
|
end
|
|
```
|
|
|
|
**Avoid Testing Implementation Details:**
|
|
|
|
```elixir
|
|
# Good - test behavior
|
|
test "member can be created with valid attributes" do
|
|
attrs = valid_member_attrs()
|
|
assert {:ok, %Member{}} = Mv.Membership.create_member(attrs)
|
|
end
|
|
|
|
# Avoid - testing internal implementation
|
|
test "create_member calls Ash.create with correct params" do
|
|
# This is too coupled to implementation
|
|
end
|
|
```
|
|
|
|
**Keep Tests Fast:**
|
|
|
|
- Use `async: true` when possible
|
|
- Avoid unnecessary database interactions
|
|
- Mock external services
|
|
- Use fixtures efficiently
|
|
|
|
---
|
|
|
|
## 5. Security Guidelines
|
|
|
|
### 5.1 Authentication & Authorization
|
|
|
|
**Use AshAuthentication:**
|
|
|
|
```elixir
|
|
# Authentication is configured at the resource level
|
|
authentication do
|
|
strategies do
|
|
password :password do
|
|
identity_field :email
|
|
hashed_password_field :hashed_password
|
|
end
|
|
|
|
oauth2 :rauthy do
|
|
# OIDC configuration
|
|
end
|
|
end
|
|
end
|
|
```
|
|
|
|
**Implement Authorization Policies:**
|
|
|
|
```elixir
|
|
policies do
|
|
# Default deny
|
|
policy action_type(:*) do
|
|
authorize_if always()
|
|
end
|
|
|
|
# Specific permissions
|
|
policy action_type([:read, :update]) do
|
|
authorize_if relates_to_actor_via(:user)
|
|
end
|
|
|
|
policy action_type(:destroy) do
|
|
authorize_if actor_attribute_equals(:role, :admin)
|
|
end
|
|
end
|
|
```
|
|
|
|
### 5.2 Password Security
|
|
|
|
**Use bcrypt for Password Hashing:**
|
|
|
|
```elixir
|
|
# Configured in AshAuthentication resource
|
|
password :password do
|
|
identity_field :email
|
|
hashed_password_field :hashed_password
|
|
hash_provider AshAuthentication.BcryptProvider
|
|
|
|
confirmation_required? true
|
|
end
|
|
```
|
|
|
|
**Password Requirements:**
|
|
|
|
- Minimum 12 characters
|
|
- Mix of uppercase, lowercase, numbers (enforced by validation)
|
|
- Use `bcrypt_elixir` for hashing (never store plain text passwords)
|
|
|
|
### 5.3 Input Validation & Sanitization
|
|
|
|
**Validate All User Input:**
|
|
|
|
```elixir
|
|
attributes do
|
|
attribute :email, :string do
|
|
allow_nil? false
|
|
public? true
|
|
end
|
|
end
|
|
|
|
validations do
|
|
validate present(:email)
|
|
validate match(:email, ~r/^[^\s]+@[^\s]+\.[^\s]+$/)
|
|
end
|
|
```
|
|
|
|
**SQL Injection Prevention:**
|
|
|
|
Ecto and Ash handle parameterized queries automatically:
|
|
|
|
```elixir
|
|
# Safe - parameterized query
|
|
Ash.Query.filter(Member, email == ^user_email)
|
|
|
|
# Avoid raw SQL when possible
|
|
Ecto.Adapters.SQL.query(Mv.Repo, "SELECT * FROM members WHERE email = $1", [user_email])
|
|
```
|
|
|
|
### 5.4 CSRF Protection
|
|
|
|
**Phoenix Handles CSRF Automatically:**
|
|
|
|
```heex
|
|
<!-- CSRF token automatically included in forms -->
|
|
<.form for={@form} phx-submit="save">
|
|
<!-- form fields -->
|
|
</.form>
|
|
```
|
|
|
|
**Configure in Endpoint:**
|
|
|
|
```elixir
|
|
# lib/mv_web/endpoint.ex
|
|
plug Plug.Session,
|
|
store: :cookie,
|
|
key: "_mv_key",
|
|
signing_salt: "secret"
|
|
|
|
plug :protect_from_forgery
|
|
```
|
|
|
|
### 5.5 Secrets Management
|
|
|
|
**Never Commit Secrets:**
|
|
|
|
```bash
|
|
# .gitignore should include:
|
|
.env
|
|
.env.*
|
|
!.env.example
|
|
```
|
|
|
|
**Use Environment Variables:**
|
|
|
|
```elixir
|
|
# config/runtime.exs
|
|
config :mv, :rauthy,
|
|
client_id: System.get_env("OIDC_CLIENT_ID") || "mv",
|
|
client_secret: System.get_env("OIDC_CLIENT_SECRET"),
|
|
base_url: System.get_env("OIDC_BASE_URL")
|
|
```
|
|
|
|
**Generate Secure Secrets:**
|
|
|
|
```bash
|
|
# Generate secret key base
|
|
mix phx.gen.secret
|
|
|
|
# Generate token signing secret
|
|
mix phx.gen.secret
|
|
```
|
|
|
|
### 5.6 Security Headers
|
|
|
|
**Configure Security Headers:**
|
|
|
|
```elixir
|
|
# lib/mv_web/endpoint.ex
|
|
plug Plug.Static,
|
|
at: "/",
|
|
from: :mv,
|
|
gzip: false,
|
|
only: MvWeb.static_paths(),
|
|
headers: %{
|
|
"x-content-type-options" => "nosniff",
|
|
"x-frame-options" => "SAMEORIGIN",
|
|
"x-xss-protection" => "1; mode=block"
|
|
}
|
|
```
|
|
|
|
### 5.7 Dependency Security
|
|
|
|
- **Use Renovate for automated dependency updates**
|
|
- **Review changelogs before updating dependencies**
|
|
- **Test thoroughly after updates**
|
|
- **Run regular audits** (see section 3.10 for audit commands)
|
|
|
|
### 5.8 Logging & Monitoring
|
|
|
|
**Sanitize Logs:**
|
|
|
|
```elixir
|
|
# Don't log sensitive information
|
|
Logger.info("User login attempt", user_id: user.id)
|
|
|
|
# Avoid
|
|
Logger.info("User login attempt", user: inspect(user)) # May contain password
|
|
```
|
|
|
|
**Configure Logger:**
|
|
|
|
```elixir
|
|
# config/config.exs
|
|
config :logger, :default_formatter,
|
|
format: "$time $metadata[$level] $message\n",
|
|
metadata: [:request_id, :user_id]
|
|
```
|
|
|
|
---
|
|
|
|
## 6. Performance Best Practices
|
|
|
|
### 6.1 Database Performance
|
|
|
|
**Indexing:**
|
|
|
|
```elixir
|
|
postgres do
|
|
table "members"
|
|
repo Mv.Repo
|
|
|
|
# Add indexes for frequently queried fields
|
|
index [:email], unique: true
|
|
index [:last_name]
|
|
index [:created_at]
|
|
end
|
|
```
|
|
|
|
**Avoid N+1 Queries:**
|
|
|
|
```elixir
|
|
# Good - preload relationships
|
|
members =
|
|
Member
|
|
|> Ash.Query.load([:properties, :user])
|
|
|> Mv.Membership.list_members!()
|
|
|
|
# Avoid - causes N+1
|
|
members = Mv.Membership.list_members!()
|
|
Enum.map(members, fn member ->
|
|
properties = Ash.load!(member, :properties) # N queries!
|
|
end)
|
|
```
|
|
|
|
**Pagination:**
|
|
|
|
```elixir
|
|
# Use keyset pagination (configured as default in Ash)
|
|
Ash.Query.page(Member, offset: 0, limit: 50)
|
|
```
|
|
|
|
**Batch Operations:**
|
|
|
|
```elixir
|
|
# Use bulk operations for multiple records
|
|
Ash.bulk_create([member1_attrs, member2_attrs, member3_attrs], Member, :create)
|
|
```
|
|
|
|
### 6.2 LiveView Performance
|
|
|
|
**Optimize Assigns:**
|
|
|
|
```elixir
|
|
# Good - only assign what's needed
|
|
def mount(_params, _session, socket) do
|
|
{:ok, assign(socket, members_count: get_count())}
|
|
end
|
|
|
|
# Avoid - assigning large collections unnecessarily
|
|
def mount(_params, _session, socket) do
|
|
{:ok, assign(socket, all_members: list_all_members())} # Heavy!
|
|
end
|
|
```
|
|
|
|
**Use Temporary Assigns:**
|
|
|
|
```elixir
|
|
# For data that's only needed for rendering
|
|
def handle_event("load_report", _, socket) do
|
|
report_data = generate_large_report()
|
|
{:noreply, assign(socket, report: report_data) |> assign(:report, temporary_assigns: [:report])}
|
|
end
|
|
```
|
|
|
|
**Stream Collections:**
|
|
|
|
```elixir
|
|
# For large collections
|
|
def mount(_params, _session, socket) do
|
|
{:ok, stream(socket, :members, list_members())}
|
|
end
|
|
|
|
def render(assigns) do
|
|
~H"""
|
|
<div id="members" phx-update="stream">
|
|
<div :for={{id, member} <- @streams.members} id={id}>
|
|
<%= member.name %>
|
|
</div>
|
|
</div>
|
|
"""
|
|
end
|
|
```
|
|
|
|
### 6.3 Caching Strategies
|
|
|
|
**Function-Level Caching:**
|
|
|
|
```elixir
|
|
defmodule Mv.Cache do
|
|
use GenServer
|
|
|
|
def get_or_compute(key, compute_fn) do
|
|
case get(key) do
|
|
nil ->
|
|
value = compute_fn.()
|
|
put(key, value)
|
|
value
|
|
|
|
value ->
|
|
value
|
|
end
|
|
end
|
|
end
|
|
```
|
|
|
|
**ETS for In-Memory Cache:**
|
|
|
|
```elixir
|
|
# In application.ex
|
|
:ets.new(:mv_cache, [:named_table, :public, read_concurrency: true])
|
|
|
|
# Usage
|
|
:ets.insert(:mv_cache, {"key", "value"})
|
|
:ets.lookup(:mv_cache, "key")
|
|
```
|
|
|
|
### 6.4 Async Processing
|
|
|
|
**Background Jobs (Future):**
|
|
|
|
```elixir
|
|
# When Oban is added
|
|
defmodule Mv.Workers.EmailWorker do
|
|
use Oban.Worker
|
|
|
|
@impl Oban.Worker
|
|
def perform(%Oban.Job{args: %{"user_id" => user_id}}) do
|
|
user = Mv.Accounts.get_user!(user_id)
|
|
Mv.Mailer.send_welcome_email(user)
|
|
:ok
|
|
end
|
|
end
|
|
|
|
# Enqueue job
|
|
%{user_id: user.id}
|
|
|> Mv.Workers.EmailWorker.new()
|
|
|> Oban.insert()
|
|
```
|
|
|
|
**Task Async for Concurrent Operations:**
|
|
|
|
```elixir
|
|
def gather_dashboard_data do
|
|
tasks = [
|
|
Task.async(fn -> get_member_count() end),
|
|
Task.async(fn -> get_recent_registrations() end),
|
|
Task.async(fn -> get_payment_status() end)
|
|
]
|
|
|
|
[member_count, recent, payments] = Task.await_many(tasks)
|
|
|
|
%{
|
|
member_count: member_count,
|
|
recent: recent,
|
|
payments: payments
|
|
}
|
|
end
|
|
```
|
|
|
|
### 6.5 Profiling & Monitoring
|
|
|
|
**Use :observer:**
|
|
|
|
```elixir
|
|
# In iex session
|
|
:observer.start()
|
|
```
|
|
|
|
**Use Telemetry:**
|
|
|
|
```elixir
|
|
# Attach telemetry handlers
|
|
:telemetry.attach(
|
|
"query-duration",
|
|
[:mv, :repo, :query],
|
|
fn event, measurements, metadata, _config ->
|
|
Logger.info("Query took #{measurements.total_time}ms")
|
|
end,
|
|
nil
|
|
)
|
|
```
|
|
|
|
---
|
|
|
|
## 7. Documentation Standards
|
|
|
|
### 7.1 Module Documentation
|
|
|
|
**Use @moduledoc:**
|
|
|
|
```elixir
|
|
defmodule Mv.Membership.Member do
|
|
@moduledoc """
|
|
Represents a club member with their personal information and membership status.
|
|
|
|
Members can have custom properties defined by the club administrators.
|
|
Each member is optionally linked to a user account for self-service access.
|
|
|
|
## Examples
|
|
|
|
iex> Mv.Membership.create_member(%{first_name: "John", last_name: "Doe", email: "john@example.com"})
|
|
{:ok, %Mv.Membership.Member{}}
|
|
|
|
"""
|
|
end
|
|
```
|
|
|
|
**Module Documentation Should Include:**
|
|
|
|
- Purpose of the module
|
|
- Key responsibilities
|
|
- Usage examples
|
|
- Related modules
|
|
|
|
### 7.2 Function Documentation
|
|
|
|
**Use @doc:**
|
|
|
|
```elixir
|
|
@doc """
|
|
Creates a new member with the given attributes.
|
|
|
|
## Parameters
|
|
|
|
- `attrs` - A map of member attributes including:
|
|
- `:first_name` (required) - The member's first name
|
|
- `:last_name` (required) - The member's last name
|
|
- `:email` (required) - The member's email address
|
|
|
|
## Returns
|
|
|
|
- `{:ok, member}` - Successfully created member
|
|
- `{:error, error}` - Validation or creation error
|
|
|
|
## Examples
|
|
|
|
iex> Mv.Membership.create_member(%{
|
|
...> first_name: "Jane",
|
|
...> last_name: "Smith",
|
|
...> email: "jane@example.com"
|
|
...> })
|
|
{:ok, %Mv.Membership.Member{first_name: "Jane"}}
|
|
|
|
iex> Mv.Membership.create_member(%{first_name: nil})
|
|
{:error, %Ash.Error.Invalid{}}
|
|
|
|
"""
|
|
@spec create_member(map()) :: {:ok, Member.t()} | {:error, Ash.Error.t()}
|
|
def create_member(attrs) do
|
|
# Implementation
|
|
end
|
|
```
|
|
|
|
### 7.3 Type Specifications
|
|
|
|
**Use @spec for Function Signatures:**
|
|
|
|
```elixir
|
|
@spec create_member(map()) :: {:ok, Member.t()} | {:error, Ash.Error.t()}
|
|
def create_member(attrs)
|
|
|
|
@spec list_members(keyword()) :: [Member.t()]
|
|
def list_members(opts \\ [])
|
|
|
|
@spec get_member!(String.t()) :: Member.t() | no_return()
|
|
def get_member!(id)
|
|
```
|
|
|
|
### 7.4 Code Comments
|
|
|
|
**When to Comment:**
|
|
|
|
```elixir
|
|
# Good - explain WHY, not WHAT
|
|
def calculate_dues(member) do
|
|
# Annual dues are prorated based on join date to be fair to mid-year joiners
|
|
months_active = calculate_months_active(member)
|
|
@annual_dues * (months_active / 12)
|
|
end
|
|
|
|
# Avoid - stating the obvious
|
|
def calculate_dues(member) do
|
|
# Calculate the dues
|
|
months_active = calculate_months_active(member) # Get months active
|
|
@annual_dues * (months_active / 12) # Multiply annual dues by fraction
|
|
end
|
|
```
|
|
|
|
**Complex Logic:**
|
|
|
|
```elixir
|
|
def sync_member_email(member, user) do
|
|
# Email synchronization priority:
|
|
# 1. User email is the source of truth (authenticated account)
|
|
# 2. Member email is updated to match user email
|
|
# 3. Preserve member email history for audit purposes
|
|
|
|
if member.email != user.email do
|
|
# Archive old email before updating
|
|
archive_member_email(member, member.email)
|
|
update_member(member, %{email: user.email})
|
|
end
|
|
end
|
|
```
|
|
|
|
### 7.5 README and Project Documentation
|
|
|
|
**Keep README.md Updated:**
|
|
|
|
- Installation instructions
|
|
- Development setup
|
|
- Running tests
|
|
- Deployment guide
|
|
- Contributing guidelines
|
|
|
|
**Additional Documentation:**
|
|
|
|
- `docs/` directory for detailed guides
|
|
- Architecture decision records (ADRs)
|
|
- API documentation (generated with ExDoc)
|
|
|
|
**Generate Documentation:**
|
|
|
|
```bash
|
|
# Generate HTML documentation
|
|
mix docs
|
|
|
|
# View documentation
|
|
open doc/index.html
|
|
```
|
|
|
|
### 7.6 Changelog
|
|
|
|
**Maintain CHANGELOG.md:**
|
|
|
|
```markdown
|
|
# Changelog
|
|
|
|
## [Unreleased]
|
|
|
|
### Added
|
|
- Member custom properties feature
|
|
- Email synchronization between user and member
|
|
|
|
### Changed
|
|
- Updated Phoenix to 1.8.0
|
|
|
|
### Fixed
|
|
- Email uniqueness validation bug
|
|
|
|
## [0.1.0] - 2025-01-15
|
|
|
|
### Added
|
|
- Initial release
|
|
- Basic member management
|
|
- OIDC authentication
|
|
```
|
|
|
|
---
|
|
|
|
## 8. Git Workflow
|
|
|
|
### 8.1 Branching Strategy
|
|
|
|
**Main Branches:**
|
|
|
|
- `main` - Production-ready code
|
|
|
|
**Feature Branches:**
|
|
|
|
```bash
|
|
# Create feature branch
|
|
git checkout -b feature/member-custom-properties
|
|
|
|
# Work on feature
|
|
git add .
|
|
git commit -m "Add custom properties to members"
|
|
|
|
# Push to remote
|
|
git push origin feature/member-custom-properties
|
|
```
|
|
|
|
### 8.2 Commit Messages
|
|
|
|
**Format:**
|
|
|
|
```
|
|
<type>: <subject>
|
|
|
|
<body>
|
|
|
|
<footer>
|
|
```
|
|
|
|
**Types:**
|
|
|
|
- `feat:` New feature
|
|
- `fix:` Bug fix
|
|
- `docs:` Documentation changes
|
|
- `style:` Code style changes (formatting)
|
|
- `refactor:` Code refactoring
|
|
- `test:` Adding or updating tests
|
|
- `chore:` Maintenance tasks
|
|
|
|
**Examples:**
|
|
|
|
```
|
|
feat: add email synchronization for members
|
|
|
|
Implement automatic email sync between user accounts and member records.
|
|
When a user updates their email, the associated member record is updated.
|
|
|
|
Closes #123
|
|
```
|
|
|
|
```
|
|
fix: resolve N+1 query in member list
|
|
|
|
Preload properties relationship when loading members to avoid N+1 queries.
|
|
|
|
Performance improvement: reduced query count from 100+ to 2.
|
|
```
|
|
|
|
### 8.3 Code Reviews
|
|
|
|
**Before Creating PR:**
|
|
|
|
- Run `mix format`
|
|
- Run `mix credo`
|
|
- Run `mix test`
|
|
- Run `mix sobelow --config`
|
|
- Update documentation if needed
|
|
|
|
**PR Description Should Include:**
|
|
|
|
- What changed
|
|
- Why it changed
|
|
- How to test it
|
|
- Screenshots (for UI changes)
|
|
- Related issues
|
|
|
|
---
|
|
|
|
## 9. Deployment
|
|
|
|
### 9.1 Environment Configuration
|
|
|
|
**Production Checklist:**
|
|
|
|
- [ ] Set `SECRET_KEY_BASE` (use `mix phx.gen.secret`)
|
|
- [ ] Set `TOKEN_SIGNING_SECRET`
|
|
- [ ] Configure `DATABASE_URL`
|
|
- [ ] Set `PHX_HOST`
|
|
- [ ] Configure OIDC credentials
|
|
- [ ] Set up SMTP for email
|
|
- [ ] Enable SSL/TLS
|
|
- [ ] Configure monitoring
|
|
|
|
### 9.2 Database Migrations
|
|
|
|
```bash
|
|
# In production Docker container
|
|
docker compose -f docker-compose.prod.yml exec app /app/bin/mv eval "Mv.Release.migrate"
|
|
```
|
|
|
|
### 9.3 Building Docker Image
|
|
|
|
```bash
|
|
# Build production image
|
|
docker build -t mitgliederverwaltung .
|
|
|
|
# Or use Just
|
|
just build-docker-container
|
|
```
|
|
|
|
### 9.4 Health Checks
|
|
|
|
**Implement Health Check Endpoint:**
|
|
|
|
```elixir
|
|
# lib/mv_web/controllers/health_controller.ex
|
|
defmodule MvWeb.HealthController do
|
|
use MvWeb, :controller
|
|
|
|
def index(conn, _params) do
|
|
# Check database connectivity
|
|
case Ecto.Adapters.SQL.query(Mv.Repo, "SELECT 1", []) do
|
|
{:ok, _} -> json(conn, %{status: "healthy"})
|
|
{:error, _} -> conn |> put_status(503) |> json(%{status: "unhealthy"})
|
|
end
|
|
end
|
|
end
|
|
```
|
|
|
|
---
|
|
|
|
## 10. Additional Resources
|
|
|
|
### 10.1 Official Documentation
|
|
|
|
- **Elixir:** https://elixir-lang.org/docs.html
|
|
- **Phoenix:** https://hexdocs.pm/phoenix/
|
|
- **Ash Framework:** https://hexdocs.pm/ash/
|
|
- **LiveView:** https://hexdocs.pm/phoenix_live_view/
|
|
- **Ecto:** https://hexdocs.pm/ecto/
|
|
- **Tailwind CSS:** https://tailwindcss.com/docs
|
|
- **DaisyUI:** https://daisyui.com/
|
|
|
|
### 10.2 Books & Guides
|
|
|
|
- Programming Elixir by Dave Thomas
|
|
- Phoenix in Action by Geoffrey Lessel
|
|
- Testing Elixir by Andrea Leopardi & Jeffrey Matthias
|
|
- Designing Elixir Systems with OTP by James Edward Gray II
|
|
|
|
### 10.3 Community
|
|
|
|
- Elixir Forum: https://elixirforum.com/
|
|
- Ash Framework Discord
|
|
- Phoenix Framework Slack
|
|
|
|
---
|
|
|
|
## 8. Accessibility Guidelines
|
|
|
|
Building accessible applications ensures that all users, including those with disabilities, can use our application effectively. The following guidelines follow WCAG 2.1 Level AA standards.
|
|
|
|
### 8.1 Semantic HTML
|
|
|
|
**Use Semantic Elements:**
|
|
|
|
```heex
|
|
<!-- Good - semantic HTML -->
|
|
<nav class="navbar">
|
|
<a href="/">Home</a>
|
|
</nav>
|
|
|
|
<main>
|
|
<article>
|
|
<h1>Member Details</h1>
|
|
<section>
|
|
<h2>Contact Information</h2>
|
|
<p>Email: <%= @member.email %></p>
|
|
</section>
|
|
</article>
|
|
</main>
|
|
|
|
<!-- Avoid - non-semantic divs -->
|
|
<div class="navigation">
|
|
<div class="link">Home</div>
|
|
</div>
|
|
```
|
|
|
|
### 8.2 ARIA Labels and Roles
|
|
|
|
**Use ARIA Attributes When Necessary:**
|
|
|
|
```heex
|
|
<!-- Icon-only buttons need labels -->
|
|
<button aria-label={gettext("Delete member")} phx-click="delete">
|
|
<.icon name="hero-trash" />
|
|
</button>
|
|
|
|
<!-- Loading states -->
|
|
<div role="status" aria-live="polite" aria-busy={@loading}>
|
|
<%= if @loading do %>
|
|
<span aria-label={gettext("Loading...")}>
|
|
<.icon name="hero-loading" class="animate-spin" />
|
|
</span>
|
|
<% end %>
|
|
</div>
|
|
|
|
<!-- Navigation landmarks -->
|
|
<nav aria-label={gettext("Main navigation")}>
|
|
<!-- navigation items -->
|
|
</nav>
|
|
```
|
|
|
|
### 8.3 Keyboard Navigation
|
|
|
|
**All Interactive Elements Must Be Keyboard Accessible:**
|
|
|
|
```heex
|
|
<!-- Good - keyboard accessible -->
|
|
<.link navigate={~p"/members"} class="btn">
|
|
Members
|
|
</.link>
|
|
|
|
<!-- Good - custom keyboard handler -->
|
|
<div
|
|
tabindex="0"
|
|
role="button"
|
|
phx-click="toggle"
|
|
phx-keydown="toggle"
|
|
phx-key="Enter"
|
|
aria-pressed={@expanded}>
|
|
Toggle
|
|
</div>
|
|
|
|
<!-- Avoid - div without keyboard support -->
|
|
<div phx-click="action">Click me</div>
|
|
```
|
|
|
|
**Tab Order:**
|
|
|
|
- Ensure logical tab order matches visual order
|
|
- Use `tabindex="0"` for custom interactive elements
|
|
- Use `tabindex="-1"` to programmatically focus (not in tab order)
|
|
- Never use positive `tabindex` values
|
|
|
|
### 8.4 Color and Contrast
|
|
|
|
**Ensure Sufficient Contrast:**
|
|
|
|
```elixir
|
|
# Tailwind classes with sufficient contrast (4.5:1 minimum)
|
|
# Good
|
|
<p class="text-gray-900 bg-white">High contrast text</p>
|
|
<p class="text-white bg-gray-900">Inverted high contrast</p>
|
|
|
|
# Avoid - insufficient contrast
|
|
<p class="text-gray-400 bg-gray-300">Low contrast text</p>
|
|
```
|
|
|
|
**Don't Rely Solely on Color:**
|
|
|
|
```heex
|
|
<!-- Good - color + icon + text -->
|
|
<div class="alert alert-error">
|
|
<.icon name="hero-exclamation-circle" />
|
|
<span><%= gettext("Error: Email is required") %></span>
|
|
</div>
|
|
|
|
<!-- Avoid - color only -->
|
|
<div class="text-red-500">
|
|
<%= gettext("Email is required") %>
|
|
</div>
|
|
```
|
|
|
|
### 8.5 Form Accessibility
|
|
|
|
**Label All Form Fields:**
|
|
|
|
```heex
|
|
<!-- Good - explicit labels -->
|
|
<.input
|
|
field={@form[:email]}
|
|
type="email"
|
|
label={gettext("Email Address")}
|
|
required
|
|
/>
|
|
|
|
<!-- Good - with helper text -->
|
|
<.input
|
|
field={@form[:password]}
|
|
type="password"
|
|
label={gettext("Password")}
|
|
help={gettext("Must be at least 12 characters")}
|
|
required
|
|
/>
|
|
```
|
|
|
|
**Error Messages:**
|
|
|
|
```heex
|
|
<!-- Accessible error messages -->
|
|
<.input
|
|
field={@form[:email]}
|
|
type="email"
|
|
label={gettext("Email")}
|
|
errors={@errors[:email]}
|
|
aria-describedby={@errors[:email] && "email-error"}
|
|
/>
|
|
<span :if={@errors[:email]} id="email-error" role="alert">
|
|
<%= @errors[:email] %>
|
|
</span>
|
|
```
|
|
|
|
**Required Fields:**
|
|
|
|
```heex
|
|
<!-- Mark required fields -->
|
|
<.input
|
|
field={@form[:first_name]}
|
|
label={gettext("First Name")}
|
|
required
|
|
aria-required="true"
|
|
/>
|
|
```
|
|
|
|
### 8.6 Focus Management
|
|
|
|
**Manage Focus in LiveView:**
|
|
|
|
```elixir
|
|
def handle_event("save", params, socket) do
|
|
case save_member(params) do
|
|
{:ok, member} ->
|
|
socket
|
|
|> put_flash(:info, gettext("Member saved successfully"))
|
|
|> push_navigate(to: ~p"/members/#{member}")
|
|
# Focus will move to the new page
|
|
|
|
{:error, changeset} ->
|
|
socket
|
|
|> assign(:form, to_form(changeset))
|
|
# Keep focus context
|
|
end
|
|
end
|
|
```
|
|
|
|
**Skip Links:**
|
|
|
|
```heex
|
|
<!-- Add skip link for keyboard users -->
|
|
<a href="#main-content" class="sr-only focus:not-sr-only focus:absolute focus:top-0 focus:left-0 focus:z-50 focus:p-4 focus:bg-white">
|
|
<%= gettext("Skip to main content") %>
|
|
</a>
|
|
|
|
<main id="main-content">
|
|
<!-- main content -->
|
|
</main>
|
|
```
|
|
|
|
### 8.7 Images and Media
|
|
|
|
**Alt Text for Images:**
|
|
|
|
```heex
|
|
<!-- Good - descriptive alt text -->
|
|
<img src="/images/member-photo.jpg" alt={gettext("Photo of %{name}", name: @member.name)} />
|
|
|
|
<!-- Decorative images -->
|
|
<img src="/images/decoration.svg" alt="" role="presentation" />
|
|
```
|
|
|
|
**Icons:**
|
|
|
|
```heex
|
|
<!-- Icons with meaning need labels -->
|
|
<.icon name="hero-check-circle" aria-label={gettext("Success")} />
|
|
|
|
<!-- Decorative icons -->
|
|
<.icon name="hero-sparkles" aria-hidden="true" />
|
|
```
|
|
|
|
### 8.8 Tables
|
|
|
|
**Accessible Data Tables:**
|
|
|
|
```heex
|
|
<table>
|
|
<caption><%= gettext("List of members") %></caption>
|
|
<thead>
|
|
<tr>
|
|
<th scope="col"><%= gettext("Name") %></th>
|
|
<th scope="col"><%= gettext("Email") %></th>
|
|
<th scope="col"><%= gettext("Actions") %></th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
<tr :for={member <- @members}>
|
|
<td><%= member.name %></td>
|
|
<td><%= member.email %></td>
|
|
<td>
|
|
<.link navigate={~p"/members/#{member}"} aria-label={gettext("View %{name}", name: member.name)}>
|
|
<%= gettext("View") %>
|
|
</.link>
|
|
</td>
|
|
</tr>
|
|
</tbody>
|
|
</table>
|
|
```
|
|
|
|
### 8.9 Live Regions
|
|
|
|
**Announce Dynamic Content:**
|
|
|
|
```heex
|
|
<!-- Search results announcement -->
|
|
<div role="status" aria-live="polite" aria-atomic="true">
|
|
<%= if @searched do %>
|
|
<span class="sr-only">
|
|
<%= ngettext("Found %{count} member", "Found %{count} members", @count) %>
|
|
</span>
|
|
<% end %>
|
|
</div>
|
|
|
|
<!-- Status messages -->
|
|
<div role="alert" aria-live="assertive">
|
|
<%= if @error do %>
|
|
<%= @error %>
|
|
<% end %>
|
|
</div>
|
|
```
|
|
|
|
### 8.10 Testing Accessibility
|
|
|
|
**Tools and Practices:**
|
|
|
|
```bash
|
|
# Browser DevTools
|
|
# - Chrome: Lighthouse Accessibility Audit
|
|
# - Firefox: Accessibility Inspector
|
|
|
|
# Automated testing (future)
|
|
# - pa11y
|
|
# - axe-core
|
|
```
|
|
|
|
**Manual Testing:**
|
|
|
|
1. **Keyboard Navigation:** Navigate entire application using only keyboard
|
|
2. **Screen Reader:** Test with NVDA (Windows) or VoiceOver (Mac)
|
|
3. **Zoom:** Test at 200% zoom level
|
|
4. **Color Blindness:** Use browser extensions to simulate
|
|
|
|
**Checklist:**
|
|
|
|
- [ ] All images have alt text (or alt="" for decorative)
|
|
- [ ] All form inputs have labels
|
|
- [ ] All interactive elements are keyboard accessible
|
|
- [ ] Color contrast meets WCAG AA (4.5:1 for normal text)
|
|
- [ ] Focus indicators are visible
|
|
- [ ] Headings follow logical hierarchy (h1, h2, h3...)
|
|
- [ ] Error messages are announced to screen readers
|
|
- [ ] Skip links are available
|
|
- [ ] Tables have proper structure (th, scope, caption)
|
|
- [ ] ARIA labels used for icon-only buttons
|
|
|
|
### 8.11 DaisyUI Accessibility
|
|
|
|
DaisyUI components are designed with accessibility in mind, but ensure:
|
|
|
|
```heex
|
|
<!-- Modal accessibility -->
|
|
<dialog id="my-modal" class="modal" aria-labelledby="modal-title">
|
|
<div class="modal-box">
|
|
<h2 id="modal-title"><%= gettext("Confirm Deletion") %></h2>
|
|
<p><%= gettext("Are you sure?") %></p>
|
|
<div class="modal-action">
|
|
<button class="btn" onclick="document.getElementById('my-modal').close()">
|
|
<%= gettext("Cancel") %>
|
|
</button>
|
|
<button class="btn btn-error" phx-click="confirm-delete">
|
|
<%= gettext("Delete") %>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</dialog>
|
|
```
|
|
|
|
---
|
|
|
|
## Conclusion
|
|
|
|
These guidelines are a living document and should evolve with our project and team. When in doubt, prioritize:
|
|
|
|
1. **Clarity over cleverness** - Write code that's easy to understand
|
|
2. **Consistency over perfection** - Follow established patterns
|
|
3. **Testing over hoping** - Write tests for confidence
|
|
4. **Documentation over memory** - Don't rely on tribal knowledge
|
|
5. **Communication over assumption** - Ask questions, discuss trade-offs
|
|
6. **Accessibility for all** - Build inclusive applications
|
|
|
|
Happy coding! 🚀
|
|
|