diff --git a/lib/live_sync.ex b/lib/live_sync.ex index d9496b5..16a17a7 100644 --- a/lib/live_sync.ex +++ b/lib/live_sync.ex @@ -112,6 +112,20 @@ defmodule LiveSync do assign(socket, list_of_objects: updates) end + + If you want to also receive a list of operations, you can add the following callback: + + def sync(:list_of_objects, updated, operations, socket) do + updates = + updated + |> Enum.filter(&is_nil(&1.executed_at)) + |> Enum.sort_by(& &1.name) + |> Repo.preload([...]) + end + + > #### Note {: .neutral} + > + > For operations in a single record, operations will be an atom of :insert, :update or :delete. """ use Supervisor @@ -159,10 +173,11 @@ defmodule LiveSync do on_mount({LiveSync, unquote(opts)}) def sync(key, value, socket), do: assign(socket, key, value) + def sync(key, value, _operations, socket), do: sync(key, value, socket) @before_compile {LiveSync, :add_sync_fallback} - defoverridable sync: 3 + defoverridable sync: 3, sync: 4 end end @@ -170,6 +185,7 @@ defmodule LiveSync do defmacro add_sync_fallback(_env) do quote do def sync(key, value, socket), do: assign(socket, key, value) + def sync(key, value, _operations, socket), do: sync(key, value, socket) end end diff --git a/lib/live_sync/socket.ex b/lib/live_sync/socket.ex index c1e4c3d..63811a0 100644 --- a/lib/live_sync/socket.ex +++ b/lib/live_sync/socket.ex @@ -29,7 +29,7 @@ defmodule LiveSync.Socket do if old_value == new_value do socket_acc else - socket_acc.view.sync(key, new_value, socket_acc) + sync(socket_acc, key, new_value, records) end end) @@ -51,7 +51,7 @@ defmodule LiveSync.Socket do socket_acc else value = if value == :delete, do: nil, else: value - socket_acc.view.sync(key, value, socket_acc) + sync(socket_acc, key, value, records) end end) @@ -62,6 +62,28 @@ defmodule LiveSync.Socket do {:cont, socket} end + defp sync(socket, key, value, operations) do + operations = + case value do + nil -> + :delete + + %{} = value -> + {schema, id} = LiveSync.lookup_info(value) + + Enum.find_value(operations, fn {op, record} -> + if record.__struct__ == schema and record.id == id do + op + end + end) + + _list -> + operations + end + + socket.view.sync(key, value, operations, socket) + end + # TODO: changesets defp traverse_assigns(struct, inserts, updates) when is_struct(struct) do traverse_associations(struct, inserts, updates) diff --git a/lib/live_sync/socket_test.exs b/lib/live_sync/socket_test.exs index 88d2d44..4ea1f14 100644 --- a/lib/live_sync/socket_test.exs +++ b/lib/live_sync/socket_test.exs @@ -245,4 +245,195 @@ defmodule LiveSync.SocketTest do {"p", [{"class", "data-child-name"}], ["child1"]} ] == Floki.find(parsed_html, ".data-child-name") end + + test "can sync with operations", %{conn: conn} do + example = Repo.insert!(%Example{organization_id: 1, name: "record A", enabled: false}) + Repo.insert!(%Ignored{organization_id: 1, name: "ignore", example_id: example.id}) + + {:ok, view, html} = conn |> get("/operations/#{example.id}") |> live() + + parsed_html = Floki.parse_document!(html) + assert parsed_html |> Floki.find("#data-name") |> Floki.text() == "record A" + assert parsed_html |> Floki.find(".data-ignored-name") |> Floki.text() == "ignore" + + assert [ + {"div", [], + [ + {"p", [{"class", "example-id"}], [_id_a]}, + {"p", [{"class", "example-name"}], ["record A"]}, + {"p", [{"class", "example-enabled"}], ["false"]} + ]} + ] = Floki.find(parsed_html, "#examples > div") + + # update record + example = Repo.update!(change(example, name: "record A.1", enabled: true)) + + assert_receive {:synced_with_record, :update} + assert_receive {:synced_with_list, operations} + assert [update: %{name: "record A.1", enabled: true}] = operations + + html = render(view) + parsed_html = Floki.parse_document!(html) + assert parsed_html |> Floki.find("#data-name") |> Floki.text() == "record A.1" + assert parsed_html |> Floki.find("#data-enabled") |> Floki.text() == "true" + assert parsed_html |> Floki.find(".data-ignored-name") |> Floki.text() == "ignore" + + assert [ + {"div", [], + [ + {"p", [{"class", "example-id"}], [_id_a]}, + {"p", [{"class", "example-name"}], ["record A.1"]}, + {"p", [{"class", "example-enabled"}], ["true"]} + ]} + ] = Floki.find(parsed_html, "#examples > div") + + # add records and update another + Repo.transaction(fn -> + Repo.insert!(%Example{organization_id: 1, name: "record B", enabled: false}) + Repo.insert!(%Example{organization_id: 1, name: "record C", enabled: false}) + Repo.update!(change(example, name: "record A.2")) + end) + + assert_receive {:synced_with_record, :update} + assert_receive {:synced_with_list, operations} + + assert [update: %{name: "record A.2"}, insert: %{name: "record B"}, insert: %{name: "record C"}] = + Enum.sort_by(operations, fn {_op, record} -> record.name end) + + html = render(view) + parsed_html = Floki.parse_document!(html) + assert parsed_html |> Floki.find("#data-name") |> Floki.text() == "record A.2" + + assert [ + {"div", [], + [ + {"p", [{"class", "example-id"}], [_id_a]}, + {"p", [{"class", "example-name"}], ["record A.2"]}, + {"p", [{"class", "example-enabled"}], ["true"]} + ]}, + {"div", [], + [ + {"p", [{"class", "example-id"}], [_id_b]}, + {"p", [{"class", "example-name"}], ["record B"]}, + {"p", [{"class", "example-enabled"}], ["false"]} + ]}, + {"div", [], + [ + {"p", [{"class", "example-id"}], [_id_c]}, + {"p", [{"class", "example-name"}], ["record C"]}, + {"p", [{"class", "example-enabled"}], ["false"]} + ]} + ] = Floki.find(parsed_html, "#examples > div") + + record_c = Repo.get_by!(Example, name: "record C") + # does all operations in one transaction + Repo.transaction(fn -> + Repo.insert!(%Example{organization_id: 1, name: "record D", enabled: false}) + Repo.delete!(record_c) + Repo.update!(change(example, name: "record A.3")) + end) + + assert_receive {:synced_with_record, :update} + + # insert and update operations are received together + assert_receive {:synced_with_list, insert_update_operations} + # delete operations are received separately, as they are sent to all subscribers + assert_receive {:synced_with_list, delete_operations} + + assert [update: %{name: "record A.3"}, insert: %{name: "record D"}] = + Enum.sort_by(insert_update_operations, fn {_op, record} -> record.name end) + + record_c_id = record_c.id + assert [delete: %{id: ^record_c_id}] = delete_operations + + html = render(view) + parsed_html = Floki.parse_document!(html) + assert parsed_html |> Floki.find("#data-name") |> Floki.text() == "record A.3" + + assert [ + {"div", [], + [ + {"p", [{"class", "example-id"}], [_id_a]}, + {"p", [{"class", "example-name"}], ["record A.3"]}, + {"p", [{"class", "example-enabled"}], ["true"]} + ]}, + {"div", [], + [ + {"p", [{"class", "example-id"}], [_id_b]}, + {"p", [{"class", "example-name"}], ["record B"]}, + {"p", [{"class", "example-enabled"}], ["false"]} + ]}, + {"div", [], + [ + {"p", [{"class", "example-id"}], [_id_d]}, + {"p", [{"class", "example-name"}], ["record D"]}, + {"p", [{"class", "example-enabled"}], ["false"]} + ]} + ] = Floki.find(parsed_html, "#examples > div") + end + + test "can mix sync/3 and sync/4", %{conn: conn} do + example = Repo.insert!(%Example{organization_id: 1, name: "Mixed A"}) + + {:ok, view, html} = conn |> get("/mixed/#{example.id}") |> live() + + parsed_html = Floki.parse_document!(html) + assert parsed_html |> Floki.find("#data-name") |> Floki.text() == "Mixed A" + + assert [ + {"div", [], + [ + {"p", [{"class", "example-id"}], [_id_a]}, + {"p", [{"class", "example-name"}], ["Mixed A"]} + ]} + ] = Floki.find(parsed_html, "#examples > div") + + example2 = Repo.insert!(%Example{organization_id: 1, name: "Mixed B"}) + + refute_receive {:synced_with_record, _operation} + assert_receive :synced_with_list + + html = render(view) + parsed_html = Floki.parse_document!(html) + assert parsed_html |> Floki.find("#data-name") |> Floki.text() == "Mixed A" + + assert [ + {"div", [], + [ + {"p", [{"class", "example-id"}], [_id_a]}, + {"p", [{"class", "example-name"}], ["Mixed A"]} + ]}, + {"div", [], + [ + {"p", [{"class", "example-id"}], [_id_b]}, + {"p", [{"class", "example-name"}], ["Mixed B"]} + ]} + ] = Floki.find(parsed_html, "#examples > div") + + Repo.transaction(fn -> + Repo.insert!(%Example{organization_id: 1, name: "Mixed C"}) + Repo.update!(change(example, name: "Mixed A.1")) + Repo.delete!(example2) + end) + + assert_receive {:synced_with_record, :update} + assert_receive :synced_with_list + + html = render(view) + parsed_html = Floki.parse_document!(html) + assert parsed_html |> Floki.find("#data-name") |> Floki.text() == "Mixed A.1" + + assert [ + {"div", [], + [ + {"p", [{"class", "example-id"}], [_id_a]}, + {"p", [{"class", "example-name"}], ["Mixed A.1"]} + ]}, + {"div", [], + [ + {"p", [{"class", "example-id"}], [_id_c]}, + {"p", [{"class", "example-name"}], ["Mixed C"]} + ]} + ] = Floki.find(parsed_html, "#examples > div") + end end diff --git a/mix.lock b/mix.lock index 1e48416..596cbdb 100644 --- a/mix.lock +++ b/mix.lock @@ -1,7 +1,7 @@ %{ "bunt": {:hex, :bunt, "1.0.0", "081c2c665f086849e6d57900292b3a161727ab40431219529f13c4ddcf3e7a44", [:mix], [], "hexpm", "dc5f86aa08a5f6fa6b8096f0735c4e76d54ae5c9fa2c143e5a1fc7c1cd9bb6b5"}, "castore": {:hex, :castore, "1.0.11", "4bbd584741601eb658007339ea730b082cc61f3554cf2e8f39bf693a11b49073", [:mix], [], "hexpm", "e03990b4db988df56262852f20de0f659871c35154691427a5047f4967a16a62"}, - "credo": {:hex, :credo, "1.7.11", "d3e805f7ddf6c9c854fd36f089649d7cf6ba74c42bc3795d587814e3c9847102", [:mix], [{:bunt, "~> 0.2.1 or ~> 1.0", [hex: :bunt, repo: "hexpm", optional: false]}, {:file_system, "~> 0.2 or ~> 1.0", [hex: :file_system, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "56826b4306843253a66e47ae45e98e7d284ee1f95d53d1612bb483f88a8cf219"}, + "credo": {:hex, :credo, "1.7.13", "126a0697df6b7b71cd18c81bc92335297839a806b6f62b61d417500d1070ff4e", [:mix], [{:bunt, "~> 0.2.1 or ~> 1.0", [hex: :bunt, repo: "hexpm", optional: false]}, {:file_system, "~> 0.2 or ~> 1.0", [hex: :file_system, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "47641e6d2bbff1e241e87695b29f617f1a8f912adea34296fb10ecc3d7e9e84f"}, "db_connection": {:hex, :db_connection, "2.7.0", "b99faa9291bb09892c7da373bb82cba59aefa9b36300f6145c5f201c7adf48ec", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "dcf08f31b2701f857dfc787fbad78223d61a32204f217f15e881dd93e4bdd3ff"}, "decimal": {:hex, :decimal, "2.3.0", "3ad6255aa77b4a3c4f818171b12d237500e63525c2fd056699967a3e7ea20f62", [:mix], [], "hexpm", "a4d66355cb29cb47c3cf30e71329e58361cfcb37c34235ef3bf1d7bf3773aeac"}, "earmark_parser": {:hex, :earmark_parser, "1.4.43", "34b2f401fe473080e39ff2b90feb8ddfeef7639f8ee0bbf71bb41911831d77c5", [:mix], [], "hexpm", "970a3cd19503f5e8e527a190662be2cee5d98eed1ff72ed9b3d1a3d466692de8"}, @@ -9,7 +9,7 @@ "ecto_sql": {:hex, :ecto_sql, "3.12.1", "c0d0d60e85d9ff4631f12bafa454bc392ce8b9ec83531a412c12a0d415a3a4d0", [:mix], [{:db_connection, "~> 2.4.1 or ~> 2.5", [hex: :db_connection, repo: "hexpm", optional: false]}, {:ecto, "~> 3.12", [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", "aff5b958a899762c5f09028c847569f7dfb9cc9d63bdb8133bff8a5546de6bf5"}, "ex_doc": {:hex, :ex_doc, "0.36.1", "4197d034f93e0b89ec79fac56e226107824adcce8d2dd0a26f5ed3a95efc36b1", [:mix], [{:earmark_parser, "~> 1.4.42", [hex: :earmark_parser, repo: "hexpm", optional: false]}, {:makeup_c, ">= 0.1.0", [hex: :makeup_c, repo: "hexpm", optional: true]}, {:makeup_elixir, "~> 0.14 or ~> 1.0", [hex: :makeup_elixir, repo: "hexpm", optional: false]}, {:makeup_erlang, "~> 0.1 or ~> 1.0", [hex: :makeup_erlang, repo: "hexpm", optional: false]}, {:makeup_html, ">= 0.1.0", [hex: :makeup_html, repo: "hexpm", optional: true]}], "hexpm", "d7d26a7cf965dacadcd48f9fa7b5953d7d0cfa3b44fa7a65514427da44eafd89"}, "excoveralls": {:hex, :excoveralls, "0.18.5", "e229d0a65982613332ec30f07940038fe451a2e5b29bce2a5022165f0c9b157e", [:mix], [{:castore, "~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "523fe8a15603f86d64852aab2abe8ddbd78e68579c8525ae765facc5eae01562"}, - "file_system": {:hex, :file_system, "1.1.0", "08d232062284546c6c34426997dd7ef6ec9f8bbd090eb91780283c9016840e8f", [:mix], [], "hexpm", "bfcf81244f416871f2a2e15c1b515287faa5db9c6bcf290222206d120b3d43f6"}, + "file_system": {:hex, :file_system, "1.1.1", "31864f4685b0148f25bd3fbef2b1228457c0c89024ad67f7a81a3ffbc0bbad3a", [:mix], [], "hexpm", "7a15ff97dfe526aeefb090a7a9d3d03aa907e100e262a0f8f7746b78f8f87a5d"}, "floki": {:hex, :floki, "0.37.0", "b83e0280bbc6372f2a403b2848013650b16640cd2470aea6701f0632223d719e", [:mix], [], "hexpm", "516a0c15a69f78c47dc8e0b9b3724b29608aa6619379f91b1ffa47109b5d0dd3"}, "jason": {:hex, :jason, "1.4.4", "b9226785a9aa77b6857ca22832cffa5d5011a667207eb2a0ad56adb5db443b8a", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "c5eb0cab91f094599f94d55bc63409236a8ec69a21a67814529e8d5f6cc90b3b"}, "makeup": {:hex, :makeup, "1.2.1", "e90ac1c65589ef354378def3ba19d401e739ee7ee06fb47f94c687016e3713d1", [:mix], [{:nimble_parsec, "~> 1.4", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "d36484867b0bae0fea568d10131197a4c2e47056a6fbe84922bf6ba71c8d17ce"}, diff --git a/test/support/live_page_mixed.ex b/test/support/live_page_mixed.ex new file mode 100644 index 0000000..7d426dc --- /dev/null +++ b/test/support/live_page_mixed.ex @@ -0,0 +1,56 @@ +defmodule LiveSync.LivePageMixed do + @moduledoc false + use Phoenix.LiveView + + use LiveSync, + subscription_key: :organization_id, + watch: [ + :data, + examples: [schema: LiveSync.Example] + ] + + alias LiveSync.Repo + + def mount(%{"id" => id}, session, socket) do + data = LiveSync.Example |> Repo.get!(id) |> Repo.preload([:parent, :children, :ignored]) + {:ok, assign(socket, organization_id: 1, examples: [data], data: data, test: session["test"])} + end + + def sync(:examples, updated, socket) do + updates = Enum.sort_by(updated, & &1.name) + send(self(), :synced_with_list) + assign(socket, examples: updates) + end + + def sync(:data, value, operation, socket) do + data = Repo.preload(value, [:parent, :children]) + send(self(), {:synced_with_record, operation}) + assign(socket, data: data) + end + + def render(assigns) do + ~H""" +
{@data.id}
+{@data.name}
+{example.id}
+{example.name}
+{@data.id}
+{@data.name}
+{@data.enabled}
+{@data.parent.name}
+{child.name}
+{ignored.name}
+{example.id}
+{example.name}
+{example.enabled}
+