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+
This commit is contained in:
commit
de1b1bd484
6
.formatter.exs
Normal file
6
.formatter.exs
Normal 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"]
|
||||||
|
]
|
||||||
47
.gitignore
vendored
Normal file
47
.gitignore
vendored
Normal 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
334
AGENTS.md
Normal 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 -->
|
||||||
215
README.md
Normal file
215
README.md
Normal 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**: 25 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: 25 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
101
assets/css/app.css
Normal 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
77
assets/js/app.js
Normal 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
32
assets/tsconfig.json
Normal 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
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
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
56
config/config.exs
Normal 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
82
config/dev.exs
Normal 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
|
||||||
15
config/prod.exs
Normal file
15
config/prod.exs
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
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"
|
||||||
|
|
||||||
|
# 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
95
config/runtime.exs
Normal 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
28
config/test.exs
Normal 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
|
||||||
9
lib/action_requests_demo.ex
Normal file
9
lib/action_requests_demo.ex
Normal 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
|
||||||
79
lib/action_requests_demo/action_requests.ex
Normal file
79
lib/action_requests_demo/action_requests.ex
Normal 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
|
||||||
29
lib/action_requests_demo/action_requests/action_request.ex
Normal file
29
lib/action_requests_demo/action_requests/action_request.ex
Normal 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: 25
|
||||||
|
}
|
||||||
|
|
||||||
|
@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
|
||||||
34
lib/action_requests_demo/application.ex
Normal file
34
lib/action_requests_demo/application.ex
Normal 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
|
||||||
5
lib/action_requests_demo/repo.ex
Normal file
5
lib/action_requests_demo/repo.ex
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
defmodule ActionRequestsDemo.Repo do
|
||||||
|
use Ecto.Repo,
|
||||||
|
otp_app: :action_requests_demo,
|
||||||
|
adapter: Ecto.Adapters.SQLite3
|
||||||
|
end
|
||||||
103
lib/action_requests_demo_web.ex
Normal file
103
lib/action_requests_demo_web.ex
Normal 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
|
||||||
76
lib/action_requests_demo_web/components/core_components.ex
Normal file
76
lib/action_requests_demo_web/components/core_components.ex
Normal 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">
|
||||||
|
×
|
||||||
|
</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
|
||||||
92
lib/action_requests_demo_web/components/layouts.ex
Normal file
92
lib/action_requests_demo_web/components/layouts.ex
Normal 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
|
||||||
@ -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>
|
||||||
50
lib/action_requests_demo_web/endpoint.ex
Normal file
50
lib/action_requests_demo_web/endpoint.ex
Normal 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
|
||||||
301
lib/action_requests_demo_web/live/action_requests_live.ex
Normal file
301
lib/action_requests_demo_web/live/action_requests_live.ex
Normal 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="300"
|
||||||
|
/>
|
||||||
|
</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
|
||||||
18
lib/action_requests_demo_web/router.ex
Normal file
18
lib/action_requests_demo_web/router.ex
Normal 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
82
mix.exs
Normal 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]
|
||||||
|
]
|
||||||
|
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
|
||||||
36
mix.lock
Normal file
36
mix.lock
Normal 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"},
|
||||||
|
}
|
||||||
4
priv/repo/migrations/.formatter.exs
Normal file
4
priv/repo/migrations/.formatter.exs
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
[
|
||||||
|
import_deps: [:ecto_sql],
|
||||||
|
inputs: ["*.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
|
||||||
89
priv/repo/seeds.exs
Normal file
89
priv/repo/seeds.exs
Normal 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
BIN
priv/static/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 152 B |
5
priv/static/robots.txt
Normal file
5
priv/static/robots.txt
Normal 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: /
|
||||||
292
test/action_requests_demo_web/action_requests_live_test.exs
Normal file
292
test/action_requests_demo_web/action_requests_live_test.exs
Normal 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..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
|
||||||
38
test/support/conn_case.ex
Normal file
38
test/support/conn_case.ex
Normal 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
58
test/support/data_case.ex
Normal 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
17
test/support/factory.ex
Normal 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
2
test/test_helper.exs
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
ExUnit.start()
|
||||||
|
Ecto.Adapters.SQL.Sandbox.mode(ActionRequestsDemo.Repo, :manual)
|
||||||
Loading…
Reference in New Issue
Block a user