Initial commit: Phoenix LiveView demo for interactive data tables with filtering, sorting, pagination, URL state, and progressive enhancement
All checks were successful
build / build (push) Successful in 11s

Implements a fully-featured action requests table in a single LiveView module using Flop, Ecto, and SQLite. Includes:

- Fuzzy search, status/assignment filters, column sorting, 15-per-page pagination
- Real-time updates, bookmarkable URLs via `handle_params/3`
- JS-disabled fallback with GET forms (no duplicate logic)
- 1,000,000 seeded records, Tailwind + DaisyUI styling, light/dark themes
- Comprehensive README with comparisons to Django+React/Rails+React stacks
- 31 tests covering all scenarios

Tech: Phoenix 1.8+, LiveView, Flop, Ecto, SQLite, Elixir 1.15+
This commit is contained in:
James Greenwood 2025-11-17 14:42:00 +00:00
commit cc4cc65950
41 changed files with 3890 additions and 0 deletions

6
.formatter.exs Normal file
View File

@ -0,0 +1,6 @@
[
import_deps: [:ecto, :ecto_sql, :phoenix],
subdirectories: ["priv/*/migrations"],
plugins: [Phoenix.LiveView.HTMLFormatter],
inputs: ["*.{heex,ex,exs}", "{config,lib,test}/**/*.{heex,ex,exs}", "priv/*/seeds.exs"]
]

View File

@ -0,0 +1,23 @@
name: build
on: [push]
jobs:
build:
runs-on: ubuntu-latest
steps:
- name: Login to Gitea container registry
uses: docker/login-action@v3
with:
registry: code.jamey.stream
username: jamey
password: 2a9cdbe263f11b074db5bd195ff8991e306d5837
- name: Checkout
uses: actions/checkout@v4
- name: Build and push
uses: docker/build-push-action@v6
with:
context: .
push: true
tags: |
code.jamey.stream/jamey/action-requests-demo.jamey.stream:${{ gitea.sha }}
code.jamey.stream/jamey/action-requests-demo.jamey.stream:latest

47
.gitignore vendored Normal file
View File

@ -0,0 +1,47 @@
# The directory Mix will write compiled artifacts to.
/_build/
# LLM
/.claude/
# Language server build cache.
/.elixir_ls/
# If you run "mix test --cover", coverage assets end up here.
/cover/
# The directory Mix downloads your dependencies sources to.
/deps/
# Where 3rd-party dependencies like ExDoc output generated docs.
/doc/
# Ignore .fetch files in case you like to edit your project deps locally.
/.fetch
# If the VM crashes, it generates a dump, let's ignore it too.
erl_crash.dump
# Also ignore archive artifacts (built via "mix archive.build").
*.ez
# Temporary files, for example, from tests.
/tmp/
# Ignore package tarball (built via "mix hex.build").
action_requests_demo-*.tar
# Ignore assets that are produced by build tools.
/priv/static/assets/
# Ignore digested assets cache.
/priv/static/cache_manifest.json
# In case you use Node.js/npm, you want to ignore these.
npm-debug.log
/assets/node_modules/
# Database files
*.db
*.db-*

334
AGENTS.md Normal file
View File

@ -0,0 +1,334 @@
This is a web application written using the Phoenix web framework.
## Project guidelines
- Use `mix precommit` alias when you are done with all changes and fix any pending issues
- Use the already included and available `:req` (`Req`) library for HTTP requests, **avoid** `:httpoison`, `:tesla`, and `:httpc`. Req is included by default and is the preferred HTTP client for Phoenix apps
### Phoenix v1.8 guidelines
- **Always** begin your LiveView templates with `<Layouts.app flash={@flash} ...>` which wraps all inner content
- The `MyAppWeb.Layouts` module is aliased in the `my_app_web.ex` file, so you can use it without needing to alias it again
- Anytime you run into errors with no `current_scope` assign:
- You failed to follow the Authenticated Routes guidelines, or you failed to pass `current_scope` to `<Layouts.app>`
- **Always** fix the `current_scope` error by moving your routes to the proper `live_session` and ensure you pass `current_scope` as needed
- Phoenix v1.8 moved the `<.flash_group>` component to the `Layouts` module. You are **forbidden** from calling `<.flash_group>` outside of the `layouts.ex` module
- Out of the box, `core_components.ex` imports an `<.icon name="hero-x-mark" class="w-5 h-5"/>` component for for hero icons. **Always** use the `<.icon>` component for icons, **never** use `Heroicons` modules or similar
- **Always** use the imported `<.input>` component for form inputs from `core_components.ex` when available. `<.input>` is imported and using it will will save steps and prevent errors
- If you override the default input classes (`<.input class="myclass px-2 py-1 rounded-lg">)`) class with your own values, no default classes are inherited, so your
custom classes must fully style the input
### JS and CSS guidelines
- **Use Tailwind CSS classes and custom CSS rules** to create polished, responsive, and visually stunning interfaces.
- Tailwindcss v4 **no longer needs a tailwind.config.js** and uses a new import syntax in `app.css`:
@import "tailwindcss" source(none);
@source "../css";
@source "../js";
@source "../../lib/my_app_web";
- **Always use and maintain this import syntax** in the app.css file for projects generated with `phx.new`
- **Never** use `@apply` when writing raw css
- **Always** manually write your own tailwind-based components instead of using daisyUI for a unique, world-class design
- Out of the box **only the app.js and app.css bundles are supported**
- You cannot reference an external vendor'd script `src` or link `href` in the layouts
- You must import the vendor deps into app.js and app.css to use them
- **Never write inline <script>custom js</script> tags within templates**
### UI/UX & design guidelines
- **Produce world-class UI designs** with a focus on usability, aesthetics, and modern design principles
- Implement **subtle micro-interactions** (e.g., button hover effects, and smooth transitions)
- Ensure **clean typography, spacing, and layout balance** for a refined, premium look
- Focus on **delightful details** like hover effects, loading states, and smooth page transitions
<!-- usage-rules-start -->
<!-- phoenix:elixir-start -->
## Elixir guidelines
- Elixir lists **do not support index based access via the access syntax**
**Never do this (invalid)**:
i = 0
mylist = ["blue", "green"]
mylist[i]
Instead, **always** use `Enum.at`, pattern matching, or `List` for index based list access, ie:
i = 0
mylist = ["blue", "green"]
Enum.at(mylist, i)
- Elixir variables are immutable, but can be rebound, so for block expressions like `if`, `case`, `cond`, etc
you *must* bind the result of the expression to a variable if you want to use it and you CANNOT rebind the result inside the expression, ie:
# INVALID: we are rebinding inside the `if` and the result never gets assigned
if connected?(socket) do
socket = assign(socket, :val, val)
end
# VALID: we rebind the result of the `if` to a new variable
socket =
if connected?(socket) do
assign(socket, :val, val)
end
- **Never** nest multiple modules in the same file as it can cause cyclic dependencies and compilation errors
- **Never** use map access syntax (`changeset[:field]`) on structs as they do not implement the Access behaviour by default. For regular structs, you **must** access the fields directly, such as `my_struct.field` or use higher level APIs that are available on the struct if they exist, `Ecto.Changeset.get_field/2` for changesets
- Elixir's standard library has everything necessary for date and time manipulation. Familiarize yourself with the common `Time`, `Date`, `DateTime`, and `Calendar` interfaces by accessing their documentation as necessary. **Never** install additional dependencies unless asked or for date/time parsing (which you can use the `date_time_parser` package)
- Don't use `String.to_atom/1` on user input (memory leak risk)
- Predicate function names should not start with `is_` and should end in a question mark. Names like `is_thing` should be reserved for guards
- Elixir's builtin OTP primitives like `DynamicSupervisor` and `Registry`, require names in the child spec, such as `{DynamicSupervisor, name: MyApp.MyDynamicSup}`, then you can use `DynamicSupervisor.start_child(MyApp.MyDynamicSup, child_spec)`
- Use `Task.async_stream(collection, callback, options)` for concurrent enumeration with back-pressure. The majority of times you will want to pass `timeout: :infinity` as option
## Mix guidelines
- Read the docs and options before using tasks (by using `mix help task_name`)
- To debug test failures, run tests in a specific file with `mix test test/my_test.exs` or run all previously failed tests with `mix test --failed`
- `mix deps.clean --all` is **almost never needed**. **Avoid** using it unless you have good reason
<!-- phoenix:elixir-end -->
<!-- phoenix:phoenix-start -->
## Phoenix guidelines
- Remember Phoenix router `scope` blocks include an optional alias which is prefixed for all routes within the scope. **Always** be mindful of this when creating routes within a scope to avoid duplicate module prefixes.
- You **never** need to create your own `alias` for route definitions! The `scope` provides the alias, ie:
scope "/admin", AppWeb.Admin do
pipe_through :browser
live "/users", UserLive, :index
end
the UserLive route would point to the `AppWeb.Admin.UserLive` module
- `Phoenix.View` no longer is needed or included with Phoenix, don't use it
<!-- phoenix:phoenix-end -->
<!-- phoenix:ecto-start -->
## Ecto Guidelines
- **Always** preload Ecto associations in queries when they'll be accessed in templates, ie a message that needs to reference the `message.user.email`
- Remember `import Ecto.Query` and other supporting modules when you write `seeds.exs`
- `Ecto.Schema` fields always use the `:string` type, even for `:text`, columns, ie: `field :name, :string`
- `Ecto.Changeset.validate_number/2` **DOES NOT SUPPORT the `:allow_nil` option**. By default, Ecto validations only run if a change for the given field exists and the change value is not nil, so such as option is never needed
- You **must** use `Ecto.Changeset.get_field(changeset, :field)` to access changeset fields
- Fields which are set programatically, such as `user_id`, must not be listed in `cast` calls or similar for security purposes. Instead they must be explicitly set when creating the struct
<!-- phoenix:ecto-end -->
<!-- phoenix:html-start -->
## Phoenix HTML guidelines
- Phoenix templates **always** use `~H` or .html.heex files (known as HEEx), **never** use `~E`
- **Always** use the imported `Phoenix.Component.form/1` and `Phoenix.Component.inputs_for/1` function to build forms. **Never** use `Phoenix.HTML.form_for` or `Phoenix.HTML.inputs_for` as they are outdated
- When building forms **always** use the already imported `Phoenix.Component.to_form/2` (`assign(socket, form: to_form(...))` and `<.form for={@form} id="msg-form">`), then access those forms in the template via `@form[:field]`
- **Always** add unique DOM IDs to key elements (like forms, buttons, etc) when writing templates, these IDs can later be used in tests (`<.form for={@form} id="product-form">`)
- For "app wide" template imports, you can import/alias into the `my_app_web.ex`'s `html_helpers` block, so they will be available to all LiveViews, LiveComponent's, and all modules that do `use MyAppWeb, :html` (replace "my_app" by the actual app name)
- Elixir supports `if/else` but **does NOT support `if/else if` or `if/elsif`. **Never use `else if` or `elseif` in Elixir**, **always** use `cond` or `case` for multiple conditionals.
**Never do this (invalid)**:
<%= if condition do %>
...
<% else if other_condition %>
...
<% end %>
Instead **always** do this:
<%= cond do %>
<% condition -> %>
...
<% condition2 -> %>
...
<% true -> %>
...
<% end %>
- HEEx require special tag annotation if you want to insert literal curly's like `{` or `}`. If you want to show a textual code snippet on the page in a `<pre>` or `<code>` block you *must* annotate the parent tag with `phx-no-curly-interpolation`:
<code phx-no-curly-interpolation>
let obj = {key: "val"}
</code>
Within `phx-no-curly-interpolation` annotated tags, you can use `{` and `}` without escaping them, and dynamic Elixir expressions can still be used with `<%= ... %>` syntax
- HEEx class attrs support lists, but you must **always** use list `[...]` syntax. You can use the class list syntax to conditionally add classes, **always do this for multiple class values**:
<a class={[
"px-2 text-white",
@some_flag && "py-5",
if(@other_condition, do: "border-red-500", else: "border-blue-100"),
...
]}>Text</a>
and **always** wrap `if`'s inside `{...}` expressions with parens, like done above (`if(@other_condition, do: "...", else: "...")`)
and **never** do this, since it's invalid (note the missing `[` and `]`):
<a class={
"px-2 text-white",
@some_flag && "py-5"
}> ...
=> Raises compile syntax error on invalid HEEx attr syntax
- **Never** use `<% Enum.each %>` or non-for comprehensions for generating template content, instead **always** use `<%= for item <- @collection do %>`
- HEEx HTML comments use `<%!-- comment --%>`. **Always** use the HEEx HTML comment syntax for template comments (`<%!-- comment --%>`)
- HEEx allows interpolation via `{...}` and `<%= ... %>`, but the `<%= %>` **only** works within tag bodies. **Always** use the `{...}` syntax for interpolation within tag attributes, and for interpolation of values within tag bodies. **Always** interpolate block constructs (if, cond, case, for) within tag bodies using `<%= ... %>`.
**Always** do this:
<div id={@id}>
{@my_assign}
<%= if @some_block_condition do %>
{@another_assign}
<% end %>
</div>
and **Never** do this the program will terminate with a syntax error:
<%!-- THIS IS INVALID NEVER EVER DO THIS --%>
<div id="<%= @invalid_interpolation %>">
{if @invalid_block_construct do}
{end}
</div>
<!-- phoenix:html-end -->
<!-- phoenix:liveview-start -->
## Phoenix LiveView guidelines
- **Never** use the deprecated `live_redirect` and `live_patch` functions, instead **always** use the `<.link navigate={href}>` and `<.link patch={href}>` in templates, and `push_navigate` and `push_patch` functions LiveViews
- **Avoid LiveComponent's** unless you have a strong, specific need for them
- LiveViews should be named like `AppWeb.WeatherLive`, with a `Live` suffix. When you go to add LiveView routes to the router, the default `:browser` scope is **already aliased** with the `AppWeb` module, so you can just do `live "/weather", WeatherLive`
- Remember anytime you use `phx-hook="MyHook"` and that js hook manages its own DOM, you **must** also set the `phx-update="ignore"` attribute
- **Never** write embedded `<script>` tags in HEEx. Instead always write your scripts and hooks in the `assets/js` directory and integrate them with the `assets/js/app.js` file
### LiveView streams
- **Always** use LiveView streams for collections for assigning regular lists to avoid memory ballooning and runtime termination with the following operations:
- basic append of N items - `stream(socket, :messages, [new_msg])`
- resetting stream with new items - `stream(socket, :messages, [new_msg], reset: true)` (e.g. for filtering items)
- prepend to stream - `stream(socket, :messages, [new_msg], at: -1)`
- deleting items - `stream_delete(socket, :messages, msg)`
- When using the `stream/3` interfaces in the LiveView, the LiveView template must 1) always set `phx-update="stream"` on the parent element, with a DOM id on the parent element like `id="messages"` and 2) consume the `@streams.stream_name` collection and use the id as the DOM id for each child. For a call like `stream(socket, :messages, [new_msg])` in the LiveView, the template would be:
<div id="messages" phx-update="stream">
<div :for={{id, msg} <- @streams.messages} id={id}>
{msg.text}
</div>
</div>
- LiveView streams are *not* enumerable, so you cannot use `Enum.filter/2` or `Enum.reject/2` on them. Instead, if you want to filter, prune, or refresh a list of items on the UI, you **must refetch the data and re-stream the entire stream collection, passing reset: true**:
def handle_event("filter", %{"filter" => filter}, socket) do
# re-fetch the messages based on the filter
messages = list_messages(filter)
{:noreply,
socket
|> assign(:messages_empty?, messages == [])
# reset the stream with the new messages
|> stream(:messages, messages, reset: true)}
end
- LiveView streams *do not support counting or empty states*. If you need to display a count, you must track it using a separate assign. For empty states, you can use Tailwind classes:
<div id="tasks" phx-update="stream">
<div class="hidden only:block">No tasks yet</div>
<div :for={{id, task} <- @stream.tasks} id={id}>
{task.name}
</div>
</div>
The above only works if the empty state is the only HTML block alongside the stream for-comprehension.
- **Never** use the deprecated `phx-update="append"` or `phx-update="prepend"` for collections
### LiveView tests
- `Phoenix.LiveViewTest` module and `LazyHTML` (included) for making your assertions
- Form tests are driven by `Phoenix.LiveViewTest`'s `render_submit/2` and `render_change/2` functions
- Come up with a step-by-step test plan that splits major test cases into small, isolated files. You may start with simpler tests that verify content exists, gradually add interaction tests
- **Always reference the key element IDs you added in the LiveView templates in your tests** for `Phoenix.LiveViewTest` functions like `element/2`, `has_element/2`, selectors, etc
- **Never** tests again raw HTML, **always** use `element/2`, `has_element/2`, and similar: `assert has_element?(view, "#my-form")`
- Instead of relying on testing text content, which can change, favor testing for the presence of key elements
- Focus on testing outcomes rather than implementation details
- Be aware that `Phoenix.Component` functions like `<.form>` might produce different HTML than expected. Test against the output HTML structure, not your mental model of what you expect it to be
- When facing test failures with element selectors, add debug statements to print the actual HTML, but use `LazyHTML` selectors to limit the output, ie:
html = render(view)
document = LazyHTML.from_fragment(html)
matches = LazyHTML.filter(document, "your-complex-selector")
IO.inspect(matches, label: "Matches")
### Form handling
#### Creating a form from params
If you want to create a form based on `handle_event` params:
def handle_event("submitted", params, socket) do
{:noreply, assign(socket, form: to_form(params))}
end
When you pass a map to `to_form/1`, it assumes said map contains the form params, which are expected to have string keys.
You can also specify a name to nest the params:
def handle_event("submitted", %{"user" => user_params}, socket) do
{:noreply, assign(socket, form: to_form(user_params, as: :user))}
end
#### Creating a form from changesets
When using changesets, the underlying data, form params, and errors are retrieved from it. The `:as` option is automatically computed too. E.g. if you have a user schema:
defmodule MyApp.Users.User do
use Ecto.Schema
...
end
And then you create a changeset that you pass to `to_form`:
%MyApp.Users.User{}
|> Ecto.Changeset.change()
|> to_form()
Once the form is submitted, the params will be available under `%{"user" => user_params}`.
In the template, the form form assign can be passed to the `<.form>` function component:
<.form for={@form} id="todo-form" phx-change="validate" phx-submit="save">
<.input field={@form[:field]} type="text" />
</.form>
Always give the form an explicit, unique DOM ID, like `id="todo-form"`.
#### Avoiding form errors
**Always** use a form assigned via `to_form/2` in the LiveView, and the `<.input>` component in the template. In the template **always access forms this**:
<%!-- ALWAYS do this (valid) --%>
<.form for={@form} id="my-form">
<.input field={@form[:field]} type="text" />
</.form>
And **never** do this:
<%!-- NEVER do this (invalid) --%>
<.form for={@changeset} id="my-form">
<.input field={@changeset[:field]} type="text" />
</.form>
- You are FORBIDDEN from accessing the changeset in the template as it will cause errors
- **Never** use `<.form let={f} ...>` in the template, instead **always use `<.form for={@form} ...>`**, then drive all form references from the form assign as in `@form[:field]`. The UI should **always** be driven by a `to_form/2` assigned in the LiveView module that is derived from a changeset
<!-- phoenix:liveview-end -->
<!-- usage-rules-end -->

