commit de1b1bd48445bf4ead21e37fe759fd044c8d9cc0 Author: Jamey Greenwood Date: Sun Nov 16 10:24:06 2025 +0000 Initial commit: Phoenix LiveView demo for interactive data tables with filtering, sorting, pagination, URL state, and progressive enhancement 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, 25-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+ diff --git a/.formatter.exs b/.formatter.exs new file mode 100644 index 0000000..ef8840c --- /dev/null +++ b/.formatter.exs @@ -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"] +] diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..86fa32d --- /dev/null +++ b/.gitignore @@ -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-* + diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..6f52c21 --- /dev/null +++ b/AGENTS.md @@ -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 `` 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 `` + - **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 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 + + + + + +## 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 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 + + + +## 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 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 `
` or `` block you *must* annotate the parent tag with `phx-no-curly-interpolation`:
+
+      
+        let obj = {key: "val"}
+      
+
+  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**:
+
+      Text
+
+  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 `]`):
+
+       ...
+      => 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:
+
+      
+ {@my_assign} + <%= if @some_block_condition do %> + {@another_assign} + <% end %> +
+ + and **Never** do this – the program will terminate with a syntax error: + + <%!-- THIS IS INVALID NEVER EVER DO THIS --%> +
+ {if @invalid_block_construct do} + {end} +
+ + + +## 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 ` + + + + {@inner_content} + + diff --git a/lib/action_requests_demo_web/endpoint.ex b/lib/action_requests_demo_web/endpoint.ex new file mode 100644 index 0000000..eb05287 --- /dev/null +++ b/lib/action_requests_demo_web/endpoint.ex @@ -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 diff --git a/lib/action_requests_demo_web/live/action_requests_live.ex b/lib/action_requests_demo_web/live/action_requests_live.ex new file mode 100644 index 0000000..b33e00e --- /dev/null +++ b/lib/action_requests_demo_web/live/action_requests_live.ex @@ -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""" +
+
+

Action Requests

+ + <.form + for={%{}} + method="get" + action={~p"/"} + phx-submit="filter" + phx-change="filter" + class="mb-6" + > +
+
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ +
+
+ + +
+ + + + + + + + + + + + <%= for request <- @action_requests do %> + + + + + + + + <% end %> + +
+ <.sort_link meta={@meta} params={@params} field={:patient_name}>Patient Name + + <.sort_link meta={@meta} params={@params} field={:status}>Status + + Assigned To + + <.sort_link meta={@meta} params={@params} field={:inserted_at}>Created + + <.sort_link meta={@meta} params={@params} field={:delivery_scheduled_at}> + Delivery Scheduled + +
+ {request.patient_name} + + <.status_badge status={request.status} /> + + {if request.assigned_user_id, do: "User ##{request.assigned_user_id}", else: "-"} + + {Calendar.strftime(request.inserted_at, "%Y-%m-%d %H:%M")} + + <%= if request.delivery_scheduled_at do %> + {Calendar.strftime(request.delivery_scheduled_at, "%Y-%m-%d %H:%M")} + <% else %> + - + <% end %> +
+
+ +
+
+ Page {@meta.current_page} of {@meta.total_pages} (showing {length(@action_requests)} of {@meta.total_count} records) +
+ +
+
+
+ """ + 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""" + + {@status} + + """ + 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 %> + + <% end %> + <%= if @current_order == :desc do %> + + <% end %> + + """ + end +end diff --git a/lib/action_requests_demo_web/router.ex b/lib/action_requests_demo_web/router.ex new file mode 100644 index 0000000..f22e061 --- /dev/null +++ b/lib/action_requests_demo_web/router.ex @@ -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 diff --git a/mix.exs b/mix.exs new file mode 100644 index 0000000..e8d7ed7 --- /dev/null +++ b/mix.exs @@ -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] + ] + 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", only: [:dev, :test]}, + {: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 diff --git a/mix.lock b/mix.lock new file mode 100644 index 0000000..6bc4e12 --- /dev/null +++ b/mix.lock @@ -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"}, +} diff --git a/priv/repo/migrations/.formatter.exs b/priv/repo/migrations/.formatter.exs new file mode 100644 index 0000000..49f9151 --- /dev/null +++ b/priv/repo/migrations/.formatter.exs @@ -0,0 +1,4 @@ +[ + import_deps: [:ecto_sql], + inputs: ["*.exs"] +] diff --git a/priv/repo/migrations/20251115093326_create_action_requests.exs b/priv/repo/migrations/20251115093326_create_action_requests.exs new file mode 100644 index 0000000..5519a4f --- /dev/null +++ b/priv/repo/migrations/20251115093326_create_action_requests.exs @@ -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 diff --git a/priv/repo/seeds.exs b/priv/repo/seeds.exs new file mode 100644 index 0000000..0320020 --- /dev/null +++ b/priv/repo/seeds.exs @@ -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()}") diff --git a/priv/static/favicon.ico b/priv/static/favicon.ico new file mode 100644 index 0000000..7f372bf Binary files /dev/null and b/priv/static/favicon.ico differ diff --git a/priv/static/robots.txt b/priv/static/robots.txt new file mode 100644 index 0000000..26e06b5 --- /dev/null +++ b/priv/static/robots.txt @@ -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: / diff --git a/test/action_requests_demo_web/action_requests_live_test.exs b/test/action_requests_demo_web/action_requests_live_test.exs new file mode 100644 index 0000000..f555311 --- /dev/null +++ b/test/action_requests_demo_web/action_requests_live_test.exs @@ -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..6//-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 <- 5..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 25 of 30 records + assert has_element?(view, "div", "Page 1 of 2") + assert has_element?(view, "div", "showing 25 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 5 of 30 records + assert has_element?(view, "div", "Page 2 of 2") + assert has_element?(view, "div", "showing 5 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 diff --git a/test/support/conn_case.ex b/test/support/conn_case.ex new file mode 100644 index 0000000..c5c417f --- /dev/null +++ b/test/support/conn_case.ex @@ -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 diff --git a/test/support/data_case.ex b/test/support/data_case.ex new file mode 100644 index 0000000..86f6ff6 --- /dev/null +++ b/test/support/data_case.ex @@ -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 diff --git a/test/support/factory.ex b/test/support/factory.ex new file mode 100644 index 0000000..44b1e10 --- /dev/null +++ b/test/support/factory.ex @@ -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 diff --git a/test/test_helper.exs b/test/test_helper.exs new file mode 100644 index 0000000..c083384 --- /dev/null +++ b/test/test_helper.exs @@ -0,0 +1,2 @@ +ExUnit.start() +Ecto.Adapters.SQL.Sandbox.mode(ActionRequestsDemo.Repo, :manual)