diff --git a/e2e/docs/snippet-conventions.md b/e2e/docs/snippet-conventions.md new file mode 100644 index 00000000..38554cae --- /dev/null +++ b/e2e/docs/snippet-conventions.md @@ -0,0 +1,76 @@ +# Snippet conventions (e2e code tabs + Hex moduledoc) + +User-facing strings (`*_code`, `*_heex`, `events_*`, `api_*`) must be copy-paste ready outside the e2e app. + +## Component classes + +On the host, use the base class plus Corex BEM modifiers only: + +```heex +<.checkbox class="checkbox checkbox--accent" /> +<.carousel class="carousel carousel--accent carousel--rounded-xl" /> +``` + +Omit `id` on the component in snippets unless the example is API or client-event wiring (`getElementById`, `Corex.Component.set_*`). E2e previews keep `id` in `*_example` for tests only. + +Allowed on related primitives in slots when needed: `class="icon"` on `<.heroicon>`, `class="button button--sm"` on `<.action>`. + +## Forbidden in snippets + +Layout Tailwind on wrapper elements: + +- `class="flex flex-col gap-2"` on `` or `
` +- `class="layout__row"`, `w-full` on forms used only for demo layout +- Extra wrappers whose only job is page layout + +Put layout in `*_example` functions and LiveView previews only. + +## Data and routes + +- Inline `Corex.List.new([...])`, `Corex.Content.new([...])`, `Corex.Image.new("/images/beach.jpg", alt: "...")` +- Not `E2eWeb.Demos.*.gallery_images()` or `basic_items()` +- Routes in generic Hex snippets: `to="#"`, `action="/your/path"`, `src="/images/avatar.png"` +- E2e controller form previews and their doc snippets (routes under `/:locale`): `action={~p"/component/form"}` + +## Elixir event handlers + +```elixir +def handle_event("event_name", params, socket) do + IO.inspect(params, label: "event_name") + {:noreply, socket} +end +``` + +Use `E2eWeb.Demos.DocExamples.event_handler_snippet/2` for consistency. + +## JavaScript vs TypeScript + +- JS: `const el = document.getElementById(...)` and untyped listeners +- TS: `const el: HTMLElement | null`, `(event: Event)`, `CustomEvent` — never delegate TS to JS verbatim + +Listener tabs use `console.log(event.detail)`. LiveView `pushEvent` belongs in colocated hooks only. + +## Code tabs by page type + +| Page | Tabs | +|------|------| +| Anatomy / Style | Heex | +| Events server | Heex + Elixir | +| Events client | Heex + JS + TS | +| API client binding | Heex | +| API client JS | Heex + JS + TS | +| API server | Heex + Elixir | +| Form (LiveView) | Heex + Elixir (+ File for uploads) | +| Pattern (LiveView + data) | Heex + Elixir + Data | + +## Shared fragments + +Prefer [`doc_examples.ex`](../lib/e2e_web/demos/doc_examples.ex) `code_*` functions for repeated item lists and event handler bodies. + +## Checklist before merging + +- [ ] Snippet has no `E2eWeb.`, `~p"`, `MyApp.` +- [ ] Snippet has no layout `flex` / `gap-` on wrappers +- [ ] Host has `class=""` (+ modifiers if documenting style) +- [ ] JS and TS tabs differ when both exist +- [ ] Hex moduledoc matches e2e tab content for the same example diff --git a/e2e/lib/e2e_web/demos/combobox_demo.ex b/e2e/lib/e2e_web/demos/combobox_demo.ex index 767af15a..bcfbfc3b 100644 --- a/e2e/lib/e2e_web/demos/combobox_demo.ex +++ b/e2e/lib/e2e_web/demos/combobox_demo.ex @@ -150,7 +150,7 @@ defmodule E2eWeb.Demos.ComboboxDemo do ])} > <:item :let={item}> - + {item.label} <:trigger> @@ -201,7 +201,7 @@ defmodule E2eWeb.Demos.ComboboxDemo do ])} > <:item :let={item}> - + {item.label} <:trigger> diff --git a/e2e/lib/e2e_web/demos/select_demo.ex b/e2e/lib/e2e_web/demos/select_demo.ex index fb584f2c..934392ce 100644 --- a/e2e/lib/e2e_web/demos/select_demo.ex +++ b/e2e/lib/e2e_web/demos/select_demo.ex @@ -180,7 +180,7 @@ defmodule E2eWeb.Demos.SelectDemo do Country of residence <:item :let={item}> - + {item.label} <:trigger> @@ -225,7 +225,7 @@ defmodule E2eWeb.Demos.SelectDemo do ])} > <:item :let={item}> - + {item.label} <:trigger> diff --git a/lib/components/accordion.ex b/lib/components/accordion.ex index 67e81c55..4c2a5e74 100644 --- a/lib/components/accordion.ex +++ b/lib/components/accordion.ex @@ -725,6 +725,8 @@ defmodule Corex.Accordion do alias Phoenix.LiveView alias Phoenix.LiveView.JS + import Corex.Api.Doc + import Corex.Helpers, only: [ validate_content_items_required!: 2, @@ -1253,8 +1255,7 @@ defmodule Corex.Accordion do """ end - @doc type: :api - @doc ~S""" + api_doc(~S""" Open or close items from `phx-click`. Pass a list (`["lorem"]`), a comma string (`"lorem,donec"`), or `[]` to close all. ```heex @@ -1277,7 +1278,8 @@ defmodule Corex.Accordion do }) ); ``` - """ + """) + def set_value(accordion_id, value) when is_binary(accordion_id) do JS.dispatch("corex:accordion:set-value", to: "##{accordion_id}", @@ -1286,8 +1288,7 @@ defmodule Corex.Accordion do ) end - @doc type: :api - @doc ~S""" + api_doc(~S""" Open or close items from `handle_event`. Pushes `accordion_set_value` (no reply event). ```heex @@ -1304,7 +1305,8 @@ defmodule Corex.Accordion do {:noreply, Corex.Accordion.set_value(socket, "my-accordion", value)} end ``` - """ + """) + def set_value(socket, accordion_id, value) when is_struct(socket, Phoenix.LiveView.Socket) and is_binary(accordion_id) do RespondTo.push_set_value( @@ -1315,8 +1317,7 @@ defmodule Corex.Accordion do ) end - @doc type: :api - @doc ~S""" + api_doc(~S""" Read open items from `phx-click`. Dispatches `corex:accordion:value`. Optional `respond_to:` `:server` (default), `:client`, or `:both`. | | Reply | Payload | @@ -1349,7 +1350,8 @@ defmodule Corex.Accordion do ``` `values` is a list of open item `value` strings, or `nil`. - """ + """) + def value(accordion_id, opts) when is_binary(accordion_id) and is_list(opts) do JS.dispatch("corex:accordion:value", to: "##{accordion_id}", @@ -1364,8 +1366,7 @@ defmodule Corex.Accordion do def value(accordion_id) when is_binary(accordion_id), do: value(accordion_id, []) - @doc type: :api - @doc ~S""" + api_doc(~S""" Read open items from `handle_event` (`accordion_value`). Same replies as [`value/2`](#value/2). | Reply | Payload | @@ -1390,7 +1391,8 @@ defmodule Corex.Accordion do {:noreply, assign(socket, :open_items, values)} end ``` - """ + """) + def value(socket, accordion_id, opts) when is_struct(socket, Phoenix.LiveView.Socket) and is_binary(accordion_id) and is_list(opts) do @@ -1401,8 +1403,7 @@ defmodule Corex.Accordion do ) end - @doc type: :api - @doc ~S""" + api_doc(~S""" Read the focused item from `phx-click`. Dispatches `corex:accordion:focused`. Optional `respond_to:` `:server` (default), `:client`, or `:both`. | | Reply | Payload | @@ -1433,7 +1434,8 @@ defmodule Corex.Accordion do {:noreply, assign(socket, :focused_item, item)} end ``` - """ + """) + def focused(accordion_id, opts) when is_binary(accordion_id) and is_list(opts) do JS.dispatch("corex:accordion:focused", to: "##{accordion_id}", @@ -1448,8 +1450,7 @@ defmodule Corex.Accordion do def focused(accordion_id) when is_binary(accordion_id), do: focused(accordion_id, []) - @doc type: :api - @doc ~S""" + api_doc(~S""" Read the focused item from `handle_event` (`accordion_focused`). Same replies as [`focused/2`](#focused/2). | Reply | Payload | @@ -1474,7 +1475,8 @@ defmodule Corex.Accordion do {:noreply, assign(socket, :focused_item, item)} end ``` - """ + """) + def focused(socket, accordion_id, opts) when is_struct(socket, Phoenix.LiveView.Socket) and is_binary(accordion_id) and is_list(opts) do @@ -1485,8 +1487,7 @@ defmodule Corex.Accordion do ) end - @doc type: :api - @doc ~S""" + api_doc(~S""" Read expanded, focused, and disabled state for one item from `phx-click`. Dispatches `corex:accordion:item-state`. Optional `disabled:` and `respond_to:` `:server` (default), `:client`, or `:both`. | | Reply | Payload | @@ -1517,7 +1518,8 @@ defmodule Corex.Accordion do {:noreply, assign(socket, :item_state, {item, state})} end ``` - """ + """) + def item_state(accordion_id, item_value, opts) when is_binary(accordion_id) and is_binary(item_value) and is_list(opts) do disabled = Keyword.get(opts, :disabled, false) @@ -1544,8 +1546,7 @@ defmodule Corex.Accordion do item_state(accordion_id, item_value, []) end - @doc type: :api - @doc ~S""" + api_doc(~S""" Read item state from `handle_event` (`accordion_item_state`). Same replies as [`item_state/3`](#item_state/3). | Reply | Payload | @@ -1570,7 +1571,8 @@ defmodule Corex.Accordion do {:noreply, assign(socket, :item_state, {item, state})} end ``` - """ + """) + def item_state(socket, accordion_id, item_value, opts) when is_struct(socket, Phoenix.LiveView.Socket) and is_binary(accordion_id) and is_binary(item_value) and is_list(opts) do diff --git a/lib/components/combobox.ex b/lib/components/combobox.ex index 6fb4a5c7..c7ce49aa 100644 --- a/lib/components/combobox.ex +++ b/lib/components/combobox.ex @@ -75,7 +75,7 @@ defmodule Corex.Combobox do ])} > <:item :let={item}> - + {item.label} <:trigger> @@ -108,7 +108,7 @@ defmodule Corex.Combobox do ])} > <:item :let={item}> - + {item.label} <:trigger> diff --git a/lib/components/listbox.ex b/lib/components/listbox.ex index d1649508..be8551bf 100644 --- a/lib/components/listbox.ex +++ b/lib/components/listbox.ex @@ -78,7 +78,7 @@ defmodule Corex.Listbox do }> <:label>Country of residence <:item :let={%{item: entry}}> - + {entry.label} <:item_indicator><.heroicon name="hero-check" /> diff --git a/lib/components/select.ex b/lib/components/select.ex index 53481c99..6a5f3a9a 100644 --- a/lib/components/select.ex +++ b/lib/components/select.ex @@ -74,7 +74,7 @@ defmodule Corex.Select do Country of residence <:item :let={item}> - + {item.label} <:trigger> @@ -102,7 +102,7 @@ defmodule Corex.Select do ])} > <:item :let={item}> - + {item.label} <:trigger> diff --git a/lib/components/tree_view.ex b/lib/components/tree_view.ex index 1048ac0e..4fd3d558 100644 --- a/lib/components/tree_view.ex +++ b/lib/components/tree_view.ex @@ -399,12 +399,8 @@ defmodule Corex.TreeView do use Corex.Api.Imports, to: Corex.TreeView.Api - alias Corex.Api.RespondTo alias Corex.TreeView.Anatomy.{Branch, Item, Label, Props, Root} alias Corex.TreeView.Connect - alias Phoenix.LiveView - alias Phoenix.LiveView.JS - import Corex.Helpers, only: [validate_value!: 1, respond_to_fields: 1] @doc """ Renders a tree view. Pass `items` as `Corex.Tree.new/1`. Component id = tree root id; names capitalized from labels. @@ -947,13 +943,7 @@ defmodule Corex.TreeView do ``` """) - def set_expanded_value(tree_view_id, value) when is_binary(tree_view_id) do - JS.dispatch("corex:tree-view:set-expanded-value", - to: "##{tree_view_id}", - detail: %{value: validate_value!(value)}, - bubbles: false - ) - end + defdelegate set_expanded_value(tree_view_id, value), to: Api api_doc(~S""" Set the selection from `phx-click`. Dispatches `corex:tree-view:set-selected-value` with `detail.value`. @@ -964,13 +954,7 @@ defmodule Corex.TreeView do ``` """) - def set_selected_value(tree_view_id, value) when is_binary(tree_view_id) do - JS.dispatch("corex:tree-view:set-selected-value", - to: "##{tree_view_id}", - detail: %{value: validate_value!(value)}, - bubbles: false - ) - end + defdelegate set_selected_value(tree_view_id, value), to: Api api_doc(~S""" Set expanded branches from `handle_event` (`tree_view_set_expanded_value`). Payload uses `tree_view_id` matching the DOM `id`. @@ -982,15 +966,7 @@ defmodule Corex.TreeView do ``` """) - def set_expanded_value(socket, tree_view_id, value) - when is_struct(socket, Phoenix.LiveView.Socket) and is_binary(tree_view_id) do - RespondTo.push_set_value( - socket, - "tree_view_set_expanded_value", - tree_view_id, - validate_value!(value) - ) - end + defdelegate set_expanded_value(socket, tree_view_id, value), to: Api api_doc(~S""" Set the selection from `handle_event` (`tree_view_set_selected_value`). @@ -1002,15 +978,7 @@ defmodule Corex.TreeView do ``` """) - def set_selected_value(socket, tree_view_id, value) - when is_struct(socket, Phoenix.LiveView.Socket) and is_binary(tree_view_id) do - RespondTo.push_set_value( - socket, - "tree_view_set_selected_value", - tree_view_id, - validate_value!(value) - ) - end + defdelegate set_selected_value(socket, tree_view_id, value), to: Api api_doc(~S""" Read the selected paths from `phx-click`. Dispatches `corex:tree-view:value`. Optional `respond_to:` `:server`, `:client`, or `:both`. @@ -1032,16 +1000,18 @@ defmodule Corex.TreeView do ``` """) - def value(tree_view_id, opts) when is_binary(tree_view_id) and is_list(opts) do - JS.dispatch("corex:tree-view:value", - to: "##{tree_view_id}", - detail: respond_to_fields(opts), - bubbles: false - ) + def value(tree_view_id, opts) when is_binary(tree_view_id) and is_list(opts), + do: Api.value(tree_view_id, opts) + + api_doc_hidden() + + def value(socket, tree_view_id) + when is_struct(socket, Phoenix.LiveView.Socket) and is_binary(tree_view_id) do + Api.value(socket, tree_view_id, []) end api_doc_short("Same as [`value/2`](#value/2) with default `respond_to:`.") - def value(tree_view_id) when is_binary(tree_view_id), do: value(tree_view_id, []) + def value(tree_view_id) when is_binary(tree_view_id), do: Api.value(tree_view_id) api_doc(~S""" Read selection from `handle_event` (`tree_view_value`). Same replies as [`value/2`](#value/2). @@ -1057,15 +1027,10 @@ defmodule Corex.TreeView do ``` """) - def value(socket, tree_view_id, opts \\ []) + def value(socket, tree_view_id, opts) when is_struct(socket, Phoenix.LiveView.Socket) and is_binary(tree_view_id) and - is_list(opts) do - LiveView.push_event( - socket, - "tree_view_value", - Map.merge(%{id: tree_view_id}, respond_to_fields(opts)) - ) - end + is_list(opts), + do: Api.value(socket, tree_view_id, opts) api_doc(~S""" Read expanded paths from `phx-click`. Dispatches `corex:tree-view:expanded-value`. @@ -1081,18 +1046,20 @@ defmodule Corex.TreeView do ``` """) - def expanded_value(tree_view_id, opts) when is_binary(tree_view_id) and is_list(opts) do - JS.dispatch("corex:tree-view:expanded-value", - to: "##{tree_view_id}", - detail: respond_to_fields(opts), - bubbles: false - ) + def expanded_value(tree_view_id, opts) when is_binary(tree_view_id) and is_list(opts), + do: Api.expanded_value(tree_view_id, opts) + + api_doc_hidden() + + def expanded_value(socket, tree_view_id) + when is_struct(socket, Phoenix.LiveView.Socket) and is_binary(tree_view_id) do + Api.expanded_value(socket, tree_view_id, []) end api_doc_short("Same as [`expanded_value/2`](#expanded_value/2) with default `respond_to:`.") def expanded_value(tree_view_id) when is_binary(tree_view_id), - do: expanded_value(tree_view_id, []) + do: Api.expanded_value(tree_view_id) api_doc(~S""" Read expanded paths from `handle_event` (`tree_view_expanded_value`). Same replies as [`expanded_value/2`](#expanded_value/2). @@ -1108,15 +1075,10 @@ defmodule Corex.TreeView do ``` """) - def expanded_value(socket, tree_view_id, opts \\ []) + def expanded_value(socket, tree_view_id, opts) when is_struct(socket, Phoenix.LiveView.Socket) and is_binary(tree_view_id) and - is_list(opts) do - LiveView.push_event( - socket, - "tree_view_expanded_value", - Map.merge(%{id: tree_view_id}, respond_to_fields(opts)) - ) - end + is_list(opts), + do: Api.expanded_value(socket, tree_view_id, opts) defp items_to_tree(component_id, items) when is_binary(component_id) and is_list(items) do %{ diff --git a/test/corex/api_modules_contract_test.exs b/test/corex/api_modules_contract_test.exs new file mode 100644 index 00000000..c95c3429 --- /dev/null +++ b/test/corex/api_modules_contract_test.exs @@ -0,0 +1,54 @@ +defmodule Corex.ApiModulesContractTest do + use ExUnit.Case, async: true + + @components_root Path.expand("../../lib/components", __DIR__) + + @extracted_api_modules [ + {Corex.Checkbox, Corex.Checkbox.Api, "checkbox.ex", "checkbox"}, + {Corex.TreeView, Corex.TreeView.Api, "tree_view.ex", "tree-view"} + ] + + test "imperative API docs use api_doc macros" do + for path <- component_ex_files() do + content = File.read!(path) + + refute Regex.match?(~r/@doc type: :api\s*\n\s*@doc/, content), + "#{path} must use api_doc/1 instead of raw @doc type: :api" + end + end + + test "modules with imperative API import Corex.Api.Doc or use Corex.Api.Imports" do + for path <- component_ex_files() do + content = File.read!(path) + + if content =~ "api_doc(" or (content =~ "defdelegate" and content =~ ", to: Api") do + assert content =~ "import Corex.Api.Doc" or content =~ "use Corex.Api.Imports", + "#{path} must import Corex.Api.Doc or use Corex.Api.Imports" + end + end + end + + test "extracted Api modules delegate from component module" do + for {_mod, _api, file, slug} <- @extracted_api_modules do + path = Path.join(@components_root, file) + content = File.read!(path) + + assert content =~ "use Corex.Api.Imports" + assert content =~ "defdelegate" + + refute content =~ ~s|JS.dispatch("corex:#{slug}:set-|, + "#{path} must delegate set_* API to Api module" + end + end + + defp component_ex_files do + @components_root + |> Path.join("**/*.ex") + |> Path.wildcard() + |> Enum.reject(fn path -> + String.ends_with?(path, "/connect.ex") or + String.ends_with?(path, "/api.ex") or + String.contains?(path, "/anatomy/") + end) + end +end diff --git a/test/corex/connect_registry_contract_test.exs b/test/corex/connect_registry_contract_test.exs new file mode 100644 index 00000000..c804cba1 --- /dev/null +++ b/test/corex/connect_registry_contract_test.exs @@ -0,0 +1,64 @@ +defmodule Corex.ConnectRegistryContractTest do + use ExUnit.Case, async: true + + @components_root Path.expand("../../lib/components", __DIR__) + + test "hooked components declare loadable connect modules with props/1" do + for row <- hooked_wire_rows() do + mod = row["connect_module"] + + assert is_binary(mod) and mod != "", + "missing connect_module for #{row["id"]}" + + path = connect_path(mod) + assert File.exists?(path), "missing connect module file #{path} for #{row["id"]}" + + module = mod |> String.split(".") |> Module.concat() + assert Code.ensure_loaded?(module), "connect module #{mod} is not loadable" + + content = File.read!(path) + + assert content =~ "def props" or content =~ "def group", + "#{path} must define props/1 or group/1 for hook wiring" + end + end + + test "connect_module follows Corex..Connect convention" do + for row <- hooked_wire_rows() do + id = row["id"] + expected = "Corex.#{Macro.camelize(id)}.Connect" + + assert row["connect_module"] == expected, + "expected #{expected} for #{id}, got #{inspect(row["connect_module"])}" + end + end + + test "component_wire connect_module paths match registry ids" do + wire_ids = + wire_rows() + |> Enum.map(& &1["id"]) + |> Enum.sort() + + registry_ids = Corex.component_ids() |> Enum.map(&Atom.to_string/1) |> Enum.sort() + assert wire_ids == registry_ids + end + + defp hooked_wire_rows do + wire_rows() |> Enum.filter(& &1["phx_hook"]) + end + + defp wire_rows do + Application.app_dir(:corex, "priv/doc/component_wire.json") + |> File.read!() + |> Jason.decode!() + end + + defp connect_path(module_string) do + module_string + |> String.replace_prefix("Corex.", "") + |> String.split(".") + |> Enum.map(&Macro.underscore/1) + |> then(&Path.join([@components_root | &1])) + |> Kernel.<>(".ex") + end +end