72
Dockerfile Normal file
View File

@ -0,0 +1,72 @@
# syntax=docker/dockerfile:1.7
# Use the official Alpine Elixir image; keep the version in sync with mix.exs
ARG ELIXIR_VERSION=1.15
FROM elixir:${ELIXIR_VERSION}-alpine AS build
ENV LANG="en_US.UTF-8" \
MIX_ENV="prod" \
HOME="/app"
WORKDIR /app
RUN apk update && \
apk add --no-cache \
build-base \
sqlite-dev \
openssl-dev \
ca-certificates
RUN mix do local.hex --force, local.rebar --force
# Only copy files necessary to download deps to maximize layer caching
COPY mix.exs mix.lock ./
COPY config config
RUN mix deps.get --only ${MIX_ENV} && \
mix deps.compile
# Copy the rest of the application source
COPY lib lib
COPY priv priv
COPY assets assets
RUN mix compile
RUN mix assets.deploy
RUN mix release
# Minimal runtime image
FROM alpine:3.19 AS app
ENV LANG="en_US.UTF-8" \
MIX_ENV="prod" \
PORT="4000" \
PHX_SERVER="true" \
DATABASE_PATH="/app/data/action_requests_demo.db"
WORKDIR /app
RUN apk add --no-cache \
openssl \
ncurses-libs \
libstdc++ \
sqlite \
ca-certificates
# Keep SQLite data outside of the release directory so it can persist as a volume
RUN install -d -m 0755 /app/data
COPY --from=build /app/_build/prod/rel/action_requests_demo ./
RUN addgroup -S app && \
adduser -S app -G app && \
chown -R app:app /app
USER app
EXPOSE 4000
VOLUME ["/app/data"]
ENTRYPOINT ["./bin/action_requests_demo"]
CMD ["start"]

215
README.md Normal file
View File

