4. Границы между контекстами
SourceТеперь, когда у нас есть зачатки функций каталога товаров, давайте приступим к разработке других основных функций приложения — добавлению товаров из каталога в корзину. Чтобы правильно отслеживать товары, добавленные в корзину пользователя, нам понадобится новое место для хранения этой информации, а также сведений о товаре на определённый момент времени, например его цене в момент добавления в корзину. Это необходимо, чтобы мы могли обнаруживать изменения цен на товары в будущем. Мы знаем, что нужно создать, но теперь нужно решить, где в приложении будет находиться функциональность корзины.
Если немного отстраниться и подумать об изоляции частей приложения, представление товаров в каталоге явно отличается от управления корзиной пользователя. Каталог товаров не должен зависеть от правил работы системы корзины покупок, и наоборот. Здесь явно нужен отдельный контекст для новых обязанностей, связанных с корзиной. Назовём его ShoppingCart.
Создадим контекст ShoppingCart для выполнения основных операций с корзиной. Прежде чем писать код, давайте представим, что у нас есть следующие требования к функциональности:
- Добавлять товары в корзину пользователя со страницы товара
- Сохранять сведения о цене товара на момент добавления в корзину
- Сохранять и обновлять количество товаров в корзине
- Вычислять и отображать сумму цен товаров в корзине
Из описания ясно, что нам нужен ресурс Cart для хранения корзины пользователя, а также ресурс CartItem для отслеживания товаров в корзине. План готов — приступим к работе.
Добавление аутентификации
Большая часть функциональности корзины привязана к конкретному пользователю. Поэтому, чтобы каждый пользователь мог управлять своей корзиной (и только своими корзинами), мы должны обеспечить аутентификацию пользователей. Для этого воспользуемся встроенным в Phoenix генератором mix phx.gen.auth, который создаст для нас основу решения:
mix phx.gen.auth Accounts User users
Вы увидите примерно такой вывод:
An authentication system can be created in two different ways: - Using Phoenix.LiveView (default) - Using Phoenix.Controller only Do you want to create a LiveView based authentication system? [Yn]
Введите n, а затем ключ Return. Вы увидите примерно такой вывод:
...
* creating lib/hello/accounts/scope.ex
...
* injecting config/config.exs
...
Please re-fetch your dependencies with the following command:
$ mix deps.get
Remember to update your repository by running migrations:
$ mix ecto.migrate
Once you are ready, visit "/users/register"
to create your account and then access "/dev/mailbox" to
see the account confirmation email.
Следуя инструкциям по повторной загрузке зависимостей и миграции базы данных, мы сможем запустить сервер с помощью mix phx.server и снова открыть главную страницу http://localhost:4000/. Вверху страницы появятся ссылки для регистрации и входа. На странице регистрации создайте нового пользователя. В режиме разработки письмо с подтверждением отправляется в почтовый ящик разработчика, доступный по адресу http://localhost:4000/dev/mailbox. Перейдя по ссылке подтверждения, вы успешно войдёте в систему.
Одно из преимуществ mix phx.gen.auth заключается в том, что он также создаёт файл области видимости по адресу lib/hello/accounts/scope.ex. Если вкратце, аутентификация определяет, кто пользователь, на основе его адреса электронной почты, но не сообщает, какими ресурсами он владеет или к каким имеет доступ. Для этого нужна авторизация. Области видимости позволяют связывать создаваемые ресурсы, например корзину Cart, которую мы создадим, с пользователями. Откроем файл:
defmodule Hello.Accounts.Scope do
...
alias Hello.Accounts.User
defstruct user: nil
@doc """
Creates a scope for the given user.
Returns nil if no user is given.
"""
def for_user(%User{} = user) do
%__MODULE__{user: user}
end
def for_user(nil), do: nil
end
Как видим, это всего лишь структура с полем user. Система аутентификации гарантирует, что значение current_scope assign установлено соответствующим образом и содержит текущего пользователя. Посмотрим, как это работает на практике.
Создание ресурсов с учётом области видимости
Сгенерируем новый контекст:
$ mix phx.gen.context ShoppingCart Cart carts
* creating lib/hello/shopping_cart/cart.ex
* creating priv/repo/migrations/20250205203128_create_carts.exs
* creating lib/hello/shopping_cart.ex
* injecting lib/hello/shopping_cart.ex
* creating test/hello/shopping_cart_test.exs
* injecting test/hello/shopping_cart_test.exs
* creating test/support/fixtures/shopping_cart_fixtures.ex
* injecting test/support/fixtures/shopping_cart_fixtures.ex
Remember to update your repository by running migrations:
$ mix ecto.migrate
Мы сгенерировали новый контекст ShoppingCart с новой схемой ShoppingCart.Cart. Откройте сгенерированные файлы схемы и миграции, и вы увидите, что благодаря области видимости в них автоматически появилось поле user_id. Кроме того, когда мы позже изучим код, то узнаем, что все запросы к таблице carts корректно ограничены областью видимости.
Теперь, когда корзина готова, сгенерируем её элементы. На этот раз укажем флаг --no-scope, поскольку мы будем связывать cart_items с carts, а carts уже ограничены областью видимости пользователя:
$ mix phx.gen.context ShoppingCart CartItem cart_items \
cart_id:references:carts product_id:references:products \
price_when_carted:decimal quantity:integer --no-scope
You are generating into an existing context.
...
Would you like to proceed? [Yn] y
* creating lib/hello/shopping_cart/cart_item.ex
* creating priv/repo/migrations/20250205213410_create_cart_items.exs
* injecting lib/hello/shopping_cart.ex
* injecting test/hello/shopping_cart_test.exs
* injecting test/support/fixtures/shopping_cart_fixtures.ex
Remember to update your repository by running migrations:
$ mix ecto.migrate
Мы сгенерировали новый ресурс CartItem внутри контекста ShoppingCart. Эта схема и таблица будут хранить ссылки на корзину и товар, а также цену на момент добавления товара в корзину и желаемое пользователем количество. Доработаем сгенерированный файл миграции в priv/repo/migrations/*_create_cart_items.ex:
create table(:cart_items) do
- add :price_when_carted, :decimal
+ add :price_when_carted, :decimal, precision: 15, scale: 6, null: false
add :quantity, :integer
- add :cart_id, references(:carts, on_delete: :nothing)
+ add :cart_id, references(:carts, on_delete: :delete_all)
- add :product_id, references(:products, on_delete: :nothing)
+ add :product_id, references(:products, on_delete: :delete_all)
timestamps(type: :utc_datetime)
end
- create index(:cart_items, [:cart_id])
create index(:cart_items, [:product_id])
+ create unique_index(:cart_items, [:cart_id, :product_id])
Мы снова использовали стратегию :delete_all для обеспечения целостности данных. Теперь, когда корзина или товар удаляются из приложения, нам не нужно полагаться на код приложения в контекстах ShoppingCart или Catalog, чтобы позаботиться об удалении записей. Это помогает сохранить слабую связанность кода приложения, а обеспечение целостности данных остаётся там, где ему и место, — в базе данных. Мы также добавили уникальное ограничение, чтобы один и тот же товар нельзя было добавить в корзину дважды. Как и в случае с таблицей product_categories, составной индекс позволяет удалить отдельный индекс для крайнего левого поля (cart_id). Таблицы базы данных готовы — теперь можно выполнить миграцию:
$ mix ecto.migrate 16:59:51.941 [info] == Running 20250205203342 Hello.Repo.Migrations.CreateCarts.change/0 forward 16:59:51.945 [info] create table carts 16:59:51.952 [info] == Migrated 20250205203342 in 0.0s 16:59:51.988 [info] == Running 20250205213410 Hello.Repo.Migrations.CreateCartItems.change/0 forward 16:59:51.988 [info] create table cart_items 16:59:52.000 [info] create index cart_items_product_id_index 16:59:52.001 [info] create index cart_items_cart_id_product_id_index 16:59:52.002 [info] == Migrated 20250205213410 in 0.0s
База данных готова: появились новые таблицы carts и cart_items. Теперь нужно отразить это в коде приложения. Возможно, вы задаётесь вопросом, как можно использовать внешние ключи базы данных между разными таблицами и как это связано с шаблоном контекстов, изолирующих и группирующих функциональность. Давайте обсудим возможные подходы и их компромиссы.
Данные между контекстами
До сих пор нам удавалось хорошо изолировать два основных контекста приложения друг от друга, но теперь появилась необходимая зависимость, которую нужно обработать.
Ресурс Catalog.Product отвечает за представление товара в каталоге, однако, чтобы товар мог попасть в корзину, он должен быть в каталоге. Следовательно, контекст ShoppingCart будет зависеть от данных контекста Catalog. Учитывая это, у нас есть два варианта. Первый — предоставить в контексте Catalog API для эффективного получения данных о товарах, которые будут использоваться в системе ShoppingCart, а затем вручную объединять их. Второй — использовать соединения таблиц базы данных для получения зависимых данных. Оба варианта допустимы, выбор зависит от компромиссов и размера приложения, но при наличии жёсткой зависимости по данным соединять таблицы в базе данных вполне нормально для широкого круга приложений. Именно этот подход мы здесь и выберем.
Теперь, когда мы знаем, где существуют зависимости между данными, добавим ассоциации в схемы, чтобы связать элементы корзины с товарами. Сначала немного изменим схему корзины в lib/hello/shopping_cart/cart.ex и свяжем корзину с её элементами:
schema "carts" do
- field :user_id, :id
+ belongs_to :user, Hello.Accounts.User
+ has_many :items, Hello.ShoppingCart.CartItem
timestamps(type: :utc_datetime)
end
Теперь корзина связана с размещёнными в ней элементами. Настроим ассоциации элементов корзины в lib/hello/shopping_cart/cart_item.ex:
schema "cart_items" do
field :price_when_carted, :decimal
field :quantity, :integer
- field :cart_id, :id
- field :product_id, :id
+ belongs_to :cart, Hello.ShoppingCart.Cart
+ belongs_to :product, Hello.Catalog.Product
timestamps(type: :utc_datetime)
end
@doc false
def changeset(cart_item, attrs) do
cart_item
- |> cast(attrs, [:price_when_carted, :quantity])
+ |> cast(attrs, [:quantity])
- |> validate_required([:price_when_carted, :quantity])
+ |> validate_required([:quantity])
+ |> validate_number(:quantity, greater_than_or_equal_to: 0, less_than: 100)
end
Сначала мы заменили поле cart_id стандартным belongs_to, указывающим на схему ShoppingCart.Cart. Затем заменили поле product_id и добавили первую зависимость между контекстами — belongs_to для схемы Catalog.Product. Здесь мы намеренно связали границы данных, поскольку это даёт нам именно то, что нужно: изолированный API контекста с минимально необходимыми знаниями для ссылки на товар в нашей системе. Во-вторых, мы удалили price_when_carted из списка разрешённых атрибутов, чтобы отбрасывать цену, заданную пользователем. Наконец, мы добавили новую проверку в changeset. С помощью validate_number/3 мы гарантируем, что указанное пользователем количество будет от 0 до 100.
Теперь, когда схемы готовы, можно начать интегрировать новые структуры данных и API контекста ShoppingCart в функции, доступные через веб-интерфейс.
Добавление функций корзины
Как мы уже говорили, генераторы контекстов — лишь отправная точка для приложения. Мы можем и должны писать хорошо названные функции, предназначенные для решения конкретных задач нашего контекста. Нам предстоит реализовать несколько новых функций. Во-первых, нужно гарантировать, что каждому пользователю приложения будет предоставлена корзина, если её ещё нет. Затем мы сможем разрешить пользователям добавлять товары в корзину, менять их количество и вычислять итоговую стоимость. Приступим!
Поскольку мы использовали mix phx.gen.auth, у нас уже есть полноценная система аутентификации. Чтобы получить текущего аутентифицированного пользователя, можно использовать assign current_scope. Добавим новый plug, который назначает корзину, если пользователь аутентифицирован:
pipeline :browser do
plug :accepts, ["html"]
plug :fetch_session
plug :fetch_live_flash
plug :put_root_layout, html: {HelloWeb.LayoutView, :root}
plug :protect_from_forgery
plug :put_secure_browser_headers
plug :fetch_current_scope_for_user
+ plug :fetch_current_cart
end
+ alias Hello.ShoppingCart
+
+ defp fetch_current_cart(%{assigns: %{current_scope: scope}} = conn, _opts) when not is_nil(scope) do
+ if cart = ShoppingCart.get_cart(scope) do
+ assign(conn, :cart, cart)
+ else
+ {:ok, new_cart} = ShoppingCart.create_cart(scope, %{})
+ assign(conn, :cart, new_cart)
+ end
+ end
+
+ defp fetch_current_cart(conn, _opts), do: conn
Мы добавили новый plug :fetch_current_cart: он либо находит корзину по UUID пользователя, либо создаёт её для текущего пользователя, а затем помещает результат в assigns соединения. Нам нужно реализовать ShoppingCart.get_cart/1, но сначала добавим маршруты.
Нужно реализовать контроллер корзины для таких операций, как просмотр корзины, изменение количества товаров и начало процесса оформления заказа, а также контроллер элементов корзины для добавления и удаления отдельных товаров. Система аутентификации уже создала несколько областей маршрутизатора с разными требованиями к аутентификации:
...
## Authentication routes
scope "/", HelloWeb do
pipe_through [:browser, :redirect_if_user_is_authenticated]
get "/user/register", UserRegistrationController, :new
post "/user/register", UserRegistrationController, :create
end
scope "/", HelloWeb do
pipe_through [:browser, :require_authenticated_user]
get "/user/settings", UserSettingsController, :edit
put "/user/settings", UserSettingsController, :update
get "/user/settings/confirm-email/:token", UserSettingsController, :confirm_email
end
...
Как видите, в маршруте регистрации используется plug :redirect_if_user_is_authenticated, который перенаправляет пользователя на главную страницу, если тот уже аутентифицирован. В маршрутах настроек пользователя используется plug :require_authenticated_user, который перенаправляет пользователя на страницу входа, если тот не аутентифицирован. Эти plug определены в модуле lib/hello_web/user_auth.ex.
Для маршрутов корзины мы хотим разрешить доступ только аутентифицированным пользователям. Добавьте в маршрутизатор в lib/hello_web/router.ex следующие маршруты:
scope "/", HelloWeb do
pipe_through :browser
get "/", PageController, :index
resources "/products", ProductController
end
+ scope "/", HelloWeb do
+ pipe_through [:browser, :require_authenticated_user]
+
+ resources "/cart_items", CartItemController, only: [:create, :delete]
+
+ get "/cart", CartController, :show
+ put "/cart", CartController, :update
+ end
Мы добавили объявление resources для CartItemController, которое настроит маршруты для создания и удаления отдельных элементов корзины. Затем мы добавили два новых маршрута, указывающих на CartController. Первый маршрут, GET-запрос, будет вызывать действие show для отображения содержимого корзины. Второй маршрут, PUT-запрос, будет обрабатывать отправку формы для изменения количества товаров в корзине.
Маршруты готовы — добавим возможность помещать товар в корзину со страницы товара. Создайте новый файл по адресу lib/hello_web/controllers/cart_item_controller.ex и введите следующий код:
defmodule HelloWeb.CartItemController do
use HelloWeb, :controller
alias Hello.ShoppingCart
def create(conn, %{"product_id" => product_id}) do
case ShoppingCart.add_item_to_cart(conn.assigns.current_scope, conn.assigns.cart, product_id) do
{:ok, _item} ->
conn
|> put_flash(:info, "Item added to your cart")
|> redirect(to: ~p"/cart")
{:error, _changeset} ->
conn
|> put_flash(:error, "There was an error adding the item to your cart")
|> redirect(to: ~p"/cart")
end
end
def delete(conn, %{"id" => product_id}) do
{:ok, _cart} = ShoppingCart.remove_item_from_cart(conn.assigns.current_scope, conn.assigns.cart, product_id)
redirect(conn, to: ~p"/cart")
end
end
Мы определили новый CartItemController с действиями create и delete, указанными в маршрутизаторе. Для create мы вызываем функцию ShoppingCart.add_item_to_cart/3, которую реализуем чуть позже. Если операция успешна, выводим сообщение об успехе и перенаправляем пользователя на страницу корзины; в противном случае выводим сообщение об ошибке и также перенаправляем его на страницу корзины. Для delete вызовем функцию remove_item_from_cart, которую реализуем в контексте ShoppingCart, а затем перенаправим пользователя обратно на страницу корзины. Мы ещё не реализовали эти две функции корзины, но обратите внимание, что их названия ясно говорят об их назначении: add_item_to_cart и remove_item_from_cart сразу дают понять, что именно мы здесь делаем. Кроме того, это позволяет нам определить интерфейс веб-уровня и API контекстов, не продумывая сразу все детали реализации.
Реализуем новый интерфейс API контекста ShoppingCart в lib/hello/shopping_cart.ex:
+ alias Hello.Catalog
- alias Hello.ShoppingCart.Cart
+ alias Hello.ShoppingCart.{Cart, CartItem}
alias Hello.Accounts.Scope
+ def get_cart(%Scope{} = scope) do
+ Repo.one(
+ from(c in Cart,
+ where: c.user_id == ^scope.user.id,
+ left_join: i in assoc(c, :items),
+ left_join: p in assoc(i, :product),
+ order_by: [asc: i.inserted_at],
+ preload: [items: {i, product: p}]
+ )
+ )
+ end
def create_cart(%Scope{} = scope, attrs) do
with {:ok, cart = %Cart{}} <-
%Cart{}
|> Cart.changeset(attrs, scope)
|> Repo.insert() do
broadcast(scope, {:created, cart})
- {:ok, cart}
+ {:ok, get_cart(scope)}
end
end
+
+ def add_item_to_cart(%Scope{} = scope, %Cart{} = cart, product_id) do
+ true = cart.user_id == scope.user.id
+ product = Catalog.get_product!(product_id)
+
+ %CartItem{quantity: 1, price_when_carted: product.price}
+ |> CartItem.changeset(%{})
+ |> Ecto.Changeset.put_assoc(:cart, cart)
+ |> Ecto.Changeset.put_assoc(:product, product)
+ |> Repo.insert(
+ on_conflict: [inc: [quantity: 1]],
+ conflict_target: [:cart_id, :product_id]
+ )
+ end
+
+ def remove_item_from_cart(%Scope{} = scope, %Cart{} = cart, product_id) do
+ true = cart.user_id == scope.user.id
+
+ {1, _} =
+ Repo.delete_all(
+ from(i in CartItem,
+ where: i.cart_id == ^cart.id,
+ where: i.product_id == ^product_id
+ )
+ )
+
+ {:ok, get_cart(scope)}
+ end
Сначала мы реализовали get_cart/1: функция получает корзину и объединяет элементы корзины с товарами, чтобы корзина содержала все предварительно загруженные данные. Затем мы изменили функцию create_cart, чтобы она использовала get_cart для повторной загрузки содержимого корзины.
Затем мы написали новую функцию add_item_to_cart/3, которая принимает область видимости, структуру корзины и идентификатор товара. Сначала мы получаем товар с помощью Catalog.get_product!/1, показывая, что контексты при необходимости могут естественным образом вызывать друг друга. Можно было также передать товар в качестве аргумента и получить аналогичный результат. Затем мы использовали операцию upsert в репозитории: она либо вставляет новый элемент корзины в базу данных, либо увеличивает его количество на единицу, если товар уже есть в корзине. Для этого используются параметры on_conflict и conflict_target, которые указывают репозиторию, как обрабатывать конфликт при вставке.
Наконец, мы реализовали remove_item_from_cart/3, где просто вызываем Repo.delete_all с запросом на удаление из корзины элемента, соответствующего идентификатору товара. Затем повторно загружаем содержимое корзины, вызывая get_cart/1.
Теперь, когда функции корзины готовы, можно добавить кнопку «В корзину» на страницу товара в каталоге. Откройте шаблон в lib/hello_web/controllers/product_html/show.html.heex и внесите следующие изменения:
...
<.button variant="primary" navigate={~p"/products/#{@product}/edit?return_to=show"}>
<.icon name="hero-pencil-square" /> Edit product
</.button>
+ <.button href={~p"/cart_items?product_id=#{@product.id}"} method="post">
+ Add to cart
+ </.button>
...
Функциональный компонент link из Phoenix.Component принимает атрибут :method, позволяющий при нажатии выполнить HTTP-запрос с указанным методом вместо GET-запроса по умолчанию. С этой настройкой ссылка «В корзину» будет отправлять POST-запрос, который будет обработан маршрутом, определённым в маршрутизаторе и вызывающим функцию CartItemController.create/2.
Проверим результат. Запустите сервер с помощью mix phx.server и откройте страницу товара. При нажатии на ссылку добавления в корзину появится страница с ошибкой. Если вы аутентифицированы, в консоли должны появиться следующие журналы:
[info] POST /cart_items
[debug] Processing with HelloWeb.CartItemController.create/2
Parameters: %{"_method" => "post", "product_id" => "1", ...}
Pipelines: [:browser, :require_authenticated_user]
[debug] QUERY OK source="user_tokens" db=2.4ms idle=1340.8ms
...
[debug] QUERY OK source="cart_items" db=2.5ms
INSERT INTO "cart_items" ...
[info] Sent 302 in 24ms
[info] GET /cart
[debug] Processing with HelloWeb.CartController.show/2
Parameters: %{}
Pipelines: [:browser, :require_authenticated_user]
[debug] QUERY OK source="user_tokens" db=1.6ms idle=430.2ms
...
[debug] QUERY OK source="carts" db=1.9ms idle=1798.5ms
...
[info] Sent 500 in 18ms
[error] ** (UndefinedFunctionError) function HelloWeb.CartController.init/1 is undefined (module HelloWeb.CartController is not available)
...
Работает! Вроде бы. Судя по журналам, мы отправляем POST-запрос по пути /cart_items. Далее видим, что функция ShoppingCart.add_item_to_cart успешно добавила строку в таблицу cart_items, после чего выполнила перенаправление на /cart. Перед ошибкой также виден запрос к таблице carts, то есть мы получаем корзину текущего пользователя. Пока всё хорошо. Контроллер CartItem и новые функции контекста ShoppingCart выполняют свою работу, но мы столкнулись со следующей нереализованной функцией: маршрутизатор пытается вызвать отсутствующий контроллер корзины. Создадим контроллер, представление и шаблон корзины для отображения корзин пользователей и управления ими.
Создайте новый файл по адресу lib/hello_web/controllers/cart_controller.ex и введите следующий код:
defmodule HelloWeb.CartController do
use HelloWeb, :controller
alias Hello.ShoppingCart
def show(conn, _params) do
render(conn, :show, changeset: ShoppingCart.change_cart(conn.assigns.current_scope, conn.assigns.cart))
end
end
Мы определили новый контроллер корзины для обработки маршрута get "/cart". Для отображения корзины рендерится шаблон "show.html", который мы создадим чуть позже. Мы знаем, что количество товаров в корзине должно изменяться, поэтому сразу понимаем, что нам понадобится changeset корзины. К счастью, генератор контекста добавил функцию ShoppingCart.change_cart/1, которой мы воспользуемся. Мы передаём ей структуру корзины, уже помещённую в assigns соединения благодаря plug fetch_current_cart, определённому в маршрутизаторе.
Теперь можно реализовать представление и шаблон. Создайте файл представления по адресу lib/hello_web/controllers/cart_html.ex со следующим содержимым:
defmodule HelloWeb.CartHTML do
use HelloWeb, :html
alias Hello.ShoppingCart
embed_templates "cart_html/*"
def currency_to_str(%Decimal{} = val), do: "$#{Decimal.round(val, 2)}"
end
Мы создали представление для рендеринга шаблона show.html и добавили псевдоним контекста ShoppingCart, чтобы он был доступен в шаблоне. Нам нужно отображать цены товаров, общую стоимость корзины и прочее, поэтому мы определили функцию currency_to_str/1. Она принимает структуру Decimal, округляет её для отображения и добавляет в начало знак доллара США.
Теперь создадим шаблон по адресу lib/hello_web/controllers/cart_html/show.html.heex:
<Layouts.app flash={@flash}>
<.header>
My Cart
<:subtitle :if={@cart.items == []}>Your cart is empty</:subtitle>
</.header>
<div :if={@cart.items !== []}>
<.form :let={f} for={@changeset} action={~p"/cart"}>
<.inputs_for :let={%{data: item} = item_form} field={f[:items]}>
<.input field={item_form[:quantity]} type="number" label={item.product.title} />
{currency_to_str(ShoppingCart.total_item_price(item))}
</.inputs_for>
<.button>Update cart</.button>
</.form>
<b>Total</b>: {currency_to_str(ShoppingCart.total_cart_price(@cart))}
</div>
<.button navigate={~p"/products"}>Back to products</.button>
</Layouts.app>
Сначала мы отображаем сообщение о пустой корзине, если предварительно загруженный cart.items пуст. Если в корзине есть товары, мы используем компонент form, предоставленный модулем HelloWeb.CoreComponents, чтобы взять changeset корзины, назначенный в действии CartController.show/2, и создать форму, связанную с действием update/2 контроллера корзины. Внутри формы используем компонент inputs_for для отображения полей ввода вложенных элементов корзины. Это позволит сопоставить введённые данные с элементами при отправке формы. Затем мы отображаем числовое поле для количества товара и подписываем его названием товара. В конце формы элемента преобразуем цену товара в строку. Функцию ShoppingCart.total_item_price/1 мы ещё не написали, но снова используем идею понятных, описательных публичных интерфейсов контекстов. После отображения полей для всех элементов корзины мы выводим кнопку отправки «Обновить корзину» и общую стоимость товаров. Для этого используется ещё одна новая функция ShoppingCart.total_cart_price/1, которую мы реализуем чуть позже. Наконец, мы добавили компонент back, позволяющий вернуться на страницу товаров.
Страница корзины почти готова, но сначала нужно реализовать функции вычисления стоимости. Откройте контекст корзины покупок в lib/hello/shopping_cart.ex и добавьте следующие функции:
def total_item_price(%CartItem{} = item) do
Decimal.mult(item.product.price, item.quantity)
end
def total_cart_price(%Cart{} = cart) do
Enum.reduce(cart.items, 0, fn item, acc ->
item
|> total_item_price()
|> Decimal.add(acc)
end)
end
Мы реализовали функцию total_item_price/1, которая принимает структуру %CartItem{}. Чтобы вычислить общую стоимость товара, достаточно умножить цену предварительно загруженного товара на количество. Мы использовали Decimal.mult/2, чтобы умножить структуры Decimal, представляющие денежные суммы, с необходимой точностью. Аналогично для вычисления общей стоимости корзины мы реализовали функцию total_cart_price/1, которая принимает корзину и суммирует цены предварительно загруженных товаров в ней. Здесь мы снова воспользовались функциями Decimal, чтобы сложить структуры Decimal.
Теперь мы умеем вычислять итоговую стоимость — проверим, как это работает! Откройте страницу http://localhost:4000/cart, и вы уже должны увидеть в корзине первый товар. Если вернуться к тому же товару и нажать «В корзину», можно увидеть, как работает upsert. Теперь количество товара должно быть равно двум. Отлично!
Страница корзины почти готова, но отправка формы приведёт к ещё одной ошибке.
[info] POST /cart ... [error] ** (UndefinedFunctionError) function HelloWeb.CartController.update/2 is undefined or private
Вернёмся к CartController в lib/hello_web/controllers/cart_controller.ex и реализуем действие update:
def update(conn, %{"cart" => cart_params}) do
case ShoppingCart.update_cart(conn.assigns.current_scope, conn.assigns.cart, cart_params) do
{:ok, _cart} ->
redirect(conn, to: ~p"/cart")
{:error, _changeset} ->
conn
|> put_flash(:error, "There was an error updating your cart")
|> redirect(to: ~p"/cart")
end
end
Сначала мы извлекли параметры корзины из отправленной формы. Затем вызвали существующую функцию ShoppingCart.update_cart/2, добавленную генератором контекста. Интерфейс этой функции нас устраивает, но её нужно немного изменить. Если обновление прошло успешно, перенаправляем пользователя обратно на страницу корзины. В противном случае отображаем сообщение об ошибке и возвращаем пользователя на страницу корзины, чтобы он исправил ошибки. Изначально функция ShoppingCart.update_cart/2 только преобразовывала параметры корзины в changeset и обновляла запись в репозитории. Теперь же она должна обрабатывать вложенные ассоциации элементов корзины и, что важнее всего, реализовывать бизнес-логику обработки обновления количества, например удаление товаров с нулевым количеством.
Вернитесь к контексту корзины покупок в lib/hello/shopping_cart.ex и замените функцию update_cart/2 следующей реализацией:
def update_cart(%Scope{} = scope, %Cart{} = cart, attrs) do
true = cart.user_id == scope.user.id
changeset =
cart
|> Cart.changeset(attrs, scope)
|> Ecto.Changeset.cast_assoc(:items, with: &CartItem.changeset/2)
Repo.transact(fn ->
with {:ok, cart} <- Repo.update(changeset),
{_count, _cart_items} = Repo.delete_all(from(i in CartItem, where: i.cart_id == ^cart.id and i.quantity == 0)) do
{:ok, cart}
end
end)
|> case do
{:ok, cart} ->
broadcast_cart(scope, {:updated, cart})
{:ok, cart}
{:error, reason} ->
{:error, reason}
end
end
Мы начали примерно так же, как и исходный код: берём структуру корзины и преобразуем пользовательские данные в changeset корзины. Но теперь используем Ecto.Changeset.cast_assoc/3, чтобы преобразовать вложенные данные элементов в changeset типа CartItem. Помните вызов <.inputs_for /> в шаблоне формы корзины? Скрытые данные об идентификаторе позволяют Ecto с помощью cast_assoc сопоставлять данные элементов с существующими ассоциациями элементов корзины.
Когда changeset готов, мы оборачиваем всё в Repo.transact/2, чтобы безопасно выполнить операции как единое целое. Внутри транзакции обновляем корзину с помощью Repo.update/1. Если обновление прошло успешно, выполняем очистку с помощью Repo.delete_all/2, удаляя элементы корзины с нулевым количеством. Выполнение обоих шагов в одной транзакции предотвращает частичные обновления и сохраняет точность данных корзины. Наконец, мы рассылаем обновлённую корзину, чтобы подключённые LiveView могли мгновенно отобразить изменения.
Вернёмся в браузер и проверим результат. Добавьте в корзину несколько товаров, измените их количество и наблюдайте, как меняются значения и пересчитываются цены. Если указать количество 0, товар также будет удалён. Можно выйти из системы и зарегистрировать нового пользователя, чтобы убедиться, что корзины привязаны к текущему пользователю. Здорово!
© 2014 Chris McCord
Licensed under the MIT License.
https://phoenix.hexdocs.pm/cross_context_boundaries.html