@ -0,0 +1,215 @@
# Phoenix LiveView Action Requests Demo
A demonstration of how Phoenix LiveView can replace complex multi-framework stacks (Django + React, Rails + React, etc.) with a single, elegant solution for building interactive, real-time data tables.
## What This Demo Shows
This project implements a fully-featured data table with **filtering, sorting, and pagination** in a fraction of the code required by traditional stack combinations. What typically requires:
- Backend API (Django/Rails)
- Frontend framework (React/Vue)
- State management (Redux/Zustand)
- API client libraries (Axios/Fetch)
- TypeScript type definitions
- Multiple build tools and configs
...is accomplished here in **one Phoenix LiveView module** with progressive enhancement, real-time updates, and URL-based state management built in.
## Why This Matters
### Compared to Django + React (or similar stacks):
| Aspect | Django + React | Phoenix LiveView |
|--------|---------------|------------------|
| **Codebase** | Split across backend/frontend | Single unified codebase |
| **Languages** | Python + JavaScript/TypeScript | Elixir |
| **Files needed** | Views, serializers, API endpoints, React components, state management, type definitions | 1 LiveView module + 1 context |
| **State sync** | Manual API calls, polling, or WebSocket setup | Automatic via LiveView |
| **Real-time** | Requires channels/WebSockets setup | Built-in |
| **Progressive enhancement** | Requires separate server-side rendering setup | Native |
| **URL state** | Manual query param handling on both ends | Declarative with `handle_params/3` |
| **Deployment** | Two separate services | Single Elixir application |
| **Build complexity** | Webpack/Vite + Python packaging | Mix (built-in) |
### Performance Benefits
- **Reduced latency**: No API round-trips, server renders diffs only
- **Minimal bandwidth**: LiveView sends HTML diffs, not full JSON payloads
- **Efficient updates**: Only changed DOM elements are updated
- **No client-side state bugs**: Source of truth lives on the server
### Maintainability Benefits
- **Single mental model**: No context switching between languages/frameworks
- **Fewer dependencies**: No npm packages, no frontend framework updates
- **Type safety**: Elixir's pattern matching catches errors at compile time
- **Simpler testing**: Test the LiveView, not API + frontend + integration
## Features Implemented
### Core Functionality
- ✅ **Search/Filter**: Fuzzy text search on patient names
- ✅ **Status Filter**: Dropdown (All, Resolved, Unresolved)
- ✅ **Assignment Filter**: Dropdown (All, Mine, Assigned, Unassigned)
- ✅ **Sorting**: All columns with ascending/descending indicators
- ✅ **Pagination**: 15 records per page with full navigation
- ✅ **URL State**: All filters/sorting/pagination in URL (bookmarkable/shareable)
- ✅ **Progressive Enhancement**: Works with and without JavaScript
### Technical Highlights
- **Single LiveView module** handles all user interactions
- **Flop library** for efficient query building
- **SQLite database** with proper indexes
- **Real-time updates** without polling or manual WebSocket setup
- **Responsive design** with Tailwind CSS + DaisyUI
- **Theme support** with light/dark modes
## Database Schema
```elixir
# action_requests table
id :binary_id, primary_key: true
patient_name :string
status :string
assigned_user_id :integer
delivery_scheduled_at :naive_datetime
inserted_at :utc_datetime
updated_at :utc_datetime
# Indexes
- patient_name
- status
- assigned_user_id
- inserted_at
```
## Tech Stack
- **Phoenix 1.8+** - Web framework
- **LiveView** - Real-time server-rendered interactions
- **Flop** - Filtering, ordering, and pagination
- **Ecto** - Database wrapper and query builder
- **SQLite** - Embedded database (zero-config)
- **Tailwind CSS + DaisyUI** - Styling
- **Elixir** - Language
## Getting Started
### Prerequisites
- Elixir 1.15+
- Erlang/OTP 26+
### Setup
```bash
# Install dependencies and setup database
mix setup
# Start the Phoenix server
mix phx.server
# Or start inside IEx
iex -S mix phx.server
```
Visit [`localhost:4000`](http://localhost:4000) in your browser.
### Seeding Data
The database is automatically seeded with 1,000,000 sample records on first run. To reset:
```bash
mix ecto.reset
```
## Code Structure
```
lib/
├── action_requests_demo/
│ ├── action_requests/
│ │ └── action_request.ex # Schema definition
│ └── action_requests.ex # Context with query logic
└── action_requests_demo_web/
├── live/
│ └── action_requests_live.ex # Main LiveView (filtering, sorting, pagination)
└── router.ex # Single route
```
## Progressive Enhancement
This app works perfectly **without JavaScript**:
1. Forms use `method="get"` and `action="/"`
2. Filter changes trigger full page reloads via form submission
3. All state persists in URL query parameters
When JavaScript is enabled:
1. Forms trigger LiveView events instead of page reloads
2. LiveView patches the URL and re-renders
3. Updates happen in real-time with minimal data transfer
**The same code handles both modes** via `handle_params/3` - no duplicate logic required.
## Testing
```bash
# Run all tests
mix test
# Run with coverage
mix test --cover
```
31 comprehensive tests covering all filtering, sorting, and pagination scenarios.
## Key Implementation Details
### Special Filter Logic
- **"Mine"**: `assigned_user_id = current_user_id` (mocked as 1)
- **"Assigned"**: `assigned_user_id IS NOT NULL`
- **"Unassigned"**: `assigned_user_id IS NULL`
### URL Format
All state is in the URL for bookmarking/sharing:
```
/?status=resolved&assignment=mine&patient_name=john&page=2&order_by=patient_name&order_directions=asc
```
### Default Behavior
- Sort: `inserted_at DESC` (newest first)
- Per page: 15 records
- Current user: ID 1 (for demo purposes)
## Why Elixir/Phoenix?
This demo showcases Elixir's strengths:
1. **Concurrency**: BEAM VM handles thousands of concurrent connections effortlessly
2. **Fault tolerance**: Process isolation means one user's error won't crash others
3. **Low latency**: LiveView's stateful connections eliminate API overhead
4. **Developer productivity**: Write less code, ship faster, maintain easier
5. **Scalability**: Vertical and horizontal scaling built into the platform
## Production Considerations
This is a **demo application**. For production use, consider:
- Authentication/authorization (currently mocked)
- Rate limiting
- Database connection pooling configuration
- CDN for static assets
- Load balancing for multiple nodes
- Monitoring and observability
## Learn More
- **Phoenix Framework**: https://www.phoenixframework.org/
- **Phoenix LiveView**: https://hexdocs.pm/phoenix_live_view
- **Flop**: https://hexdocs.pm/flop
- **Elixir**: https://elixir-lang.org/
## License
This demonstration project is provided as-is for educational purposes.

101
assets/css/app.css Normal file
View File

@ -0,0 +1,101 @@
/* See the Tailwind configuration guide for advanced usage
https://tailwindcss.com/docs/configuration */
@import "tailwindcss" source(none);
@source "../css";
@source "../js";
@source "../../lib/action_requests_demo_web";
/* daisyUI Tailwind Plugin. You can update this file by fetching the latest version with:
curl -sLO https://github.com/saadeghi/daisyui/releases/latest/download/daisyui.js
Make sure to look at the daisyUI changelog: https://daisyui.com/docs/changelog/ */
@plugin "../vendor/daisyui" {
themes: false;
}
/* daisyUI theme plugin. You can update this file by fetching the latest version with:
curl -sLO https://github.com/saadeghi/daisyui/releases/latest/download/daisyui-theme.js
We ship with two themes, a light one inspired on Phoenix colors and a dark one inspired
on Elixir colors. Build your own at: https://daisyui.com/theme-generator/ */
@plugin "../vendor/daisyui-theme" {
name: "dark";
default: false;
prefersdark: true;
color-scheme: "dark";
--color-base-100: oklch(30.33% 0.016 252.42);
--color-base-200: oklch(25.26% 0.014 253.1);
--color-base-300: oklch(20.15% 0.012 254.09);
--color-base-content: oklch(97.807% 0.029 256.847);
--color-primary: oklch(58% 0.233 277.117);
--color-primary-content: oklch(96% 0.018 272.314);
--color-secondary: oklch(58% 0.233 277.117);
--color-secondary-content: oklch(96% 0.018 272.314);
--color-accent: oklch(60% 0.25 292.717);
--color-accent-content: oklch(96% 0.016 293.756);
--color-neutral: oklch(37% 0.044 257.287);
--color-neutral-content: oklch(98% 0.003 247.858);
--color-info: oklch(58% 0.158 241.966);
--color-info-content: oklch(97% 0.013 236.62);
--color-success: oklch(60% 0.118 184.704);
--color-success-content: oklch(98% 0.014 180.72);
--color-warning: oklch(66% 0.179 58.318);
--color-warning-content: oklch(98% 0.022 95.277);
--color-error: oklch(58% 0.253 17.585);
--color-error-content: oklch(96% 0.015 12.422);
--radius-selector: 0.25rem;
--radius-field: 0.25rem;
--radius-box: 0.5rem;
--size-selector: 0.21875rem;
--size-field: 0.21875rem;
--border: 1.5px;
--depth: 1;
--noise: 0;
}
@plugin "../vendor/daisyui-theme" {
name: "light";
default: true;
prefersdark: false;
color-scheme: "light";
--color-base-100: oklch(98% 0 0);
--color-base-200: oklch(96% 0.001 286.375);
--color-base-300: oklch(92% 0.004 286.32);
--color-base-content: oklch(21% 0.006 285.885);
--color-primary: oklch(70% 0.213 47.604);
--color-primary-content: oklch(98% 0.016 73.684);
--color-secondary: oklch(55% 0.027 264.364);
--color-secondary-content: oklch(98% 0.002 247.839);
--color-accent: oklch(0% 0 0);
--color-accent-content: oklch(100% 0 0);
--color-neutral: oklch(44% 0.017 285.786);
--color-neutral-content: oklch(98% 0 0);
--color-info: oklch(62% 0.214 259.815);
--color-info-content: oklch(97% 0.014 254.604);
--color-success: oklch(70% 0.14 182.503);
--color-success-content: oklch(98% 0.014 180.72);
--color-warning: oklch(66% 0.179 58.318);
--color-warning-content: oklch(98% 0.022 95.277);
--color-error: oklch(58% 0.253 17.585);
--color-error-content: oklch(96% 0.015 12.422);
--radius-selector: 0.25rem;
--radius-field: 0.25rem;
--radius-box: 0.5rem;
--size-selector: 0.21875rem;
--size-field: 0.21875rem;
--border: 1.5px;
--depth: 1;
--noise: 0;
}
/* Add variants based on LiveView classes */
@custom-variant phx-click-loading (.phx-click-loading&, .phx-click-loading &);
@custom-variant phx-submit-loading (.phx-submit-loading&, .phx-submit-loading &);
@custom-variant phx-change-loading (.phx-change-loading&, .phx-change-loading &);
/* Use the data attribute for dark mode */
@custom-variant dark (&:where([data-theme=dark], [data-theme=dark] *));
/* Make LiveView wrapper divs transparent for layout */
[data-phx-session], [data-phx-teleported-src] { display: contents }
/* This file is for your main application CSS */

77
assets/js/app.js Normal file
View File

@ -0,0 +1,77 @@
// If you want to use Phoenix channels, run `mix help phx.gen.channel`
// to get started and then uncomment the line below.
// import "./user_socket.js"
// You can include dependencies in two ways.
//
// The simplest option is to put them in assets/vendor and
// import them using relative paths:
//
// import "../vendor/some-package.js"
//
// Alternatively, you can `npm install some-package --prefix assets` and import
// them using a path starting with the package name:
//
// import "some-package"
//
// If you have dependencies that try to import CSS, esbuild will generate a separate `app.css` file.
// To load it, simply add a second `<link>` to your `root.html.heex` file.
// Include phoenix_html to handle method=PUT/DELETE in forms and buttons.
import "phoenix_html"
// Establish Phoenix Socket and LiveView configuration.
import {Socket} from "phoenix"
import {LiveSocket} from "phoenix_live_view"
import {hooks as colocatedHooks} from "phoenix-colocated/action_requests_demo"
const csrfToken = document.querySelector("meta[name='csrf-token']").getAttribute("content")
const liveSocket = new LiveSocket("/live", Socket, {
longPollFallbackMs: 2500,
params: {_csrf_token: csrfToken},
hooks: {...colocatedHooks},
})
// connect if there are any LiveViews on the page
liveSocket.connect()
// expose liveSocket on window for web console debug logs and latency simulation:
// >> liveSocket.enableDebug()
// >> liveSocket.enableLatencySim(1000) // enabled for duration of browser session
// >> liveSocket.disableLatencySim()
window.liveSocket = liveSocket
// The lines below enable quality of life phoenix_live_reload
// development features:
//
// 1. stream server logs to the browser console
// 2. click on elements to jump to their definitions in your code editor
//
if (process.env.NODE_ENV === "development") {
window.addEventListener("phx:live_reload:attached", ({detail: reloader}) => {
// Enable server log streaming to client.
// Disable with reloader.disableServerLogs()
reloader.enableServerLogs()
// Open configured PLUG_EDITOR at file:line of the clicked element's HEEx component
//
// * click with "c" key pressed to open at caller location
// * click with "d" key pressed to open at function component definition location
let keyDown
window.addEventListener("keydown", e => keyDown = e.key)
window.addEventListener("keyup", e => keyDown = null)
window.addEventListener("click", e => {
if(keyDown === "c"){
e.preventDefault()
e.stopImmediatePropagation()
reloader.openEditorAtCaller(e.target)
} else if(keyDown === "d"){
e.preventDefault()
e.stopImmediatePropagation()
reloader.openEditorAtDef(e.target)
}
}, true)
window.liveReloader = reloader
})
}

32
assets/tsconfig.json Normal file
View File

@ -0,0 +1,32 @@
// This file is needed on most editors to enable the intelligent autocompletion
// of LiveView's JavaScript API methods. You can safely delete it if you don't need it.
//
// Note: This file assumes a basic esbuild setup without node_modules.
// We include a generic paths alias to deps to mimic how esbuild resolves
// the Phoenix and LiveView JavaScript assets.
// If you have a package.json in your project, you should remove the
// paths configuration and instead add the phoenix dependencies to the
// dependencies section of your package.json:
//
// {
// ...
// "dependencies": {
// ...,
// "phoenix": "../deps/phoenix",
// "phoenix_html": "../deps/phoenix_html",
// "phoenix_live_view": "../deps/phoenix_live_view"
// }
// }
//
// Feel free to adjust this configuration however you need.
{
"compilerOptions": {
"baseUrl": ".",
"paths": {
"*": ["../deps/*"]
},
"allowJs": true,
"noEmit": true
},
"include": ["js/**/*"]
}

124
assets/vendor/daisyui-theme.js vendored Normal file

File diff suppressed because one or more lines are too long

1031
assets/vendor/daisyui.js vendored Normal file

File diff suppressed because one or more lines are too long

56
config/config.exs Normal file
View File

@ -0,0 +1,56 @@
# This file is responsible for configuring your application
# and its dependencies with the aid of the Config module.
#
# This configuration file is loaded before any dependency and
# is restricted to this project.
# General application configuration
import Config
config :action_requests_demo,
ecto_repos: [ActionRequestsDemo.Repo],
generators: [timestamp_type: :utc_datetime]
# Configures the endpoint
config :action_requests_demo, ActionRequestsDemoWeb.Endpoint,
url: [host: "localhost"],
adapter: Bandit.PhoenixAdapter,
render_errors: [
formats: [html: ActionRequestsDemoWeb.ErrorHTML, json: ActionRequestsDemoWeb.ErrorJSON],
layout: false
],
pubsub_server: ActionRequestsDemo.PubSub,
live_view: [signing_salt: "ru8ss/3f"]
# Configure esbuild (the version is required)
config :esbuild,
version: "0.25.4",
action_requests_demo: [
args:
~w(js/app.js --bundle --target=es2022 --outdir=../priv/static/assets/js --external:/fonts/* --external:/images/* --alias:@=.),
cd: Path.expand("../assets", __DIR__),
env: %{"NODE_PATH" => [Path.expand("../deps", __DIR__), Mix.Project.build_path()]}
]
# Configure tailwind (the version is required)
config :tailwind,
version: "4.1.7",
action_requests_demo: [
args: ~w(
--input=assets/css/app.css
--output=priv/static/assets/css/app.css
),
cd: Path.expand("..", __DIR__)
]
# Configures Elixir's Logger
config :logger, :default_formatter,
format: "$time $metadata[$level] $message\n",
metadata: [:request_id]
# Use Jason for JSON parsing in Phoenix
config :phoenix, :json_library, Jason
# Import environment specific config. This must remain at the bottom
# of this file so it overrides the configuration defined above.
import_config "#{config_env()}.exs"

82
config/dev.exs Normal file
View File

@ -0,0 +1,82 @@
import Config
# Configure your database
config :action_requests_demo, ActionRequestsDemo.Repo,
database: Path.expand("../action_requests_demo_dev.db", __DIR__),
pool_size: 5,
stacktrace: true,
show_sensitive_data_on_connection_error: true
# For development, we disable any cache and enable
# debugging and code reloading.
#
# The watchers configuration can be used to run external
# watchers to your application. For example, we can use it
# to bundle .js and .css sources.
config :action_requests_demo, ActionRequestsDemoWeb.Endpoint,
# Binding to loopback ipv4 address prevents access from other machines.
# Change to `ip: {0, 0, 0, 0}` to allow access from other machines.
http: [ip: {127, 0, 0, 1}, port: String.to_integer(System.get_env("PORT") || "4000")],
check_origin: false,
code_reloader: true,
debug_errors: true,
secret_key_base: "YOZyuoKzeTWbxwCzvfTM7fHK9mVJhVWZ4NFZXUfMCJwTGZfmKzCabd1CfaXliqXW",
watchers: [
esbuild: {Esbuild, :install_and_run, [:action_requests_demo, ~w(--sourcemap=inline --watch)]},
tailwind: {Tailwind, :install_and_run, [:action_requests_demo, ~w(--watch)]}
]
# ## SSL Support
#
# In order to use HTTPS in development, a self-signed
# certificate can be generated by running the following
# Mix task:
#
# mix phx.gen.cert
#
# Run `mix help phx.gen.cert` for more information.
#
# The `http:` config above can be replaced with:
#
# https: [
# port: 4001,
# cipher_suite: :strong,
# keyfile: "priv/cert/selfsigned_key.pem",
# certfile: "priv/cert/selfsigned.pem"
# ],
#
# If desired, both `http:` and `https:` keys can be
# configured to run both http and https servers on
# different ports.
# Watch static and templates for browser reloading.
config :action_requests_demo, ActionRequestsDemoWeb.Endpoint,
live_reload: [
web_console_logger: true,
patterns: [
~r"priv/static/(?!uploads/).*(js|css|png|jpeg|jpg|gif|svg)$",
~r"priv/gettext/.*(po)$",
~r"lib/action_requests_demo_web/(?:controllers|live|components|router)/?.*\.(ex|heex)$"
]
]
# Enable dev routes for dashboard and mailbox
config :action_requests_demo, dev_routes: true
# Do not include metadata nor timestamps in development logs
config :logger, :default_formatter, format: "[$level] $message\n"
# Set a higher stacktrace during development. Avoid configuring such
# in production as building large stacktraces may be expensive.
config :phoenix, :stacktrace_depth, 20
# Initialize plugs at runtime for faster development compilation
config :phoenix, :plug_init_mode, :runtime
config :phoenix_live_view,
# Include debug annotations and locations in rendered markup.
# Changing this configuration will require mix clean and a full recompile.
debug_heex_annotations: true,
debug_attributes: true,
# Enable helpful, but potentially expensive runtime checks
enable_expensive_runtime_checks: true

22
config/prod.exs Normal file
View File

@ -0,0 +1,22 @@
import Config
# Note we also include the path to a cache manifest
# containing the digested version of static files. This
# manifest is generated by the `mix assets.deploy` task,
# which you should run after static files are built and
# before starting your production server.
config :action_requests_demo, ActionRequestsDemoWeb.Endpoint,
cache_static_manifest: "priv/static/cache_manifest.json"
# Allow LiveView/WebSocket connections from the deployed host
config :action_requests_demo, ActionRequestsDemoWeb.Endpoint,
check_origin: [
"https://action-requests-demo.jamey.stream",
"//action-requests-demo.jamey.stream"
]
# Do not print debug messages in production
config :logger, level: :info
# Runtime production configuration, including reading
# of environment variables, is done on config/runtime.exs.

95
config/runtime.exs Normal file
View File

@ -0,0 +1,95 @@
import Config
# config/runtime.exs is executed for all environments, including
# during releases. It is executed after compilation and before the
# system starts, so it is typically used to load production configuration
# and secrets from environment variables or elsewhere. Do not define
# any compile-time configuration in here, as it won't be applied.
# The block below contains prod specific runtime configuration.
# ## Using releases
#
# If you use `mix release`, you need to explicitly enable the server
# by passing the PHX_SERVER=true when you start it:
#
# PHX_SERVER=true bin/action_requests_demo start
#
# Alternatively, you can use `mix phx.gen.release` to generate a `bin/server`
# script that automatically sets the env var above.
if System.get_env("PHX_SERVER") do
config :action_requests_demo, ActionRequestsDemoWeb.Endpoint, server: true
end
if config_env() == :prod do
database_path =
System.get_env("DATABASE_PATH") ||
raise """
environment variable DATABASE_PATH is missing.
For example: /etc/action_requests_demo/action_requests_demo.db
"""
config :action_requests_demo, ActionRequestsDemo.Repo,
database: database_path,
pool_size: String.to_integer(System.get_env("POOL_SIZE") || "5")
# The secret key base is used to sign/encrypt cookies and other secrets.
# A default value is used in config/dev.exs and config/test.exs but you
# want to use a different value for prod and you most likely don't want
# to check this value into version control, so we use an environment
# variable instead.
secret_key_base =
System.get_env("SECRET_KEY_BASE") ||
raise """
environment variable SECRET_KEY_BASE is missing.
You can generate one by calling: mix phx.gen.secret
"""
host = System.get_env("PHX_HOST") || "example.com"
port = String.to_integer(System.get_env("PORT") || "4000")
config :action_requests_demo, :dns_cluster_query, System.get_env("DNS_CLUSTER_QUERY")
config :action_requests_demo, ActionRequestsDemoWeb.Endpoint,
url: [host: host, port: 443, scheme: "https"],
http: [
# Enable IPv6 and bind on all interfaces.
# Set it to {0, 0, 0, 0, 0, 0, 0, 1} for local network only access.
# See the documentation on https://hexdocs.pm/bandit/Bandit.html#t:options/0
# for details about using IPv6 vs IPv4 and loopback vs public addresses.
ip: {0, 0, 0, 0, 0, 0, 0, 0},
port: port
],
secret_key_base: secret_key_base
# ## SSL Support
#
# To get SSL working, you will need to add the `https` key
# to your endpoint configuration:
#
# config :action_requests_demo, ActionRequestsDemoWeb.Endpoint,
# https: [
# ...,
# port: 443,
# cipher_suite: :strong,
# keyfile: System.get_env("SOME_APP_SSL_KEY_PATH"),
# certfile: System.get_env("SOME_APP_SSL_CERT_PATH")
# ]
#
# The `cipher_suite` is set to `:strong` to support only the
# latest and more secure SSL ciphers. This means old browsers
# and clients may not be supported. You can set it to
# `:compatible` for wider support.
#
# `:keyfile` and `:certfile` expect an absolute path to the key
# and cert in disk or a relative path inside priv, for example
# "priv/ssl/server.key". For all supported SSL configuration
# options, see https://hexdocs.pm/plug/Plug.SSL.html#configure/1
#
# We also recommend setting `force_ssl` in your config/prod.exs,
# ensuring no data is ever sent via http, always redirecting to https:
#
# config :action_requests_demo, ActionRequestsDemoWeb.Endpoint,
# force_ssl: [hsts: true]
#
# Check `Plug.SSL` for all available options in `force_ssl`.
end

28
config/test.exs Normal file
View File

@ -0,0 +1,28 @@
import Config
# Configure your database
#
# The MIX_TEST_PARTITION environment variable can be used
# to provide built-in test partitioning in CI environment.
# Run `mix help test` for more information.
config :action_requests_demo, ActionRequestsDemo.Repo,
database: Path.expand("../action_requests_demo_test.db", __DIR__),
pool_size: 5,
pool: Ecto.Adapters.SQL.Sandbox
# We don't run a server during test. If one is required,
# you can enable the server option below.
config :action_requests_demo, ActionRequestsDemoWeb.Endpoint,
http: [ip: {127, 0, 0, 1}, port: 4002],
secret_key_base: "O6eCv6wWUKzOCpZEPEdPM8N+Np9DN6VV1uU33op7FLLcywj0Tor8CaU/k/h31hO7",
server: false
# Print only warnings and errors during test
config :logger, level: :warning
# Initialize plugs at runtime for faster test compilation
config :phoenix, :plug_init_mode, :runtime
# Enable helpful, but potentially expensive runtime checks
config :phoenix_live_view,
enable_expensive_runtime_checks: true

View File

@ -0,0 +1,9 @@
defmodule ActionRequestsDemo do
@moduledoc """
ActionRequestsDemo keeps the contexts that define your domain
and business logic.
Contexts are also responsible for managing your data, regardless
if it comes from the database, an external API or others.
"""
end

View File

@ -0,0 +1,79 @@
defmodule ActionRequestsDemo.ActionRequests do
import Ecto.Query
alias ActionRequestsDemo.{Repo, ActionRequests.ActionRequest}
def list_action_requests(params, current_user_id \\ nil) do
# Extract custom filters
patient_name = get_in(params, ["patient_name"]) |> clean_param()
status = get_in(params, ["status"]) |> clean_param()
assignment = get_in(params, ["assignment"]) |> clean_param()
# Build base query with custom filters
query =
ActionRequest
|> apply_patient_name_filter(patient_name)
|> apply_status_filter(status)
|> apply_assignment_filter(assignment, current_user_id)
# Use Flop for sorting and pagination only
flop_params = Map.drop(params, ["patient_name", "status", "assignment"])
# Set default sort if not specified
flop_params =
if is_nil(flop_params["order_by"]) || flop_params["order_by"] == "" do
flop_params
|> Map.put("order_by", ["inserted_at"])
|> Map.put("order_directions", ["desc"])
else
# Convert order_by and order_directions to arrays if they're strings
flop_params
|> maybe_listify("order_by")
|> maybe_listify("order_directions")
end
Flop.validate_and_run(query, flop_params, for: ActionRequest, repo: Repo)
end
defp maybe_listify(params, key) do
case params[key] do
nil -> params
"" -> Map.delete(params, key)
val when is_binary(val) -> Map.put(params, key, [val])
val when is_atom(val) -> Map.put(params, key, [val])
val when is_list(val) -> params
_ -> params
end
end
defp clean_param(nil), do: nil
defp clean_param(""), do: nil
defp clean_param("all"), do: nil
defp clean_param(val), do: val
defp apply_patient_name_filter(query, nil), do: query
defp apply_patient_name_filter(query, name) do
pattern = "%#{name}%"
where(query, [a], like(a.patient_name, ^pattern))
end
defp apply_status_filter(query, nil), do: query
defp apply_status_filter(query, status) do
where(query, [a], a.status == ^status)
end
defp apply_assignment_filter(query, nil, _), do: query
defp apply_assignment_filter(query, "mine", current_user_id) do
where(query, [a], a.assigned_user_id == ^current_user_id)
end
defp apply_assignment_filter(query, "assigned", _) do
where(query, [a], not is_nil(a.assigned_user_id))
end
defp apply_assignment_filter(query, "unassigned", _) do
where(query, [a], is_nil(a.assigned_user_id))
end
end

View File

@ -0,0 +1,29 @@
defmodule ActionRequestsDemo.ActionRequests.ActionRequest do
use Ecto.Schema
import Ecto.Changeset
@derive {
Flop.Schema,
filterable: [:patient_name, :status, :assigned_user_id],
sortable: [:patient_name, :status, :inserted_at, :delivery_scheduled_at],
default_limit: 15
}
@primary_key {:id, :binary_id, autogenerate: true}
@foreign_key_type :binary_id
schema "action_requests" do
field :patient_name, :string
field :status, :string
field :assigned_user_id, :integer
field :delivery_scheduled_at, :naive_datetime
timestamps(type: :utc_datetime)
end
@doc false
def changeset(action_request, attrs) do
action_request
|> cast(attrs, [:patient_name, :status, :assigned_user_id, :delivery_scheduled_at])
|> validate_required([:patient_name, :status])
end
end

View File

@ -0,0 +1,34 @@
defmodule ActionRequestsDemo.Application do
# See https://hexdocs.pm/elixir/Application.html
# for more information on OTP Applications
@moduledoc false
use Application
@impl true
def start(_type, _args) do
children = [
ActionRequestsDemo.Repo,
{Ecto.Migrator,
repos: Application.fetch_env!(:action_requests_demo, :ecto_repos), skip: skip_migrations?()},
{Phoenix.PubSub, name: ActionRequestsDemo.PubSub},
ActionRequestsDemoWeb.Endpoint
]
opts = [strategy: :one_for_one, name: ActionRequestsDemo.Supervisor]
Supervisor.start_link(children, opts)
end
# Tell Phoenix to update the endpoint configuration
# whenever the application is updated.
@impl true
def config_change(changed, _new, removed) do
ActionRequestsDemoWeb.Endpoint.config_change(changed, removed)
:ok
end
defp skip_migrations?() do
# By default, sqlite migrations are run when using a release
System.get_env("RELEASE_NAME") == nil
end
end

View File

@ -0,0 +1,70 @@
defmodule ActionRequestsDemo.Release do
@moduledoc """
Helpers for running tasks in a release environment.
This module is intended for commands invoked via:
bin/action_requests_demo eval "ActionRequestsDemo.Release.seed()"
"""
@app :action_requests_demo
@doc """
Run the database seeds script inside a release.
This is designed to be run against a *running* release (e.g. via
`bin/action_requests_demo eval ...` inside your Docker container),
so it does **not** try to start the application or endpoint again.
It only ensures Faker is running, then evaluates the standard
`priv/repo/seeds.exs` file, which uses `ActionRequestsDemo.Repo`.
"""
def seed do
ensure_app_started(ActionRequestsDemo.Repo)
ensure_app_started(:faker)
seeds_path = Application.app_dir(@app, "priv/repo/seeds.exs")
Code.require_file(seeds_path)
end
defp ensure_app_started(app_or_repo) do
case Application.ensure_all_started(app_or_repo) do
{:ok, _} ->
:ok
{:error, {:already_started, _pid}} ->
:ok
{:error, {:not_started, _}} ->
start_repo(app_or_repo)
{:error, reason} ->
raise "Could not start #{inspect(app_or_repo)}: #{inspect(reason)}"
end
rescue
UndefinedFunctionError ->
start_repo(app_or_repo)
end
defp start_repo(repo) when is_atom(repo) do
case repo.start_link() do
{:ok, _pid} ->
:ok
{:error, {:already_started, _pid}} ->
:ok
other ->
raise "Could not start #{inspect(repo)}: #{inspect(other)}"
end
end
defp start_repo(app) do
case Application.ensure_all_started(app) do
{:ok, _} -> :ok
{:error, {:already_started, _}} -> :ok
other -> raise "Could not start #{inspect(app)}: #{inspect(other)}"
end
end
end

View File

@ -0,0 +1,5 @@
defmodule ActionRequestsDemo.Repo do
use Ecto.Repo,
otp_app: :action_requests_demo,
adapter: Ecto.Adapters.SQLite3
end

View File

@ -0,0 +1,103 @@
defmodule ActionRequestsDemoWeb do
@moduledoc """
The entrypoint for defining your web interface, such
as controllers, components, channels, and so on.
This can be used in your application as:
use ActionRequestsDemoWeb, :controller
use ActionRequestsDemoWeb, :html
The definitions below will be executed for every controller,
component, etc, so keep them short and clean, focused
on imports, uses and aliases.
Do NOT define functions inside the quoted expressions
below. Instead, define additional modules and import
those modules here.
"""
def static_paths, do: ~w(assets fonts images favicon.ico robots.txt)
def router do
quote do
use Phoenix.Router, helpers: false
# Import common connection and controller functions to use in pipelines
import Plug.Conn
import Phoenix.Controller
import Phoenix.LiveView.Router
end
end
def controller do
quote do
use Phoenix.Controller, formats: [:html]
import Plug.Conn
unquote(verified_routes())
end
end
def live_view do
quote do
use Phoenix.LiveView
unquote(html_helpers())
end
end
def live_component do
quote do
use Phoenix.LiveComponent
unquote(html_helpers())
end
end
def html do
quote do
use Phoenix.Component
# Import convenience functions from controllers
import Phoenix.Controller,
only: [get_csrf_token: 0, view_module: 1, view_template: 1]
# Include general helpers for rendering HTML
unquote(html_helpers())
end
end
defp html_helpers do
quote do
# HTML escaping functionality
import Phoenix.HTML
# Core UI components
import ActionRequestsDemoWeb.CoreComponents
# Common modules used in templates
alias Phoenix.LiveView.JS
alias ActionRequestsDemoWeb.Layouts
# Routes generation with the ~p sigil
unquote(verified_routes())
end
end
def verified_routes do
quote do
use Phoenix.VerifiedRoutes,
endpoint: ActionRequestsDemoWeb.Endpoint,
router: ActionRequestsDemoWeb.Router,
statics: ActionRequestsDemoWeb.static_paths()
end
end
@doc """
When used, dispatch to the appropriate controller/live_view/etc.
"""
defmacro __using__(which) when is_atom(which) do
apply(__MODULE__, which, [])
end
end

View File

@ -0,0 +1,76 @@
defmodule ActionRequestsDemoWeb.CoreComponents do
@moduledoc """
Provides core UI components. Only the minimal set for demo: icon, show/hide JS helpers, and error translation.
"""
use Phoenix.Component
alias Phoenix.LiveView.JS
@doc """
Renders flash notices.
"""
attr :id, :string, doc: "the optional id of flash container"
attr :flash, :map, default: %{}, doc: "the map of flash messages to display"
attr :title, :string, default: nil
attr :kind, :atom, values: [:info, :error], doc: "used for styling and flash lookup"
attr :rest, :global, doc: "the arbitrary HTML attributes to add to the flash container"
slot :inner_block, doc: "the optional inner block that renders the flash message"
def flash(assigns) do
assigns = assign_new(assigns, :id, fn -> "flash-#{assigns.kind}" end)
~H"""
<div
:if={msg = render_slot(@inner_block) || Phoenix.Flash.get(@flash, @kind)}
id={@id}
phx-click={JS.push("lv:clear-flash", value: %{key: @kind}) |> hide("##{@id}")}
role="alert"
class="toast toast-top toast-end z-50"
{@rest}
>
<div class={[
"alert w-80 sm:w-96 max-w-80 sm:max-w-96 text-wrap",
@kind == :info && "alert-info",
@kind == :error && "alert-error"
]}>
<div>
<p :if={@title} class="font-semibold">{@title}</p>
<p>{msg}</p>
</div>
<div class="flex-1" />
<button type="button" class="group self-start cursor-pointer" aria-label="close">
&times;
</button>
</div>
</div>
"""
end
## JS Commands
@doc """
Shows an element with animation.
"""
def show(js \\ %JS{}, selector) do
JS.show(js,
to: selector,
time: 300,
transition:
{"transition-all ease-out duration-300",
"opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95",
"opacity-100 translate-y-0 sm:scale-100"}
)
end
@doc """
Hides an element with animation.
"""
def hide(js \\ %JS{}, selector) do
JS.hide(js,
to: selector,
time: 200,
transition:
{"transition-all ease-in duration-200", "opacity-100 translate-y-0 sm:scale-100",
"opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"}
)
end
end

View File

@ -0,0 +1,92 @@
defmodule ActionRequestsDemoWeb.Layouts do
@moduledoc """
This module holds layouts and related functionality
used by your application.
"""
use ActionRequestsDemoWeb, :html
# Embed all files in layouts/* within this module.
# The default root.html.heex file contains the HTML
# skeleton of your application, namely HTML headers
# and other static content.
embed_templates "layouts/*"
@doc """
Renders your app layout.
This function is typically invoked from every template,
and it often contains your application menu, sidebar,
or similar.
## Examples
<Layouts.app flash={@flash}>
<h1>Content</h1>
</Layouts.app>
"""
attr :flash, :map, required: true, doc: "the map of flash messages"
attr :current_scope, :map,
default: nil,
doc: "the current [scope](https://hexdocs.pm/phoenix/scopes.html)"
slot :inner_block, required: true
def app(assigns) do
~H"""
<main>
{render_slot(@inner_block)}
</main>
<.flash_group flash={@flash} />
"""
end
@doc """
Shows the flash group with standard titles and content.
## Examples
<.flash_group flash={@flash} />
"""
attr :flash, :map, required: true, doc: "the map of flash messages"
attr :id, :string, default: "flash-group", doc: "the optional id of flash container"
def flash_group(assigns) do
~H"""
<div id={@id} aria-live="polite">
<.flash kind={:info} flash={@flash} />
<.flash kind={:error} flash={@flash} />
<.flash
id="client-error"
kind={:error}
title="We can't find the internet"
phx-disconnected={show(".phx-client-error #client-error") |> JS.remove_attribute("hidden")}
phx-connected={hide("#client-error") |> JS.set_attribute({"hidden", ""})}
hidden
>
Attempting to reconnect
</.flash>
<.flash
id="server-error"
kind={:error}
title="Something went wrong!"
phx-disconnected={show(".phx-server-error #server-error") |> JS.remove_attribute("hidden")}
phx-connected={hide("#server-error") |> JS.set_attribute({"hidden", ""})}
hidden
>
Attempting to reconnect
</.flash>
</div>
"""
end
@doc """
Provides dark vs light theme toggle based on themes defined in app.css.
See <head> in root.html.heex which applies the theme before page load.
"""
end

View File

@ -0,0 +1,36 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="csrf-token" content={get_csrf_token()} />
<.live_title default="ActionRequestsDemo" suffix=" · Phoenix Framework">
{assigns[:page_title]}
</.live_title>
<link phx-track-static rel="stylesheet" href={~p"/assets/css/app.css"} />
<script defer phx-track-static type="text/javascript" src={~p"/assets/js/app.js"}>
</script>
<script>
(() => {
const setTheme = (theme) => {
if (theme === "system") {
localStorage.removeItem("phx:theme");
document.documentElement.removeAttribute("data-theme");
} else {
localStorage.setItem("phx:theme", theme);
document.documentElement.setAttribute("data-theme", theme);
}
};
if (!document.documentElement.hasAttribute("data-theme")) {
setTheme(localStorage.getItem("phx:theme") || "system");
}
window.addEventListener("storage", (e) => e.key === "phx:theme" && setTheme(e.newValue || "system"));
window.addEventListener("phx:set-theme", (e) => setTheme(e.target.dataset.phxTheme));
})();
</script>
</head>
<body>
{@inner_content}
</body>
</html>

View File

@ -0,0 +1,50 @@
defmodule ActionRequestsDemoWeb.Endpoint do
use Phoenix.Endpoint, otp_app: :action_requests_demo
# The session will be stored in the cookie and signed,
# this means its contents can be read but not tampered with.
# Set :encryption_salt if you would also like to encrypt it.
@session_options [
store: :cookie,
key: "_action_requests_demo_key",
signing_salt: "gcCGnfEA",
same_site: "Lax"
]
socket "/live", Phoenix.LiveView.Socket,
websocket: [connect_info: [session: @session_options]],
longpoll: [connect_info: [session: @session_options]]
# Serve at "/" the static files from "priv/static" directory.
#
# When code reloading is disabled (e.g., in production),
# the `gzip` option is enabled to serve compressed
# static files generated by running `phx.digest`.
plug Plug.Static,
at: "/",
from: :action_requests_demo,
gzip: not code_reloading?,
only: ActionRequestsDemoWeb.static_paths()
# Code reloading can be explicitly enabled under the
# :code_reloader configuration of your endpoint.
if code_reloading? do
socket "/phoenix/live_reload/socket", Phoenix.LiveReloader.Socket
plug Phoenix.LiveReloader
plug Phoenix.CodeReloader
plug Phoenix.Ecto.CheckRepoStatus, otp_app: :action_requests_demo
end
plug Plug.RequestId
plug Plug.Telemetry, event_prefix: [:phoenix, :endpoint]
plug Plug.Parsers,
parsers: [:urlencoded, :multipart, :json],
pass: ["*/*"],
json_decoder: Phoenix.json_library()
plug Plug.MethodOverride
plug Plug.Head
plug Plug.Session, @session_options
plug ActionRequestsDemoWeb.Router
end

View File

@ -0,0 +1,301 @@
defmodule ActionRequestsDemoWeb.ActionRequestsLive do
use ActionRequestsDemoWeb, :live_view
alias ActionRequestsDemo.ActionRequests
def mount(_params, _session, socket) do
{:ok, assign(socket, current_user_id: 1)}
end
def handle_params(params, _uri, socket) do
case ActionRequests.list_action_requests(params, socket.assigns.current_user_id) do
{:ok, {action_requests, meta}} ->
{:noreply,
socket
|> assign(:action_requests, action_requests)
|> assign(:meta, meta)
|> assign(:params, params)}
{:error, _meta} ->
{:noreply,
socket
|> assign(:action_requests, [])
|> assign(:meta, %Flop.Meta{})
|> assign(:params, params)}
end
end
def handle_event("filter", params, socket) do
# Navigate with new params, which will trigger handle_params
{:noreply, push_patch(socket, to: ~p"/?#{params}")}
end
def handle_event("patch", params, socket) do
# Handle pagination events from paginator links
{:noreply, push_patch(socket, to: ~p"/?#{params}")}
end
def render(assigns) do
~H"""
<div class="px-4 py-8 sm:px-6 lg:px-8">
<div class="mx-auto max-w-7xl">
<h1 class="text-3xl font-bold mb-8">Action Requests</h1>
<.form
for={%{}}
method="get"
action={~p"/"}
phx-submit="filter"
phx-change="filter"
class="mb-6"
>
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-4">
<div>
<label class="block text-sm font-medium text-white mb-1">Status</label>
<select
name="status"
class="w-full rounded-md border border-gray-400 shadow-sm focus:border-blue-500 focus:ring-blue-500 px-3 py-2"
>
<option value="" selected={@params["status"] in [nil, "", "all"]}>
All
</option>
<option value="resolved" selected={@params["status"] == "resolved"}>
Resolved
</option>
<option value="unresolved" selected={@params["status"] == "unresolved"}>
Unresolved
</option>
</select>
</div>
<div>
<label class="block text-sm font-medium text-white mb-1">Assignment</label>
<select
name="assignment"
class="w-full rounded-md border border-gray-400 shadow-sm focus:border-blue-500 focus:ring-blue-500 px-3 py-2"
>
<option value="" selected={@params["assignment"] in [nil, ""]}>
All
</option>
<option value="mine" selected={@params["assignment"] == "mine"}>
Mine
</option>
<option value="assigned" selected={@params["assignment"] == "assigned"}>
Assigned
</option>
<option value="unassigned" selected={@params["assignment"] == "unassigned"}>
Unassigned
</option>
</select>
</div>
<div>
<label class="block text-sm font-medium text-white mb-1">Patient Name</label>
<input
type="text"
name="patient_name"
value={@params["patient_name"] || ""}
placeholder="Search by name..."
autocomplete="off"
class="w-full rounded-md border border-gray-400 shadow-sm focus:border-blue-500 focus:ring-blue-500 px-3 py-2 leading-4"
phx-debounce="100"
/>
</div>
<div class="flex items-end">
<noscript>
<button
type="submit"
class="w-full rounded-md bg-blue-600 px-4 py-2 text-sm font-semibold text-white shadow-sm hover:bg-blue-500"
>
Apply Filters
</button>
</noscript>
</div>
</div>
</.form>
<div class="overflow-x-auto rounded-lg border border-gray-200">
<table class="min-w-full divide-y divide-gray-200">
<thead class="bg-gray-50">
<tr>
<th class="px-6 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500">
<.sort_link meta={@meta} params={@params} field={:patient_name}>Patient Name</.sort_link>
</th>
<th class="px-6 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500">
<.sort_link meta={@meta} params={@params} field={:status}>Status</.sort_link>
</th>
<th class="px-6 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500">
Assigned To
</th>
<th class="px-6 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500">
<.sort_link meta={@meta} params={@params} field={:inserted_at}>Created</.sort_link>
</th>
<th class="px-6 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500">
<.sort_link meta={@meta} params={@params} field={:delivery_scheduled_at}>
Delivery Scheduled
</.sort_link>
</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-200 bg-white">
<%= for request <- @action_requests do %>
<tr>
<td class="whitespace-nowrap px-6 py-4 text-sm text-gray-900">
{request.patient_name}
</td>
<td class="whitespace-nowrap px-6 py-4 text-sm">
<.status_badge status={request.status} />
</td>
<td class="whitespace-nowrap px-6 py-4 text-sm text-gray-500">
{if request.assigned_user_id, do: "User ##{request.assigned_user_id}", else: "-"}
</td>
<td class="whitespace-nowrap px-6 py-4 text-sm text-gray-500">
{Calendar.strftime(request.inserted_at, "%Y-%m-%d %H:%M")}
</td>
<td class="whitespace-nowrap px-6 py-4 text-sm text-gray-500">
<%= if request.delivery_scheduled_at do %>
{Calendar.strftime(request.delivery_scheduled_at, "%Y-%m-%d %H:%M")}
<% else %>
-
<% end %>
</td>
</tr>
<% end %>
</tbody>
</table>
</div>
<div class="mt-4 flex items-center justify-between">
<div class="text-sm text-white">
Page {@meta.current_page} of {@meta.total_pages} (showing {length(@action_requests)} of {@meta.total_count} records)
</div>
<nav aria-label="Pagination" class="flex flex-wrap gap-1 items-center justify-center">
<.link
patch={~p"/?#{Map.merge(@params, %{"page" => 1})}"}
class={[
"rounded-md px-2 py-1 text-sm font-semibold",
(@meta.current_page == 1 && "bg-blue-600 text-white") ||
"bg-white text-gray-900 hover:bg-gray-50 shadow-sm ring-1 ring-inset ring-gray-300"
]}
aria-current={@meta.current_page == 1 && "page"}
>
1
</.link>
<%= if @meta.current_page > 4 do %>
<span class="px-2 py-1 text-gray-500">...</span>
<% end %>
<%= for page <- page_window(@meta.current_page, @meta.total_pages, 2) do %>
<%= if page != 1 and page != @meta.total_pages do %>
<.link
patch={~p"/?#{Map.merge(@params, %{"page" => page})}"}
class={[
"rounded-md px-2 py-1 text-sm font-semibold",
(page == @meta.current_page && "bg-blue-600 text-white") ||
"bg-white text-gray-900 hover:bg-blue-50 shadow-sm ring-1 ring-inset ring-gray-300"
]}
aria-current={page == @meta.current_page && "page"}
>
{page}
</.link>
<% end %>
<% end %>
<%= if @meta.current_page < @meta.total_pages - 3 do %>
<span class="px-2 py-1 text-gray-500">...</span>
<% end %>
<%= if @meta.total_pages > 1 do %>
<.link
patch={~p"/?#{Map.merge(@params, %{"page" => @meta.total_pages})}"}
class={[
"rounded-md px-2 py-1 text-sm font-semibold",
(@meta.current_page == @meta.total_pages && "bg-blue-600 text-white") ||
"bg-white text-gray-900 hover:bg-gray-50 shadow-sm ring-1 ring-inset ring-gray-300"
]}
aria-current={@meta.current_page == @meta.total_pages && "page"}
>
{@meta.total_pages}
</.link>
<% end %>
<.link
patch={~p"/?#{Map.merge(@params, %{"page" => @meta.previous_page})}"}
class={[
"rounded-md px-2 py-1 text-sm font-semibold",
(!@meta.has_previous_page? && "bg-gray-200 text-gray-500 cursor-not-allowed") ||
"bg-white text-gray-900 hover:bg-gray-50 shadow-sm ring-1 ring-inset ring-gray-300"
]}
aria-disabled={!@meta.has_previous_page?}
>
Previous
</.link>
<.link
patch={~p"/?#{Map.merge(@params, %{"page" => @meta.next_page})}"}
class={[
"rounded-md px-2 py-1 text-sm font-semibold",
(!@meta.has_next_page? && "bg-gray-200 text-gray-500 cursor-not-allowed") ||
"bg-white text-gray-900 hover:bg-gray-50 shadow-sm ring-1 ring-inset ring-gray-300"
]}
aria-disabled={!@meta.has_next_page?}
>
Next
</.link>
</nav>
</div>
</div>
</div>
"""
end
defp page_window(current, total, radius) do
# Returns a list of page numbers centered around current page, excluding first/last
start_page = max(current - radius, 2)
end_page = min(current + radius, total - 1)
if end_page >= start_page do
Enum.to_list(start_page..end_page)
else
[]
end
end
defp status_badge(assigns) do
~H"""
<span class={[
"inline-flex rounded-full px-2 py-1 text-xs font-semibold leading-5",
if(@status == "resolved",
do: "bg-green-100 text-green-800",
else: "bg-yellow-100 text-yellow-800"
)
]}>
{@status}
</span>
"""
end
defp sort_link(assigns) do
current_order =
if assigns.meta.flop.order_by == [assigns.field],
do: List.first(assigns.meta.flop.order_directions),
else: nil
next_direction = if current_order == :asc, do: :desc, else: :asc
assigns = assign(assigns, :next_direction, next_direction)
assigns = assign(assigns, :current_order, current_order)
~H"""
<.link
patch={
~p"/?#{Map.merge(@params, %{"order_by" => @field, "order_directions" => @next_direction})}"
}
class="group inline-flex"
>
{render_slot(@inner_block)}
<%= if @current_order == :asc do %>
<span class="ml-2"></span>
<% end %>
<%= if @current_order == :desc do %>
<span class="ml-2"></span>
<% end %>
</.link>
"""
end
end

View File

@ -0,0 +1,18 @@
defmodule ActionRequestsDemoWeb.Router do
use ActionRequestsDemoWeb, :router
pipeline :browser do
plug :accepts, ["html"]
plug :fetch_session
plug :fetch_live_flash
plug :put_root_layout, html: {ActionRequestsDemoWeb.Layouts, :root}
plug :protect_from_forgery
plug :put_secure_browser_headers
end
scope "/", ActionRequestsDemoWeb do
pipe_through :browser
live "/", ActionRequestsLive
end
end

82
mix.exs Normal file
View File

@ -0,0 +1,82 @@
defmodule ActionRequestsDemo.MixProject do
use Mix.Project
def project do
[
app: :action_requests_demo,
version: "0.1.0",
elixir: "~> 1.15",
elixirc_paths: elixirc_paths(Mix.env()),
start_permanent: Mix.env() == :prod,
aliases: aliases(),
deps: deps(),
compilers: [:phoenix_live_view] ++ Mix.compilers(),
listeners: [Phoenix.CodeReloader]
]
end
# Configuration for the OTP application.
#
# Type `mix help compile.app` for more information.
def application do
[
mod: {ActionRequestsDemo.Application, []},
extra_applications: [:logger, :runtime_tools, :faker]
]
end
def cli do
[
preferred_envs: [precommit: :test]
]
end
# Specifies which paths to compile per environment.
defp elixirc_paths(:test), do: ["lib", "test/support"]
defp elixirc_paths(_), do: ["lib"]
# Specifies your project dependencies.
#
# Type `mix help deps` for examples and options.
defp deps do
[
{:phoenix, "~> 1.8.1"},
{:phoenix_ecto, "~> 4.5"},
{:ecto_sql, "~> 3.13"},
{:ecto_sqlite3, ">= 0.0.0"},
{:flop, "~> 0.26"},
{:phoenix_html, "~> 4.1"},
{:phoenix_live_reload, "~> 1.2", only: :dev},
{:phoenix_live_view, "~> 1.1.0"},
{:lazy_html, ">= 0.1.0", only: :test},
{:esbuild, "~> 0.10", runtime: Mix.env() == :dev},
{:tailwind, "~> 0.3", runtime: Mix.env() == :dev},
{:faker, "~> 0.18"},
{:jason, "~> 1.2"},
{:bandit, "~> 1.5"}
]
end
# Aliases are shortcuts or tasks specific to the current project.
# For example, to install project dependencies and perform other setup tasks, run:
#
# $ mix setup
#
# See the documentation for `Mix` for more info on aliases.
defp aliases do
[
setup: ["deps.get", "ecto.setup", "assets.setup", "assets.build"],
"ecto.setup": ["ecto.create", "ecto.migrate", "run priv/repo/seeds.exs"],
"ecto.reset": ["ecto.drop", "ecto.setup"],
test: ["ecto.create --quiet", "ecto.migrate --quiet", "test"],
"assets.setup": ["tailwind.install --if-missing", "esbuild.install --if-missing"],
"assets.build": ["compile", "tailwind action_requests_demo", "esbuild action_requests_demo"],
"assets.deploy": [
"tailwind action_requests_demo --minify",
"esbuild action_requests_demo --minify",
"phx.digest"
],
precommit: ["compile --warning-as-errors", "deps.unlock --unused", "format", "test"]
]
end
end

36
mix.lock Normal file
View File

@ -0,0 +1,36 @@
%{
"bandit": {:hex, :bandit, "1.8.0", "c2e93d7e3c5c794272fa4623124f827c6f24b643acc822be64c826f9447d92fb", [:mix], [{:hpax, "~> 1.0", [hex: :hpax, repo: "hexpm", optional: false]}, {:plug, "~> 1.18", [hex: :plug, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:thousand_island, "~> 1.0", [hex: :thousand_island, repo: "hexpm", optional: false]}, {:websock, "~> 0.5", [hex: :websock, repo: "hexpm", optional: false]}], "hexpm", "8458ff4eed20ff2a2ea69d4854883a077c33ea42b51f6811b044ceee0fa15422"},
"cc_precompiler": {:hex, :cc_precompiler, "0.1.11", "8c844d0b9fb98a3edea067f94f616b3f6b29b959b6b3bf25fee94ffe34364768", [:mix], [{:elixir_make, "~> 0.7", [hex: :elixir_make, repo: "hexpm", optional: false]}], "hexpm", "3427232caf0835f94680e5bcf082408a70b48ad68a5f5c0b02a3bea9f3a075b9"},
"db_connection": {:hex, :db_connection, "2.8.1", "9abdc1e68c34c6163f6fb96a96532272d13ad7ca45262156ae8b7ec6d9dc4bec", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "a61a3d489b239d76f326e03b98794fb8e45168396c925ef25feb405ed09da8fd"},
"decimal": {:hex, :decimal, "2.3.0", "3ad6255aa77b4a3c4f818171b12d237500e63525c2fd056699967a3e7ea20f62", [:mix], [], "hexpm", "a4d66355cb29cb47c3cf30e71329e58361cfcb37c34235ef3bf1d7bf3773aeac"},
"ecto": {:hex, :ecto, "3.13.5", "9d4a69700183f33bf97208294768e561f5c7f1ecf417e0fa1006e4a91713a834", [:mix], [{:decimal, "~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "df9efebf70cf94142739ba357499661ef5dbb559ef902b68ea1f3c1fabce36de"},
"ecto_sql": {:hex, :ecto_sql, "3.13.2", "a07d2461d84107b3d037097c822ffdd36ed69d1cf7c0f70e12a3d1decf04e2e1", [:mix], [{:db_connection, "~> 2.4.1 or ~> 2.5", [hex: :db_connection, repo: "hexpm", optional: false]}, {:ecto, "~> 3.13.0", [hex: :ecto, repo: "hexpm", optional: false]}, {:myxql, "~> 0.7", [hex: :myxql, repo: "hexpm", optional: true]}, {:postgrex, "~> 0.19 or ~> 1.0", [hex: :postgrex, repo: "hexpm", optional: true]}, {:tds, "~> 2.1.1 or ~> 2.2", [hex: :tds, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4.0 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "539274ab0ecf1a0078a6a72ef3465629e4d6018a3028095dc90f60a19c371717"},
"ecto_sqlite3": {:hex, :ecto_sqlite3, "0.22.0", "edab2d0f701b7dd05dcf7e2d97769c106aff62b5cfddc000d1dd6f46b9cbd8c3", [:mix], [{:decimal, "~> 1.6 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:ecto, "~> 3.13.0", [hex: :ecto, repo: "hexpm", optional: false]}, {:ecto_sql, "~> 3.13.0", [hex: :ecto_sql, repo: "hexpm", optional: false]}, {:exqlite, "~> 0.22", [hex: :exqlite, repo: "hexpm", optional: false]}], "hexpm", "5af9e031bffcc5da0b7bca90c271a7b1e7c04a93fecf7f6cd35bc1b1921a64bd"},
"elixir_make": {:hex, :elixir_make, "0.9.0", "6484b3cd8c0cee58f09f05ecaf1a140a8c97670671a6a0e7ab4dc326c3109726", [:mix], [], "hexpm", "db23d4fd8b757462ad02f8aa73431a426fe6671c80b200d9710caf3d1dd0ffdb"},
"esbuild": {:hex, :esbuild, "0.10.0", "b0aa3388a1c23e727c5a3e7427c932d89ee791746b0081bbe56103e9ef3d291f", [:mix], [{:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "468489cda427b974a7cc9f03ace55368a83e1a7be12fba7e30969af78e5f8c70"},
"expo": {:hex, :expo, "1.1.1", "4202e1d2ca6e2b3b63e02f69cfe0a404f77702b041d02b58597c00992b601db5", [:mix], [], "hexpm", "5fb308b9cb359ae200b7e23d37c76978673aa1b06e2b3075d814ce12c5811640"},
"exqlite": {:hex, :exqlite, "0.33.1", "0465fdb997be174edeba6a27496fa27dfe8bc79ef1324a723daa8f0e8579da24", [:make, :mix], [{:cc_precompiler, "~> 0.1", [hex: :cc_precompiler, repo: "hexpm", optional: false]}, {:db_connection, "~> 2.1", [hex: :db_connection, repo: "hexpm", optional: false]}, {:elixir_make, "~> 0.8", [hex: :elixir_make, repo: "hexpm", optional: false]}, {:table, "~> 0.1.0", [hex: :table, repo: "hexpm", optional: true]}], "hexpm", "b3db0c9ae6e5ee7cf84dd0a1b6dc7566b80912eb7746d45370f5666ed66700f9"},
"faker": {:hex, :faker, "0.18.0", "943e479319a22ea4e8e39e8e076b81c02827d9302f3d32726c5bf82f430e6e14", [:mix], [], "hexpm", "bfbdd83958d78e2788e99ec9317c4816e651ad05e24cfd1196ce5db5b3e81797"},
"file_system": {:hex, :file_system, "1.1.1", "31864f4685b0148f25bd3fbef2b1228457c0c89024ad67f7a81a3ffbc0bbad3a", [:mix], [], "hexpm", "7a15ff97dfe526aeefb090a7a9d3d03aa907e100e262a0f8f7746b78f8f87a5d"},
"fine": {:hex, :fine, "0.1.4", "b19a89c1476c7c57afb5f9314aed5960b5bc95d5277de4cb5ee8e1d1616ce379", [:mix], [], "hexpm", "be3324cc454a42d80951cf6023b9954e9ff27c6daa255483b3e8d608670303f5"},
"flop": {:hex, :flop, "0.26.3", "9bc700b34f96a57e56aaa89b850926356311372556eacd5a1abe0fdd0ea40bf2", [:mix], [{:ecto, "~> 3.11", [hex: :ecto, repo: "hexpm", optional: false]}, {:nimble_options, "~> 1.0", [hex: :nimble_options, repo: "hexpm", optional: false]}], "hexpm", "cd77588229778ac55560c90dfbe15ab6486773f067d6e52db9fa703b8c9a9d2d"},
"hpax": {:hex, :hpax, "1.0.3", "ed67ef51ad4df91e75cc6a1494f851850c0bd98ebc0be6e81b026e765ee535aa", [:mix], [], "hexpm", "8eab6e1cfa8d5918c2ce4ba43588e894af35dbd8e91e6e55c817bca5847df34a"},
"jason": {:hex, :jason, "1.4.4", "b9226785a9aa77b6857ca22832cffa5d5011a667207eb2a0ad56adb5db443b8a", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "c5eb0cab91f094599f94d55bc63409236a8ec69a21a67814529e8d5f6cc90b3b"},
"lazy_html": {:hex, :lazy_html, "0.1.8", "677a8642e644eef8de98f3040e2520d42d0f0f8bd6c5cd49db36504e34dffe91", [:make, :mix], [{:cc_precompiler, "~> 0.1", [hex: :cc_precompiler, repo: "hexpm", optional: false]}, {:elixir_make, "~> 0.9.0", [hex: :elixir_make, repo: "hexpm", optional: false]}, {:fine, "~> 0.1.0", [hex: :fine, repo: "hexpm", optional: false]}], "hexpm", "0d8167d930b704feb94b41414ca7f5779dff9bca7fcf619fcef18de138f08736"},
"mime": {:hex, :mime, "2.0.7", "b8d739037be7cd402aee1ba0306edfdef982687ee7e9859bee6198c1e7e2f128", [:mix], [], "hexpm", "6171188e399ee16023ffc5b76ce445eb6d9672e2e241d2df6050f3c771e80ccd"},
"nimble_options": {:hex, :nimble_options, "1.1.1", "e3a492d54d85fc3fd7c5baf411d9d2852922f66e69476317787a7b2bb000a61b", [:mix], [], "hexpm", "821b2470ca9442c4b6984882fe9bb0389371b8ddec4d45a9504f00a66f650b44"},
"phoenix": {:hex, :phoenix, "1.8.1", "865473a60a979551a4879db79fbfb4503e41cd809e77c85af79716578b6a456d", [:mix], [{:bandit, "~> 1.0", [hex: :bandit, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:phoenix_pubsub, "~> 2.1", [hex: :phoenix_pubsub, repo: "hexpm", optional: false]}, {:phoenix_template, "~> 1.0", [hex: :phoenix_template, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: true]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.7", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:plug_crypto, "~> 1.2 or ~> 2.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:websock_adapter, "~> 0.5.3", [hex: :websock_adapter, repo: "hexpm", optional: false]}], "hexpm", "84d77d2b2e77c3c7e7527099bd01ef5c8560cd149c036d6b3a40745f11cd2fb2"},
"phoenix_ecto": {:hex, :phoenix_ecto, "4.7.0", "75c4b9dfb3efdc42aec2bd5f8bccd978aca0651dbcbc7a3f362ea5d9d43153c6", [:mix], [{:ecto, "~> 3.5", [hex: :ecto, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 2.14.2 or ~> 3.0 or ~> 4.1", [hex: :phoenix_html, repo: "hexpm", optional: true]}, {:plug, "~> 1.9", [hex: :plug, repo: "hexpm", optional: false]}, {:postgrex, "~> 0.16 or ~> 1.0", [hex: :postgrex, repo: "hexpm", optional: true]}], "hexpm", "1d75011e4254cb4ddf823e81823a9629559a1be93b4321a6a5f11a5306fbf4cc"},
"phoenix_html": {:hex, :phoenix_html, "4.3.0", "d3577a5df4b6954cd7890c84d955c470b5310bb49647f0a114a6eeecc850f7ad", [:mix], [], "hexpm", "3eaa290a78bab0f075f791a46a981bbe769d94bc776869f4f3063a14f30497ad"},
"phoenix_live_reload": {:hex, :phoenix_live_reload, "1.6.1", "05df733a09887a005ed0d69a7fc619d376aea2730bf64ce52ac51ce716cc1ef0", [:mix], [{:file_system, "~> 0.2.10 or ~> 1.0", [hex: :file_system, repo: "hexpm", optional: false]}, {:phoenix, "~> 1.4", [hex: :phoenix, repo: "hexpm", optional: false]}], "hexpm", "74273843d5a6e4fef0bbc17599f33e3ec63f08e69215623a0cd91eea4288e5a0"},
"phoenix_live_view": {:hex, :phoenix_live_view, "1.1.17", "1d782b5901cf13b137c6d8c56542ff6cb618359b2adca7e185b21df728fa0c6c", [:mix], [{:igniter, ">= 0.6.16 and < 1.0.0-0", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:lazy_html, "~> 0.1.0", [hex: :lazy_html, repo: "hexpm", optional: true]}, {:phoenix, "~> 1.6.15 or ~> 1.7.0 or ~> 1.8.0-rc", [hex: :phoenix, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 3.3 or ~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: false]}, {:phoenix_template, "~> 1.0", [hex: :phoenix_template, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: true]}, {:plug, "~> 1.15", [hex: :plug, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.2 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "fa82307dd9305657a8236d6b48e60ef2e8d9f742ee7ed832de4b8bcb7e0e5ed2"},
"phoenix_pubsub": {:hex, :phoenix_pubsub, "2.2.0", "ff3a5616e1bed6804de7773b92cbccfc0b0f473faf1f63d7daf1206c7aeaaa6f", [:mix], [], "hexpm", "adc313a5bf7136039f63cfd9668fde73bba0765e0614cba80c06ac9460ff3e96"},
"phoenix_template": {:hex, :phoenix_template, "1.0.4", "e2092c132f3b5e5b2d49c96695342eb36d0ed514c5b252a77048d5969330d639", [:mix], [{:phoenix_html, "~> 2.14.2 or ~> 3.0 or ~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: true]}], "hexpm", "2c0c81f0e5c6753faf5cca2f229c9709919aba34fab866d3bc05060c9c444206"},
"plug": {:hex, :plug, "1.18.1", "5067f26f7745b7e31bc3368bc1a2b818b9779faa959b49c934c17730efc911cf", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:plug_crypto, "~> 1.1.1 or ~> 1.2 or ~> 2.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.3 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "57a57db70df2b422b564437d2d33cf8d33cd16339c1edb190cd11b1a3a546cc2"},
"plug_crypto": {:hex, :plug_crypto, "2.1.1", "19bda8184399cb24afa10be734f84a16ea0a2bc65054e23a62bb10f06bc89491", [:mix], [], "hexpm", "6470bce6ffe41c8bd497612ffde1a7e4af67f36a15eea5f921af71cf3e11247c"},
"tailwind": {:hex, :tailwind, "0.4.1", "e7bcc222fe96a1e55f948e76d13dd84a1a7653fb051d2a167135db3b4b08d3e9", [:mix], [], "hexpm", "6249d4f9819052911120dbdbe9e532e6bd64ea23476056adb7f730aa25c220d1"},
"telemetry": {:hex, :telemetry, "1.3.0", "fedebbae410d715cf8e7062c96a1ef32ec22e764197f70cda73d82778d61e7a2", [:rebar3], [], "hexpm", "7015fc8919dbe63764f4b4b87a95b7c0996bd539e0d499be6ec9d7f3875b79e6"},
"thousand_island": {:hex, :thousand_island, "1.4.2", "735fa783005d1703359bbd2d3a5a3a398075ba4456e5afe3c5b7cf4666303d36", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "1c7637f16558fc1c35746d5ee0e83b18b8e59e18d28affd1f2fa1645f8bc7473"},
"websock": {:hex, :websock, "0.5.3", "2f69a6ebe810328555b6fe5c831a851f485e303a7c8ce6c5f675abeb20ebdadc", [:mix], [], "hexpm", "6105453d7fac22c712ad66fab1d45abdf049868f253cf719b625151460b8b453"},
"websock_adapter": {:hex, :websock_adapter, "0.5.9", "43dc3ba6d89ef5dec5b1d0a39698436a1e856d000d84bf31a3149862b01a287f", [:mix], [{:bandit, ">= 0.6.0", [hex: :bandit, repo: "hexpm", optional: true]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.6", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:websock, "~> 0.5", [hex: :websock, repo: "hexpm", optional: false]}], "hexpm", "5534d5c9adad3c18a0f58a9371220d75a803bf0b9a3d87e6fe072faaeed76a08"},
}

View File

@ -0,0 +1,4 @@
[
import_deps: [:ecto_sql],
inputs: ["*.exs"]
]

View File

@ -0,0 +1,20 @@
defmodule ActionRequestsDemo.Repo.Migrations.CreateActionRequests do
use Ecto.Migration
def change do
create table(:action_requests, primary_key: false) do
add :id, :binary_id, primary_key: true
add :patient_name, :string
add :status, :string
add :assigned_user_id, :integer
add :delivery_scheduled_at, :naive_datetime
timestamps(type: :utc_datetime)
end
create index(:action_requests, [:patient_name])
create index(:action_requests, [:status])
create index(:action_requests, [:assigned_user_id])
create index(:action_requests, [:inserted_at])
end
end

89
priv/repo/seeds.exs Normal file
View File

@ -0,0 +1,89 @@
# Script for populating the database. You can run it as:
#
# mix run priv/repo/seeds.exs
#
# Inside the script, you can read and write to any of your
# repositories directly:
#
# ActionRequestsDemo.Repo.insert!(%ActionRequestsDemo.SomeSchema{})
#
# We recommend using the bang functions (`insert!`, `update!`
# and so on) as they will fail if something goes wrong.
alias ActionRequestsDemo.Repo
alias ActionRequestsDemo.ActionRequests.ActionRequest
# Configuration
total_records = 1_000_000
# SQLite has a max parameter limit of 32766
# With 7 fields per record, max is ~4681 records per batch
# Using 4000 to be safe
batch_size = 4_000
num_batches = div(total_records, batch_size)
IO.puts("Seeding #{total_records} action requests in #{num_batches} batches of #{batch_size}...")
IO.puts("Started at: #{DateTime.utc_now()}")
statuses = ["resolved", "unresolved"]
# Helper function to generate a single record
generate_record = fn ->
patient_name = Faker.Person.name()
status = Enum.random(statuses)
assigned_user_id = if :rand.uniform() > 0.3, do: Enum.random(1..5), else: nil
days_ago = :rand.uniform(365)
inserted_at =
DateTime.utc_now()
|> DateTime.add(-days_ago * 24 * 3600, :second)
|> DateTime.truncate(:second)
delivery_scheduled_at =
if :rand.uniform() > 0.4 do
days_ahead = :rand.uniform(14)
NaiveDateTime.utc_now()
|> NaiveDateTime.add(days_ahead * 24 * 3600, :second)
|> NaiveDateTime.truncate(:second)
else
nil
end
%{
id: Ecto.UUID.generate(),
patient_name: patient_name,
status: status,
assigned_user_id: assigned_user_id,
delivery_scheduled_at: delivery_scheduled_at,
inserted_at: inserted_at,
updated_at: inserted_at
}
end
# Process batches sequentially (SQLite doesn't handle concurrent writes well)
start_time = System.monotonic_time(:millisecond)
Enum.each(1..num_batches, fn batch_num ->
# Generate batch of records
records = for _i <- 1..batch_size, do: generate_record.()
# Insert batch in a single transaction
{count, _} = Repo.insert_all(ActionRequest, records)
if rem(batch_num, 10) == 0 do
elapsed = div(System.monotonic_time(:millisecond) - start_time, 1000)
progress = batch_num * batch_size
rate = if elapsed > 0, do: div(progress, elapsed), else: 0
IO.puts("Progress: #{progress}/#{total_records} (#{div(progress * 100, total_records)}%) - #{rate} records/sec")
end
count
end)
end_time = System.monotonic_time(:millisecond)
elapsed_seconds = div(end_time - start_time, 1000)
records_per_second = div(total_records, max(elapsed_seconds, 1))
IO.puts("\n✓ Successfully seeded #{total_records} action requests!")
IO.puts("Total time: #{elapsed_seconds} seconds (#{records_per_second} records/sec)")
IO.puts("Finished at: #{DateTime.utc_now()}")

BIN
priv/static/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 152 B

5
priv/static/robots.txt Normal file
View File

@ -0,0 +1,5 @@
# See https://www.robotstxt.org/robotstxt.html for documentation on how to use the robots.txt file
#
# To ban all spiders from the entire site uncomment the next two lines:
# User-agent: *
# Disallow: /

View File

@ -0,0 +1,292 @@
defmodule ActionRequestsDemoWeb.ActionRequestsLiveTest do
use ActionRequestsDemoWeb.ConnCase, async: true
import Phoenix.LiveViewTest
alias ActionRequestsDemo.Factory
setup do
# Seed 30 requests for pagination and filtering
for i <- 1..30 do
status = if rem(i, 2) == 0, do: "resolved", else: "unresolved"
assignment =
cond do
rem(i, 3) == 0 -> nil
rem(i, 3) == 1 -> 1
true -> 2
end
Factory.insert_action_request(%{
patient_name: "Patient #{i}",
status: status,
assigned_user_id: assignment
})
end
:ok
end
describe "ActionRequestsLive" do
test "renders the main view and shows the title", %{conn: conn} do
{:ok, view, _html} = live(conn, "/")
assert has_element?(view, "h1", "Action Requests")
end
test "shows filter form and table headers", %{conn: conn} do
{:ok, view, _html} = live(conn, "/")
assert has_element?(view, "form")
assert has_element?(view, "th", "Patient Name")
assert has_element?(view, "th", "Status")
assert has_element?(view, "th", "Assigned To")
assert has_element?(view, "th", "Created")
assert has_element?(view, "th", "Delivery Scheduled")
end
test "pagination links are present and work", %{conn: conn} do
{:ok, view, _html} = live(conn, "/")
assert has_element?(view, "nav[aria-label=Pagination]")
assert has_element?(view, "a", "Next")
assert has_element?(view, "a", "Previous")
# Click next page using the actual link element
render_click(element(view, "a", "Next"))
assert has_element?(view, "nav[aria-label=Pagination]")
end
test "status dropdown filter works", %{conn: conn} do
{:ok, view, _html} = live(conn, "/")
render_change(view, "filter", %{status: "resolved"})
assert has_element?(view, "td", "resolved")
refute has_element?(view, "td", "unresolved")
end
test "assignment dropdown filter works", %{conn: conn} do
{:ok, view, _html} = live(conn, "/")
render_change(view, "filter", %{assignment: "unassigned"})
assert has_element?(view, "td", "-")
end
test "fuzzy patient name search works", %{conn: conn} do
{:ok, view, _html} = live(conn, "/")
render_change(view, "filter", %{patient_name: "Patient 2"})
assert has_element?(view, "td", "Patient 2")
# Should match Patient 20, Patient 21, etc.
assert has_element?(view, "td", "Patient 20")
end
test "pagination displays correct records per page", %{conn: conn} do
{:ok, view, _html} = live(conn, "/")
# Default ordering is inserted_at: :desc, so page 1 shows Patient 30..6
for i <- 30..16//-1 do
assert has_element?(view, "td", "Patient #{i}")
end
# Page 2 should show Patient 5..1
render_click(element(view, "a", "Next"))
for i <- 15..1//-1 do
assert has_element?(view, "td", "Patient #{i}")
end
end
test "sorting by patient name ascending works", %{conn: conn} do
{:ok, view, _html} = live(conn, "/")
# Click on Patient Name header to sort ascending
view |> element("a", "Patient Name") |> render_click()
# Should show Patient 1, Patient 10, Patient 11, etc. (alphabetical)
assert has_element?(view, "td", "Patient 1")
assert has_element?(view, "span", "")
end
test "sorting by patient name descending works", %{conn: conn} do
{:ok, view, _html} = live(conn, "/")
# Click twice to get descending order
view |> element("a", "Patient Name") |> render_click()
view |> element("a", "Patient Name") |> render_click()
assert has_element?(view, "span", "")
end
test "sorting by status works", %{conn: conn} do
{:ok, view, _html} = live(conn, "/")
view |> element("a", "Status") |> render_click()
# After sorting by status, should have a sort indicator
assert has_element?(view, "span", "")
end
test "sorting by created date works", %{conn: conn} do
{:ok, view, _html} = live(conn, "/")
# Default is descending, should show down arrow
assert has_element?(view, "span", "")
# Click to reverse
view |> element("a", "Created") |> render_click()
assert has_element?(view, "span", "")
end
test "sorting by delivery scheduled works", %{conn: conn} do
{:ok, view, _html} = live(conn, "/")
view |> element("a", "Delivery Scheduled") |> render_click()
assert has_element?(view, "span", "")
end
test "combining status filter with sorting works", %{conn: conn} do
{:ok, view, _html} = live(conn, "/?status=resolved")
view |> element("a", "Patient Name") |> render_click()
# Should only show resolved items, sorted by name
html = render(view)
assert html =~ "bg-green-100 text-green-800"
refute html =~ "bg-yellow-100 text-yellow-800"
assert has_element?(view, "span", "")
end
test "combining multiple filters works", %{conn: conn} do
{:ok, view, _html} = live(conn, "/?status=resolved&assignment=assigned")
# Should show only resolved AND assigned items
html = render(view)
assert html =~ "bg-green-100 text-green-800"
refute html =~ "bg-yellow-100 text-yellow-800"
# Should show User # but not unassigned items in the Assigned To column
assert html =~ "User #"
end
test "mine filter shows only current user's assignments", %{conn: conn} do
{:ok, view, _html} = live(conn, "/")
render_change(view, "filter", %{assignment: "mine"})
# Current user is set to 1 in mount
html = render(view)
assert html =~ "User #1"
# Should not show User #2
refute html =~ "User #2"
end
test "assigned filter shows all assigned items regardless of user", %{conn: conn} do
{:ok, view, _html} = live(conn, "/")
render_change(view, "filter", %{assignment: "assigned"})
# Should show both User #1 and User #2
html = render(view)
assert html =~ "User #1"
assert html =~ "User #2"
end
test "pagination metadata displays correctly", %{conn: conn} do
{:ok, view, _html} = live(conn, "/")
# Page 1 of 2, showing 15 of 30 records
assert has_element?(view, "div", "Page 1 of 2")
assert has_element?(view, "div", "showing 15 of 30 records")
end
test "pagination metadata updates after navigating", %{conn: conn} do
{:ok, view, _html} = live(conn, "/")
render_click(element(view, "a", "Next"))
# Page 2 of 2, showing 15 of 30 records
assert has_element?(view, "div", "Page 2 of 2")
assert has_element?(view, "div", "showing 15 of 30 records")
end
test "pagination metadata updates with filters", %{conn: conn} do
{:ok, view, _html} = live(conn, "/")
render_change(view, "filter", %{status: "resolved"})
# Should show fewer total records
assert has_element?(view, "div", "of 15 records")
end
test "status badge shows green for resolved", %{conn: conn} do
{:ok, view, _html} = live(conn, "/")
render_change(view, "filter", %{status: "resolved"})
html = render(view)
assert html =~ "bg-green-100 text-green-800"
assert html =~ "resolved"
end
test "status badge shows yellow for unresolved", %{conn: conn} do
{:ok, view, _html} = live(conn, "/")
render_change(view, "filter", %{status: "unresolved"})
html = render(view)
assert html =~ "bg-yellow-100 text-yellow-800"
assert html =~ "unresolved"
end
test "unassigned items show dash", %{conn: conn} do
{:ok, view, _html} = live(conn, "/")
render_change(view, "filter", %{assignment: "unassigned"})
# Should show "-" for unassigned items
assert has_element?(view, "td", "-")
end
test "clicking page number navigates to that page", %{conn: conn} do
{:ok, view, _html} = live(conn, "/")
# Click on page 2
view |> element("a", "2") |> render_click()
assert has_element?(view, "div", "Page 2 of 2")
end
test "clicking page 1 navigates to first page", %{conn: conn} do
{:ok, view, _html} = live(conn, "/")
# Go to page 2 first
render_click(element(view, "a", "Next"))
# Click on page 1
view |> element("a", "1") |> render_click()
assert has_element?(view, "div", "Page 1 of 2")
end
test "previous link is disabled on first page", %{conn: conn} do
{:ok, view, _html} = live(conn, "/")
html = render(view)
assert html =~ "bg-gray-200 text-gray-500 cursor-not-allowed"
assert html =~ "Previous"
end
test "next link is disabled on last page", %{conn: conn} do
{:ok, view, _html} = live(conn, "/")
# Navigate to last page
render_click(element(view, "a", "Next"))
html = render(view)
# Next button should be disabled
assert html =~ "bg-gray-200 text-gray-500 cursor-not-allowed"
assert html =~ "Next"
end
test "filters persist through pagination", %{conn: conn} do
{:ok, view, _html} = live(conn, "/")
render_change(view, "filter", %{status: "resolved"})
render_click(element(view, "a", "Next"))
# Should still show only resolved items
assert has_element?(view, "td", "resolved")
refute has_element?(view, "td", "unresolved")
end
test "sorting persists through pagination", %{conn: conn} do
{:ok, view, _html} = live(conn, "/")
view |> element("a", "Patient Name") |> render_click()
render_click(element(view, "a", "Next"))
# Should still show sort indicator
assert has_element?(view, "span", "")
end
test "clearing patient name filter shows all results", %{conn: conn} do
{:ok, view, _html} = live(conn, "/")
render_change(view, "filter", %{patient_name: "Patient 2"})
# Should show filtered results
assert has_element?(view, "td", "Patient 2")
# Clear filter
render_change(view, "filter", %{patient_name: ""})
# Should show all patients again
assert has_element?(view, "td", "Patient 1")
assert has_element?(view, "td", "Patient 30")
end
test "clearing status filter shows all statuses", %{conn: conn} do
{:ok, view, _html} = live(conn, "/")
render_change(view, "filter", %{status: "resolved"})
refute has_element?(view, "td", "unresolved")
# Clear filter
render_change(view, "filter", %{status: ""})
# Should show both statuses
assert has_element?(view, "td", "resolved")
assert has_element?(view, "td", "unresolved")
end
test "delivery scheduled date displays when present", %{conn: conn} do
{:ok, _view, html} = live(conn, "/")
# Most records should have delivery scheduled dates in the format YYYY-MM-DD HH:MM
assert html =~ ~r/\d{4}-\d{2}-\d{2} \d{2}:\d{2}/
end
end
end

38
test/support/conn_case.ex Normal file
View File

@ -0,0 +1,38 @@
defmodule ActionRequestsDemoWeb.ConnCase do
@moduledoc """
This module defines the test case to be used by
tests that require setting up a connection.
Such tests rely on `Phoenix.ConnTest` and also
import other functionality to make it easier
to build common data structures and query the data layer.
Finally, if the test case interacts with the database,
we enable the SQL sandbox, so changes done to the database
are reverted at the end of every test. If you are using
PostgreSQL, you can even run database tests asynchronously
by setting `use ActionRequestsDemoWeb.ConnCase, async: true`, although
this option is not recommended for other databases.
"""
use ExUnit.CaseTemplate
using do
quote do
# The default endpoint for testing
@endpoint ActionRequestsDemoWeb.Endpoint
use ActionRequestsDemoWeb, :verified_routes
# Import conveniences for testing with connections
import Plug.Conn
import Phoenix.ConnTest
import ActionRequestsDemoWeb.ConnCase
end
end
setup tags do
ActionRequestsDemo.DataCase.setup_sandbox(tags)
{:ok, conn: Phoenix.ConnTest.build_conn()}
end
end

58
test/support/data_case.ex Normal file
View File

@ -0,0 +1,58 @@
defmodule ActionRequestsDemo.DataCase do
@moduledoc """
This module defines the setup for tests requiring
access to the application's data layer.
You may define functions here to be used as helpers in
your tests.
Finally, if the test case interacts with the database,
we enable the SQL sandbox, so changes done to the database
are reverted at the end of every test. If you are using
PostgreSQL, you can even run database tests asynchronously
by setting `use ActionRequestsDemo.DataCase, async: true`, although
this option is not recommended for other databases.
"""
use ExUnit.CaseTemplate
using do
quote do
alias ActionRequestsDemo.Repo
import Ecto
import Ecto.Changeset
import Ecto.Query
import ActionRequestsDemo.DataCase
end
end
setup tags do
ActionRequestsDemo.DataCase.setup_sandbox(tags)
:ok
end
@doc """
Sets up the sandbox based on the test tags.
"""
def setup_sandbox(tags) do
pid = Ecto.Adapters.SQL.Sandbox.start_owner!(ActionRequestsDemo.Repo, shared: not tags[:async])
on_exit(fn -> Ecto.Adapters.SQL.Sandbox.stop_owner(pid) end)
end
@doc """
A helper that transforms changeset errors into a map of messages.
assert {:error, changeset} = Accounts.create_user(%{password: "short"})
assert "password is too short" in errors_on(changeset).password
assert %{password: ["password is too short"]} = errors_on(changeset)
"""
def errors_on(changeset) do
Ecto.Changeset.traverse_errors(changeset, fn {message, opts} ->
Regex.replace(~r"%{(\w+)}", message, fn _, key ->
opts |> Keyword.get(String.to_existing_atom(key), key) |> to_string()
end)
end)
end
end

17
test/support/factory.ex Normal file
View File

@ -0,0 +1,17 @@
defmodule ActionRequestsDemo.Factory do
alias ActionRequestsDemo.Repo
alias ActionRequestsDemo.ActionRequests.ActionRequest
def insert_action_request(attrs \\ %{}) do
default_attrs = %{
patient_name: "Patient #{System.unique_integer([:positive])}",
status: Enum.random(["resolved", "unresolved"]),
assigned_user_id: Enum.random([nil, 1, 2]),
delivery_scheduled_at: ~N[2025-11-16 10:00:00]
}
%ActionRequest{}
|> ActionRequest.changeset(Map.merge(default_attrs, attrs))
|> Repo.insert!()
end
end

2
test/test_helper.exs Normal file
View File

@ -0,0 +1,2 @@
ExUnit.start()
Ecto.Adapters.SQL.Sandbox.mode(ActionRequestsDemo.Repo, :manual)