feat(contacts): private flag with scope-aware visibility
Contacts can be marked private at submit time (single form, CSV import, ADIF import) and edit time. Private contacts are hidden from the public map, the public /u/callsign profile, and the browse table for anonymous and non-owning viewers. The original submitter and admins see them inline on the browse table with a "Yes" badge, and the detail page shows a lock icon. Non-authorized viewers get a 404 on the detail page.
This commit is contained in:
parent
69f4c81899
commit
6c652ef2d4
12 changed files with 417 additions and 26 deletions
|
|
@ -3,6 +3,7 @@ defmodule Microwaveprop.Radio do
|
||||||
|
|
||||||
import Ecto.Query
|
import Ecto.Query
|
||||||
|
|
||||||
|
alias Microwaveprop.Accounts.Scope
|
||||||
alias Microwaveprop.Accounts.User
|
alias Microwaveprop.Accounts.User
|
||||||
alias Microwaveprop.Cache
|
alias Microwaveprop.Cache
|
||||||
alias Microwaveprop.Radio.BandResolver
|
alias Microwaveprop.Radio.BandResolver
|
||||||
|
|
@ -57,7 +58,7 @@ defmodule Microwaveprop.Radio do
|
||||||
|
|
||||||
defp load_contacts_for_map do
|
defp load_contacts_for_map do
|
||||||
from(c in Contact,
|
from(c in Contact,
|
||||||
where: not is_nil(c.pos1) and not is_nil(c.pos2),
|
where: not is_nil(c.pos1) and not is_nil(c.pos2) and c.private == false,
|
||||||
select: %{
|
select: %{
|
||||||
id: c.id,
|
id: c.id,
|
||||||
pos1: c.pos1,
|
pos1: c.pos1,
|
||||||
|
|
@ -142,6 +143,7 @@ defmodule Microwaveprop.Radio do
|
||||||
|
|
||||||
Contact
|
Contact
|
||||||
|> where([c], fragment("upper(?)", c.station1) == ^upcased or fragment("upper(?)", c.station2) == ^upcased)
|
|> where([c], fragment("upper(?)", c.station1) == ^upcased or fragment("upper(?)", c.station2) == ^upcased)
|
||||||
|
|> where([c], c.private == false)
|
||||||
|> order_by([c], desc: c.qso_timestamp, desc: c.id)
|
|> order_by([c], desc: c.qso_timestamp, desc: c.id)
|
||||||
|> limit(100)
|
|> limit(100)
|
||||||
|> Repo.all()
|
|> Repo.all()
|
||||||
|
|
@ -152,12 +154,16 @@ defmodule Microwaveprop.Radio do
|
||||||
page = max(Keyword.get(opts, :page, 1), 1)
|
page = max(Keyword.get(opts, :page, 1), 1)
|
||||||
offset = (page - 1) * @per_page
|
offset = (page - 1) * @per_page
|
||||||
search = Keyword.get(opts, :search)
|
search = Keyword.get(opts, :search)
|
||||||
|
scope = Keyword.get(opts, :scope)
|
||||||
|
|
||||||
{sort_field, sort_dir} = sort_opts(opts)
|
{sort_field, sort_dir} = sort_opts(opts)
|
||||||
|
|
||||||
base_query = maybe_search(Contact, search)
|
base_query =
|
||||||
|
Contact
|
||||||
|
|> maybe_search(search)
|
||||||
|
|> filter_private(scope)
|
||||||
|
|
||||||
total_entries = total_entries_for(search, base_query)
|
total_entries = total_entries_for(search, scope, base_query)
|
||||||
total_pages = max(ceil(total_entries / @per_page), 1)
|
total_pages = max(ceil(total_entries / @per_page), 1)
|
||||||
|
|
||||||
entries =
|
entries =
|
||||||
|
|
@ -222,7 +228,9 @@ defmodule Microwaveprop.Radio do
|
||||||
t2 |> NaiveDateTime.truncate(:second) |> Map.take([:year, :month, :day, :hour])
|
t2 |> NaiveDateTime.truncate(:second) |> Map.take([:year, :month, :day, :hour])
|
||||||
end
|
end
|
||||||
|
|
||||||
defp total_entries_for(search, query) when search in [nil, ""] do
|
# Only cache the unscoped, unsearched total — scope-dependent counts
|
||||||
|
# differ per-viewer and must not share a cache.
|
||||||
|
defp total_entries_for(search, nil, query) when search in [nil, ""] do
|
||||||
if Application.get_env(:microwaveprop, :cache_contact_count, true) do
|
if Application.get_env(:microwaveprop, :cache_contact_count, true) do
|
||||||
Cache.fetch_or_store(@count_cache_key, @count_cache_ttl_ms, fn ->
|
Cache.fetch_or_store(@count_cache_key, @count_cache_ttl_ms, fn ->
|
||||||
Repo.aggregate(query, :count)
|
Repo.aggregate(query, :count)
|
||||||
|
|
@ -232,7 +240,18 @@ defmodule Microwaveprop.Radio do
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
defp total_entries_for(_search, query), do: Repo.aggregate(query, :count)
|
defp total_entries_for(_search, _scope, query), do: Repo.aggregate(query, :count)
|
||||||
|
|
||||||
|
# Visibility filter for scope-aware queries. Admins see everything;
|
||||||
|
# logged-in users see non-private + their own private; others see only
|
||||||
|
# non-private.
|
||||||
|
defp filter_private(query, %Scope{user: %User{is_admin: true}}), do: query
|
||||||
|
|
||||||
|
defp filter_private(query, %Scope{user: %User{id: user_id}}) do
|
||||||
|
where(query, [c], c.private == false or c.user_id == ^user_id)
|
||||||
|
end
|
||||||
|
|
||||||
|
defp filter_private(query, _), do: where(query, [c], c.private == false)
|
||||||
|
|
||||||
defp maybe_search(query, nil), do: query
|
defp maybe_search(query, nil), do: query
|
||||||
defp maybe_search(query, ""), do: query
|
defp maybe_search(query, ""), do: query
|
||||||
|
|
@ -417,6 +436,17 @@ defmodule Microwaveprop.Radio do
|
||||||
Repo.get!(Contact, id)
|
Repo.get!(Contact, id)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
True when the viewer (per `Scope`) is allowed to see `contact`.
|
||||||
|
Public contacts are always viewable; private contacts only by the
|
||||||
|
submitter or an admin.
|
||||||
|
"""
|
||||||
|
@spec can_view?(Contact.t(), Scope.t() | nil) :: boolean()
|
||||||
|
def can_view?(%Contact{private: false}, _scope), do: true
|
||||||
|
def can_view?(%Contact{}, %Scope{user: %User{is_admin: true}}), do: true
|
||||||
|
def can_view?(%Contact{user_id: user_id}, %Scope{user: %User{id: user_id}}) when not is_nil(user_id), do: true
|
||||||
|
def can_view?(%Contact{}, _scope), do: false
|
||||||
|
|
||||||
@spec toggle_flagged_invalid!(Contact.t()) :: Contact.t()
|
@spec toggle_flagged_invalid!(Contact.t()) :: Contact.t()
|
||||||
def toggle_flagged_invalid!(contact) do
|
def toggle_flagged_invalid!(contact) do
|
||||||
contact
|
contact
|
||||||
|
|
@ -775,6 +805,20 @@ defmodule Microwaveprop.Radio do
|
||||||
|> normalize_string_field("mode")
|
|> normalize_string_field("mode")
|
||||||
|> normalize_integer_field("height1_ft")
|
|> normalize_integer_field("height1_ft")
|
||||||
|> normalize_integer_field("height2_ft")
|
|> normalize_integer_field("height2_ft")
|
||||||
|
|> normalize_boolean_field("private")
|
||||||
|
end
|
||||||
|
|
||||||
|
# HTML checkboxes arrive as "true"/"false" strings; Ecto won't coerce
|
||||||
|
# those inside a validate_inclusion path, so normalize at the boundary.
|
||||||
|
defp normalize_boolean_field(map, key) do
|
||||||
|
case Map.get(map, key, :not_provided) do
|
||||||
|
:not_provided -> map
|
||||||
|
nil -> map
|
||||||
|
val when is_boolean(val) -> Map.put(map, key, val)
|
||||||
|
"true" -> Map.put(map, key, true)
|
||||||
|
"false" -> Map.put(map, key, false)
|
||||||
|
_ -> map
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
defp normalize_string_field(map, key) do
|
defp normalize_string_field(map, key) do
|
||||||
|
|
|
||||||
|
|
@ -59,6 +59,7 @@ defmodule Microwaveprop.Radio.Contact do
|
||||||
field :user_submitted, :boolean, default: false
|
field :user_submitted, :boolean, default: false
|
||||||
field :submitter_email, :string
|
field :submitter_email, :string
|
||||||
field :flagged_invalid, :boolean, default: false
|
field :flagged_invalid, :boolean, default: false
|
||||||
|
field :private, :boolean, default: false
|
||||||
|
|
||||||
# Antenna height above ground level (feet). Optional — when set, the
|
# Antenna height above ground level (feet). Optional — when set, the
|
||||||
# elevation profile and terrain analysis use the actual heights
|
# elevation profile and terrain analysis use the actual heights
|
||||||
|
|
@ -74,7 +75,7 @@ defmodule Microwaveprop.Radio.Contact do
|
||||||
@type t :: %__MODULE__{}
|
@type t :: %__MODULE__{}
|
||||||
|
|
||||||
@required_fields ~w(station1 station2 qso_timestamp band)a
|
@required_fields ~w(station1 station2 qso_timestamp band)a
|
||||||
@optional_fields ~w(grid1 grid2 pos1 pos2 distance_km user_id mode user_declared_prop_mode height1_ft height2_ft)a
|
@optional_fields ~w(grid1 grid2 pos1 pos2 distance_km user_id mode user_declared_prop_mode height1_ft height2_ft private)a
|
||||||
|
|
||||||
@spec changeset(t() | Ecto.Changeset.t(), map()) :: Ecto.Changeset.t()
|
@spec changeset(t() | Ecto.Changeset.t(), map()) :: Ecto.Changeset.t()
|
||||||
def changeset(contact, attrs) do
|
def changeset(contact, attrs) do
|
||||||
|
|
@ -83,7 +84,7 @@ defmodule Microwaveprop.Radio.Contact do
|
||||||
|> validate_required(@required_fields)
|
|> validate_required(@required_fields)
|
||||||
end
|
end
|
||||||
|
|
||||||
@submission_fields ~w(station1 station2 qso_timestamp mode band grid1 grid2 submitter_email user_declared_prop_mode height1_ft height2_ft)a
|
@submission_fields ~w(station1 station2 qso_timestamp mode band grid1 grid2 submitter_email user_declared_prop_mode height1_ft height2_ft private)a
|
||||||
@submission_required ~w(station1 station2 qso_timestamp band grid1 grid2)a
|
@submission_required ~w(station1 station2 qso_timestamp band grid1 grid2)a
|
||||||
@allowed_modes ~w(CW SSB FM FT8 FT4 Q65)
|
@allowed_modes ~w(CW SSB FM FT8 FT4 Q65)
|
||||||
# Keep in sync with Microwaveprop.Propagation.BandConfig.all_bands/0 and
|
# Keep in sync with Microwaveprop.Propagation.BandConfig.all_bands/0 and
|
||||||
|
|
|
||||||
|
|
@ -29,7 +29,7 @@ defmodule Microwaveprop.Radio.ContactEdit do
|
||||||
|
|
||||||
@type t :: %__MODULE__{}
|
@type t :: %__MODULE__{}
|
||||||
|
|
||||||
@editable_fields ~w(station1 station2 grid1 grid2 band mode qso_timestamp height1_ft height2_ft)
|
@editable_fields ~w(station1 station2 grid1 grid2 band mode qso_timestamp height1_ft height2_ft private)
|
||||||
|
|
||||||
@spec changeset(t() | Ecto.Changeset.t(), map()) :: Ecto.Changeset.t()
|
@spec changeset(t() | Ecto.Changeset.t(), map()) :: Ecto.Changeset.t()
|
||||||
def changeset(edit, attrs) do
|
def changeset(edit, attrs) do
|
||||||
|
|
@ -121,6 +121,16 @@ defmodule Microwaveprop.Radio.ContactEdit do
|
||||||
defp validate_field_value("height1_ft", cs), do: validate_height_value(cs, "height1_ft")
|
defp validate_field_value("height1_ft", cs), do: validate_height_value(cs, "height1_ft")
|
||||||
defp validate_field_value("height2_ft", cs), do: validate_height_value(cs, "height2_ft")
|
defp validate_field_value("height2_ft", cs), do: validate_height_value(cs, "height2_ft")
|
||||||
|
|
||||||
|
defp validate_field_value("private", changeset) do
|
||||||
|
val = get_field(changeset, :proposed_changes)["private"]
|
||||||
|
|
||||||
|
if is_boolean(val) do
|
||||||
|
changeset
|
||||||
|
else
|
||||||
|
add_error(changeset, :proposed_changes, "private must be a boolean")
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
defp validate_field_value(_, changeset), do: changeset
|
defp validate_field_value(_, changeset), do: changeset
|
||||||
|
|
||||||
defp validate_height_value(changeset, field) do
|
defp validate_height_value(changeset, field) do
|
||||||
|
|
|
||||||
|
|
@ -196,11 +196,12 @@ defmodule Microwaveprop.Radio.CsvImport do
|
||||||
the `row_num` values of the refinements so the worker can route each
|
the `row_num` values of the refinements so the worker can route each
|
||||||
row to the correct pipeline without re-classifying.
|
row to the correct pipeline without re-classifying.
|
||||||
"""
|
"""
|
||||||
@spec enqueue(preview_result() | %{valid: [map()], refinements: [map()]}, String.t()) ::
|
@spec enqueue(preview_result() | %{valid: [map()], refinements: [map()]}, String.t(), keyword()) ::
|
||||||
{:ok, Ecto.UUID.t()}
|
{:ok, Ecto.UUID.t()}
|
||||||
def enqueue(preview, submitter_email) do
|
def enqueue(preview, submitter_email, opts \\ []) do
|
||||||
valid = Map.get(preview, :valid, [])
|
private = Keyword.get(opts, :private, false)
|
||||||
refinements = Map.get(preview, :refinements, [])
|
valid = preview |> Map.get(:valid, []) |> Enum.map(&stamp_private(&1, private))
|
||||||
|
refinements = preview |> Map.get(:refinements, []) |> Enum.map(&stamp_private(&1, private))
|
||||||
|
|
||||||
serialized_rows = Enum.map(valid, &serialize_valid_row/1) ++ Enum.map(refinements, &serialize_refinement_row/1)
|
serialized_rows = Enum.map(valid, &serialize_valid_row/1) ++ Enum.map(refinements, &serialize_refinement_row/1)
|
||||||
refinement_ids = Enum.map(refinements, & &1.row_num)
|
refinement_ids = Enum.map(refinements, & &1.row_num)
|
||||||
|
|
@ -237,6 +238,9 @@ defmodule Microwaveprop.Radio.CsvImport do
|
||||||
end)
|
end)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
defp stamp_private(%{attrs: attrs} = row, true), do: %{row | attrs: Map.put(attrs, "private", true)}
|
||||||
|
defp stamp_private(row, _), do: row
|
||||||
|
|
||||||
defp serialize_valid_row(row) do
|
defp serialize_valid_row(row) do
|
||||||
%{
|
%{
|
||||||
"kind" => "insert",
|
"kind" => "insert",
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,10 @@ defmodule MicrowavepropWeb.ContactLive.Index do
|
||||||
use MicrowavepropWeb, :live_view
|
use MicrowavepropWeb, :live_view
|
||||||
use LiveTable.LiveResource, schema: Microwaveprop.Radio.Contact
|
use LiveTable.LiveResource, schema: Microwaveprop.Radio.Contact
|
||||||
|
|
||||||
|
import Ecto.Query
|
||||||
|
|
||||||
|
alias Microwaveprop.Accounts.Scope
|
||||||
|
alias Microwaveprop.Accounts.User
|
||||||
alias Microwaveprop.Radio.Contact
|
alias Microwaveprop.Radio.Contact
|
||||||
alias Microwaveprop.Repo
|
alias Microwaveprop.Repo
|
||||||
|
|
||||||
|
|
@ -26,7 +30,8 @@ defmodule MicrowavepropWeb.ContactLive.Index do
|
||||||
hrrr_status: %{label: "Enriched", renderer: &enrichment_cell/2},
|
hrrr_status: %{label: "Enriched", renderer: &enrichment_cell/2},
|
||||||
flagged_invalid: %{label: "Flag", renderer: &flag_cell/1},
|
flagged_invalid: %{label: "Flag", renderer: &flag_cell/1},
|
||||||
qso_timestamp: %{label: "QSO (UTC)", sortable: true, renderer: &format_ts/1},
|
qso_timestamp: %{label: "QSO (UTC)", sortable: true, renderer: &format_ts/1},
|
||||||
inserted_at: %{label: "Added (UTC)", sortable: true, renderer: &format_ts/1}
|
inserted_at: %{label: "Added (UTC)", sortable: true, renderer: &format_ts/1},
|
||||||
|
private: %{label: "Private", sortable: true, renderer: &private_cell/1}
|
||||||
]
|
]
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
@ -48,14 +53,55 @@ defmodule MicrowavepropWeb.ContactLive.Index do
|
||||||
|
|
||||||
@impl true
|
@impl true
|
||||||
def mount(_params, _session, socket) do
|
def mount(_params, _session, socket) do
|
||||||
total = Repo.aggregate(Contact, :count, :id)
|
scope = socket.assigns[:current_scope]
|
||||||
|
base_query = visible_query(scope)
|
||||||
|
total = Repo.aggregate(base_query, :count, :id)
|
||||||
|
|
||||||
{:ok,
|
{:ok,
|
||||||
socket
|
socket
|
||||||
|> assign(:page_title, "Contacts")
|
|> assign(:page_title, "Contacts")
|
||||||
|> assign(:total_contacts, total)}
|
|> assign(:total_contacts, total)
|
||||||
|
|> assign(:data_provider, {__MODULE__, :visible_query_provider, [scope_token(scope)]})}
|
||||||
end
|
end
|
||||||
|
|
||||||
|
@doc false
|
||||||
|
# Called by LiveTable to get the base queryable. Must be a
|
||||||
|
# named function because the helper persists MFA across renders.
|
||||||
|
def visible_query_provider(scope_token) do
|
||||||
|
scope_token
|
||||||
|
|> scope_from_token()
|
||||||
|
|> visible_query()
|
||||||
|
|> from(as: :resource)
|
||||||
|
end
|
||||||
|
|
||||||
|
defp visible_query(%Scope{user: %User{is_admin: true}}), do: Contact
|
||||||
|
|
||||||
|
defp visible_query(%Scope{user: %User{id: user_id}}) do
|
||||||
|
from(c in Contact, where: c.private == false or c.user_id == ^user_id)
|
||||||
|
end
|
||||||
|
|
||||||
|
defp visible_query(_), do: from(c in Contact, where: c.private == false)
|
||||||
|
|
||||||
|
# The data_provider MFA is serialized into the socket; a raw %Scope{}
|
||||||
|
# with associations isn't safe to store there. Collapse to the minimum
|
||||||
|
# needed to rebuild visibility and rehydrate on each invocation.
|
||||||
|
defp scope_token(%Scope{user: %User{id: id, is_admin: is_admin}}), do: {id, is_admin}
|
||||||
|
defp scope_token(_), do: nil
|
||||||
|
|
||||||
|
defp scope_from_token({id, true}), do: %Scope{user: %User{id: id, is_admin: true}}
|
||||||
|
defp scope_from_token({id, false}), do: %Scope{user: %User{id: id, is_admin: false}}
|
||||||
|
defp scope_from_token(nil), do: nil
|
||||||
|
|
||||||
|
defp private_cell(true) do
|
||||||
|
assigns = %{}
|
||||||
|
|
||||||
|
~H"""
|
||||||
|
<span class="badge badge-sm badge-warning">Yes</span>
|
||||||
|
"""
|
||||||
|
end
|
||||||
|
|
||||||
|
defp private_cell(_), do: ""
|
||||||
|
|
||||||
@enrichment_fields [
|
@enrichment_fields [
|
||||||
{:hrrr_status, "HRRR"},
|
{:hrrr_status, "HRRR"},
|
||||||
{:weather_status, "Weather"},
|
{:weather_status, "Weather"},
|
||||||
|
|
|
||||||
|
|
@ -32,7 +32,13 @@ defmodule MicrowavepropWeb.ContactLive.Show do
|
||||||
|
|
||||||
@impl true
|
@impl true
|
||||||
def mount(%{"id" => id}, session, socket) do
|
def mount(%{"id" => id}, session, socket) do
|
||||||
contact = id |> Radio.get_contact!() |> Radio.ensure_positions!()
|
contact = Radio.get_contact!(id)
|
||||||
|
|
||||||
|
if !Radio.can_view?(contact, socket.assigns[:current_scope]) do
|
||||||
|
raise Ecto.NoResultsError, queryable: Microwaveprop.Radio.Contact
|
||||||
|
end
|
||||||
|
|
||||||
|
contact = Radio.ensure_positions!(contact)
|
||||||
can_enqueue = internal_network?(session)
|
can_enqueue = internal_network?(session)
|
||||||
|
|
||||||
socket =
|
socket =
|
||||||
|
|
@ -266,7 +272,8 @@ defmodule MicrowavepropWeb.ContactLive.Show do
|
||||||
"qso_timestamp" =>
|
"qso_timestamp" =>
|
||||||
if(contact.qso_timestamp, do: Calendar.strftime(contact.qso_timestamp, "%Y-%m-%d %H:%M"), else: ""),
|
if(contact.qso_timestamp, do: Calendar.strftime(contact.qso_timestamp, "%Y-%m-%d %H:%M"), else: ""),
|
||||||
"height1_ft" => contact.height1_ft,
|
"height1_ft" => contact.height1_ft,
|
||||||
"height2_ft" => contact.height2_ft
|
"height2_ft" => contact.height2_ft,
|
||||||
|
"private" => contact.private
|
||||||
}
|
}
|
||||||
|
|
||||||
{:noreply, assign(socket, editing: true, edit_form: to_form(form_data, as: "edit"))}
|
{:noreply, assign(socket, editing: true, edit_form: to_form(form_data, as: "edit"))}
|
||||||
|
|
@ -861,6 +868,14 @@ defmodule MicrowavepropWeb.ContactLive.Show do
|
||||||
<%= if @contact.flagged_invalid do %>
|
<%= if @contact.flagged_invalid do %>
|
||||||
<span class="badge badge-error badge-sm ml-2">Flagged Invalid</span>
|
<span class="badge badge-error badge-sm ml-2">Flagged Invalid</span>
|
||||||
<% end %>
|
<% end %>
|
||||||
|
<%= if @contact.private do %>
|
||||||
|
<span
|
||||||
|
class="badge badge-warning badge-sm ml-2"
|
||||||
|
title="Only visible to you and administrators"
|
||||||
|
>
|
||||||
|
<.icon name="hero-lock-closed" class="w-3 h-3 mr-1" /> Private
|
||||||
|
</span>
|
||||||
|
<% end %>
|
||||||
</:subtitle>
|
</:subtitle>
|
||||||
<:actions>
|
<:actions>
|
||||||
<%= if current_user(assigns) do %>
|
<%= if current_user(assigns) do %>
|
||||||
|
|
@ -957,6 +972,13 @@ defmodule MicrowavepropWeb.ContactLive.Show do
|
||||||
pattern="\d{4}-\d{2}-\d{2}[T ]\d{2}:\d{2}(:\d{2})?Z?"
|
pattern="\d{4}-\d{2}-\d{2}[T ]\d{2}:\d{2}(:\d{2})?Z?"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="mt-2">
|
||||||
|
<.input
|
||||||
|
field={@edit_form[:private]}
|
||||||
|
type="checkbox"
|
||||||
|
label="Private — only visible to me and administrators"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
<div class="flex gap-2">
|
<div class="flex gap-2">
|
||||||
<.button type="submit" class="btn btn-primary btn-sm">
|
<.button type="submit" class="btn btn-primary btn-sm">
|
||||||
<.icon name="hero-paper-airplane" class="w-4 h-4" />
|
<.icon name="hero-paper-airplane" class="w-4 h-4" />
|
||||||
|
|
|
||||||
|
|
@ -119,6 +119,8 @@ defmodule MicrowavepropWeb.SubmitLive do
|
||||||
_ -> params |> Map.get("submitter_email", "") |> String.trim()
|
_ -> params |> Map.get("submitter_email", "") |> String.trim()
|
||||||
end
|
end
|
||||||
|
|
||||||
|
private = Map.get(params, "private") == "true"
|
||||||
|
|
||||||
if email == "" do
|
if email == "" do
|
||||||
{:noreply, put_flash(socket, :error, "Email is required for CSV upload")}
|
{:noreply, put_flash(socket, :error, "Email is required for CSV upload")}
|
||||||
else
|
else
|
||||||
|
|
@ -129,7 +131,7 @@ defmodule MicrowavepropWeb.SubmitLive do
|
||||||
|
|
||||||
case uploaded_contents do
|
case uploaded_contents do
|
||||||
[content] ->
|
[content] ->
|
||||||
handle_csv_preview(content, email, socket)
|
handle_csv_preview(content, email, private, socket)
|
||||||
|
|
||||||
[] ->
|
[] ->
|
||||||
{:noreply, put_flash(socket, :error, "Please select a CSV file")}
|
{:noreply, put_flash(socket, :error, "Please select a CSV file")}
|
||||||
|
|
@ -148,6 +150,8 @@ defmodule MicrowavepropWeb.SubmitLive do
|
||||||
_ -> params |> Map.get("submitter_email", "") |> String.trim()
|
_ -> params |> Map.get("submitter_email", "") |> String.trim()
|
||||||
end
|
end
|
||||||
|
|
||||||
|
private = Map.get(params, "private") == "true"
|
||||||
|
|
||||||
if email == "" do
|
if email == "" do
|
||||||
{:noreply, put_flash(socket, :error, "Email is required for ADIF upload")}
|
{:noreply, put_flash(socket, :error, "Email is required for ADIF upload")}
|
||||||
else
|
else
|
||||||
|
|
@ -158,7 +162,7 @@ defmodule MicrowavepropWeb.SubmitLive do
|
||||||
|
|
||||||
case uploaded_contents do
|
case uploaded_contents do
|
||||||
[content] ->
|
[content] ->
|
||||||
handle_adif_preview(content, email, socket)
|
handle_adif_preview(content, email, private, socket)
|
||||||
|
|
||||||
[] ->
|
[] ->
|
||||||
{:noreply, put_flash(socket, :error, "Please select an ADIF file")}
|
{:noreply, put_flash(socket, :error, "Please select an ADIF file")}
|
||||||
|
|
@ -174,11 +178,12 @@ defmodule MicrowavepropWeb.SubmitLive do
|
||||||
case socket.assigns.csv_preview do
|
case socket.assigns.csv_preview do
|
||||||
%{valid: valid_rows, refinements: refinements} = preview
|
%{valid: valid_rows, refinements: refinements} = preview
|
||||||
when valid_rows != [] or refinements != [] ->
|
when valid_rows != [] or refinements != [] ->
|
||||||
{:ok, run_id} = CsvImport.enqueue(preview, socket.assigns.csv_email)
|
private = socket.assigns[:csv_private] || false
|
||||||
|
{:ok, run_id} = CsvImport.enqueue(preview, socket.assigns.csv_email, private: private)
|
||||||
|
|
||||||
{:noreply,
|
{:noreply,
|
||||||
socket
|
socket
|
||||||
|> assign(csv_preview: nil)
|
|> assign(csv_preview: nil, csv_private: false)
|
||||||
|> push_navigate(to: ~p"/imports/#{run_id}")}
|
|> push_navigate(to: ~p"/imports/#{run_id}")}
|
||||||
|
|
||||||
_ ->
|
_ ->
|
||||||
|
|
@ -187,23 +192,23 @@ defmodule MicrowavepropWeb.SubmitLive do
|
||||||
end
|
end
|
||||||
|
|
||||||
def handle_event("cancel_csv", _params, socket) do
|
def handle_event("cancel_csv", _params, socket) do
|
||||||
{:noreply, assign(socket, csv_preview: nil)}
|
{:noreply, assign(socket, csv_preview: nil, csv_private: false)}
|
||||||
end
|
end
|
||||||
|
|
||||||
defp handle_adif_preview(content, email, socket) do
|
defp handle_adif_preview(content, email, private, socket) do
|
||||||
case AdifImport.preview(content, email) do
|
case AdifImport.preview(content, email) do
|
||||||
{:ok, preview} ->
|
{:ok, preview} ->
|
||||||
{:noreply, assign(socket, csv_preview: preview, csv_email: email)}
|
{:noreply, assign(socket, csv_preview: preview, csv_email: email, csv_private: private)}
|
||||||
|
|
||||||
{:error, :no_records} ->
|
{:error, :no_records} ->
|
||||||
{:noreply, put_flash(socket, :error, "ADIF file contains no records")}
|
{:noreply, put_flash(socket, :error, "ADIF file contains no records")}
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
defp handle_csv_preview(content, email, socket) do
|
defp handle_csv_preview(content, email, private, socket) do
|
||||||
case CsvImport.preview(content, email) do
|
case CsvImport.preview(content, email) do
|
||||||
{:ok, preview} ->
|
{:ok, preview} ->
|
||||||
{:noreply, assign(socket, csv_preview: preview, csv_email: email)}
|
{:noreply, assign(socket, csv_preview: preview, csv_email: email, csv_private: private)}
|
||||||
|
|
||||||
{:error, :empty_csv} ->
|
{:error, :empty_csv} ->
|
||||||
{:noreply, put_flash(socket, :error, "CSV file is empty")}
|
{:noreply, put_flash(socket, :error, "CSV file is empty")}
|
||||||
|
|
@ -408,6 +413,14 @@ defmodule MicrowavepropWeb.SubmitLive do
|
||||||
/>
|
/>
|
||||||
<% end %>
|
<% end %>
|
||||||
|
|
||||||
|
<div class="mt-4">
|
||||||
|
<.input
|
||||||
|
field={@form[:private]}
|
||||||
|
type="checkbox"
|
||||||
|
label="Private — only visible to me and administrators"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="mt-6">
|
<div class="mt-6">
|
||||||
<.button phx-disable-with="Submitting..." class="btn btn-primary btn-lg w-full sm:w-auto">
|
<.button phx-disable-with="Submitting..." class="btn btn-primary btn-lg w-full sm:w-auto">
|
||||||
<.icon name="hero-arrow-up-tray" class="w-5 h-5" /> Submit Contact
|
<.icon name="hero-arrow-up-tray" class="w-5 h-5" /> Submit Contact
|
||||||
|
|
@ -502,6 +515,11 @@ defmodule MicrowavepropWeb.SubmitLive do
|
||||||
</div>
|
</div>
|
||||||
<% end %>
|
<% end %>
|
||||||
|
|
||||||
|
<label class="flex items-center gap-2 cursor-pointer mt-2">
|
||||||
|
<input type="checkbox" name="private" value="true" class="checkbox checkbox-sm" />
|
||||||
|
<span class="text-sm">Private — only visible to me and administrators</span>
|
||||||
|
</label>
|
||||||
|
|
||||||
<div class="mt-6">
|
<div class="mt-6">
|
||||||
<button type="submit" class="btn btn-primary btn-lg w-full sm:w-auto">
|
<button type="submit" class="btn btn-primary btn-lg w-full sm:w-auto">
|
||||||
<.icon name="hero-arrow-up-tray" class="w-5 h-5" /> Upload Contacts
|
<.icon name="hero-arrow-up-tray" class="w-5 h-5" /> Upload Contacts
|
||||||
|
|
@ -622,6 +640,11 @@ defmodule MicrowavepropWeb.SubmitLive do
|
||||||
</div>
|
</div>
|
||||||
<% end %>
|
<% end %>
|
||||||
|
|
||||||
|
<label class="flex items-center gap-2 cursor-pointer mt-2">
|
||||||
|
<input type="checkbox" name="private" value="true" class="checkbox checkbox-sm" />
|
||||||
|
<span class="text-sm">Private — only visible to me and administrators</span>
|
||||||
|
</label>
|
||||||
|
|
||||||
<div class="mt-6">
|
<div class="mt-6">
|
||||||
<button type="submit" class="btn btn-primary btn-lg w-full sm:w-auto">
|
<button type="submit" class="btn btn-primary btn-lg w-full sm:w-auto">
|
||||||
<.icon name="hero-arrow-up-tray" class="w-5 h-5" /> Upload Contacts
|
<.icon name="hero-arrow-up-tray" class="w-5 h-5" /> Upload Contacts
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,14 @@
|
||||||
|
defmodule Microwaveprop.Repo.Migrations.AddPrivateToContacts do
|
||||||
|
use Ecto.Migration
|
||||||
|
|
||||||
|
def change do
|
||||||
|
alter table(:contacts) do
|
||||||
|
add :private, :boolean, null: false, default: false
|
||||||
|
end
|
||||||
|
|
||||||
|
create index(:contacts, [:user_id],
|
||||||
|
where: "private = true",
|
||||||
|
name: :contacts_private_user_id_idx
|
||||||
|
)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
@ -177,6 +177,28 @@ defmodule Microwaveprop.Radio.ContactEditTest do
|
||||||
refute changeset.valid?
|
refute changeset.valid?
|
||||||
end
|
end
|
||||||
|
|
||||||
|
test "accepts boolean private flag", %{contact: contact, user: user} do
|
||||||
|
changeset =
|
||||||
|
ContactEdit.changeset(%ContactEdit{}, %{
|
||||||
|
contact_id: contact.id,
|
||||||
|
user_id: user.id,
|
||||||
|
proposed_changes: %{"private" => true}
|
||||||
|
})
|
||||||
|
|
||||||
|
assert changeset.valid?
|
||||||
|
end
|
||||||
|
|
||||||
|
test "rejects non-boolean private", %{contact: contact, user: user} do
|
||||||
|
changeset =
|
||||||
|
ContactEdit.changeset(%ContactEdit{}, %{
|
||||||
|
contact_id: contact.id,
|
||||||
|
user_id: user.id,
|
||||||
|
proposed_changes: %{"private" => "yes"}
|
||||||
|
})
|
||||||
|
|
||||||
|
refute changeset.valid?
|
||||||
|
end
|
||||||
|
|
||||||
test "validates mode value", %{contact: contact, user: user} do
|
test "validates mode value", %{contact: contact, user: user} do
|
||||||
changeset =
|
changeset =
|
||||||
ContactEdit.changeset(%ContactEdit{}, %{
|
ContactEdit.changeset(%ContactEdit{}, %{
|
||||||
|
|
|
||||||
|
|
@ -97,6 +97,17 @@ defmodule Microwaveprop.Radio.ContactSubmissionTest do
|
||||||
assert errors_on(changeset).submitter_email
|
assert errors_on(changeset).submitter_email
|
||||||
end
|
end
|
||||||
|
|
||||||
|
test "accepts private flag and defaults to false" do
|
||||||
|
changeset = Contact.submission_changeset(%Contact{}, @valid_attrs)
|
||||||
|
assert changeset.valid?
|
||||||
|
assert Ecto.Changeset.get_field(changeset, :private) == false
|
||||||
|
|
||||||
|
attrs = Map.put(@valid_attrs, :private, true)
|
||||||
|
changeset = Contact.submission_changeset(%Contact{}, attrs)
|
||||||
|
assert changeset.valid?
|
||||||
|
assert Ecto.Changeset.get_field(changeset, :private) == true
|
||||||
|
end
|
||||||
|
|
||||||
test "does not cast user_submitted" do
|
test "does not cast user_submitted" do
|
||||||
attrs = Map.put(@valid_attrs, :user_submitted, true)
|
attrs = Map.put(@valid_attrs, :user_submitted, true)
|
||||||
changeset = Contact.submission_changeset(%Contact{}, attrs)
|
changeset = Contact.submission_changeset(%Contact{}, attrs)
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ defmodule Microwaveprop.RadioTest do
|
||||||
|
|
||||||
import Microwaveprop.AccountsFixtures
|
import Microwaveprop.AccountsFixtures
|
||||||
|
|
||||||
|
alias Microwaveprop.Accounts.Scope
|
||||||
alias Microwaveprop.Radio
|
alias Microwaveprop.Radio
|
||||||
alias Microwaveprop.Radio.Contact
|
alias Microwaveprop.Radio.Contact
|
||||||
|
|
||||||
|
|
@ -497,6 +498,99 @@ defmodule Microwaveprop.RadioTest do
|
||||||
user = user_fixture()
|
user = user_fixture()
|
||||||
assert Radio.list_contacts_for_user(user) == []
|
assert Radio.list_contacts_for_user(user) == []
|
||||||
end
|
end
|
||||||
|
|
||||||
|
test "includes private contacts owned by the user" do
|
||||||
|
user = user_fixture()
|
||||||
|
private = create_contact(%{station1: "W5PRV", user_id: user.id, private: true})
|
||||||
|
|
||||||
|
assert [found] = Radio.list_contacts_for_user(user)
|
||||||
|
assert found.id == private.id
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe "contact_map_payload/0 visibility" do
|
||||||
|
test "excludes private contacts from the cached map payload" do
|
||||||
|
_private = create_contact(%{station1: "W5PRV", private: true})
|
||||||
|
public = create_contact(%{station1: "W5PUB", qso_timestamp: ~U[2026-03-28 19:00:00Z]})
|
||||||
|
|
||||||
|
%{json: json, count: count} = Radio.contact_map_payload()
|
||||||
|
|
||||||
|
assert count == 1
|
||||||
|
decoded = json |> IO.iodata_to_binary() |> Jason.decode!()
|
||||||
|
assert [[_, _, _, _, _, "W5PUB" | _]] = decoded
|
||||||
|
refute to_string(IO.iodata_to_binary(json)) =~ "W5PRV"
|
||||||
|
_ = public
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe "can_view?/2" do
|
||||||
|
test "public contact is viewable by anyone" do
|
||||||
|
contact = create_contact(%{private: false})
|
||||||
|
assert Radio.can_view?(contact, nil) == true
|
||||||
|
assert Radio.can_view?(contact, %Scope{user: user_fixture()}) == true
|
||||||
|
end
|
||||||
|
|
||||||
|
test "private contact hidden from anonymous and non-owner" do
|
||||||
|
owner = user_fixture()
|
||||||
|
contact = create_contact(%{private: true, user_id: owner.id})
|
||||||
|
assert Radio.can_view?(contact, nil) == false
|
||||||
|
|
||||||
|
other = user_fixture()
|
||||||
|
assert Radio.can_view?(contact, %Scope{user: other}) == false
|
||||||
|
end
|
||||||
|
|
||||||
|
test "private contact visible to owner and admin" do
|
||||||
|
owner = user_fixture()
|
||||||
|
admin = user_fixture() |> Ecto.Changeset.change(is_admin: true) |> Repo.update!()
|
||||||
|
contact = create_contact(%{private: true, user_id: owner.id})
|
||||||
|
|
||||||
|
assert Radio.can_view?(contact, %Scope{user: owner}) == true
|
||||||
|
assert Radio.can_view?(contact, %Scope{user: admin}) == true
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe "list_contacts/2 scope-aware visibility" do
|
||||||
|
test "anonymous viewer sees only non-private" do
|
||||||
|
_private = create_contact(%{station1: "W5PRV", private: true})
|
||||||
|
public = create_contact(%{station1: "W5PUB", qso_timestamp: ~U[2026-03-28 19:00:00Z]})
|
||||||
|
|
||||||
|
%{entries: entries} = Radio.list_contacts(scope: nil)
|
||||||
|
assert Enum.map(entries, & &1.id) == [public.id]
|
||||||
|
end
|
||||||
|
|
||||||
|
test "owner sees their own private contacts" do
|
||||||
|
owner = user_fixture()
|
||||||
|
scope = %Scope{user: owner}
|
||||||
|
private = create_contact(%{station1: "W5PRV", private: true, user_id: owner.id})
|
||||||
|
public = create_contact(%{station1: "W5PUB", qso_timestamp: ~U[2026-03-28 19:00:00Z]})
|
||||||
|
|
||||||
|
%{entries: entries} = Radio.list_contacts(scope: scope)
|
||||||
|
ids = entries |> Enum.map(& &1.id) |> Enum.sort()
|
||||||
|
assert Enum.sort([private.id, public.id]) == ids
|
||||||
|
end
|
||||||
|
|
||||||
|
test "admin sees all private contacts" do
|
||||||
|
admin = user_fixture() |> Ecto.Changeset.change(is_admin: true) |> Repo.update!()
|
||||||
|
scope = %Scope{user: admin}
|
||||||
|
_other_user = user_fixture()
|
||||||
|
private = create_contact(%{station1: "W5PRV", private: true})
|
||||||
|
public = create_contact(%{station1: "W5PUB", qso_timestamp: ~U[2026-03-28 19:00:00Z]})
|
||||||
|
|
||||||
|
%{entries: entries} = Radio.list_contacts(scope: scope)
|
||||||
|
ids = entries |> Enum.map(& &1.id) |> Enum.sort()
|
||||||
|
assert Enum.sort([private.id, public.id]) == ids
|
||||||
|
end
|
||||||
|
|
||||||
|
test "non-owning non-admin cannot see other users' private" do
|
||||||
|
someone_else = user_fixture()
|
||||||
|
viewer = user_fixture()
|
||||||
|
scope = %Scope{user: viewer}
|
||||||
|
_private = create_contact(%{station1: "W5PRV", private: true, user_id: someone_else.id})
|
||||||
|
public = create_contact(%{station1: "W5PUB", qso_timestamp: ~U[2026-03-28 19:00:00Z]})
|
||||||
|
|
||||||
|
%{entries: entries} = Radio.list_contacts(scope: scope)
|
||||||
|
assert Enum.map(entries, & &1.id) == [public.id]
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
describe "list_contacts_involving_callsign/1" do
|
describe "list_contacts_involving_callsign/1" do
|
||||||
|
|
@ -553,6 +647,14 @@ defmodule Microwaveprop.RadioTest do
|
||||||
assert Radio.list_contacts_involving_callsign(nil) == []
|
assert Radio.list_contacts_involving_callsign(nil) == []
|
||||||
end
|
end
|
||||||
|
|
||||||
|
test "excludes private contacts" do
|
||||||
|
_private = create_contact(%{station1: "W5ISP", station2: "K5OTR", private: true})
|
||||||
|
visible = create_contact(%{station1: "W5ISP", station2: "K5OTR", qso_timestamp: ~U[2026-03-28 19:00:00Z]})
|
||||||
|
|
||||||
|
assert [found] = Radio.list_contacts_involving_callsign("W5ISP")
|
||||||
|
assert found.id == visible.id
|
||||||
|
end
|
||||||
|
|
||||||
test "caps the result at the 100 most recent contacts" do
|
test "caps the result at the 100 most recent contacts" do
|
||||||
base = ~U[2020-01-01 00:00:00Z]
|
base = ~U[2020-01-01 00:00:00Z]
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -117,6 +117,98 @@ defmodule MicrowavepropWeb.ContactLiveTest do
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
describe "Index private visibility" do
|
||||||
|
import Microwaveprop.AccountsFixtures
|
||||||
|
|
||||||
|
test "anonymous viewer does not see private contacts in the table", %{conn: conn} do
|
||||||
|
_private = create_contact(%{station1: "W5PRV", private: true})
|
||||||
|
_public = create_contact(%{station1: "W5PUB", qso_timestamp: ~U[2026-03-28 19:00:00Z]})
|
||||||
|
|
||||||
|
{:ok, _lv, html} = live(conn, ~p"/contacts")
|
||||||
|
|
||||||
|
assert html =~ "W5PUB"
|
||||||
|
refute html =~ "W5PRV"
|
||||||
|
end
|
||||||
|
|
||||||
|
test "owner sees their own private inline with Yes badge", %{conn: conn} do
|
||||||
|
owner = user_fixture()
|
||||||
|
_private = create_contact(%{station1: "W5PRV", private: true, user_id: owner.id})
|
||||||
|
_public = create_contact(%{station1: "W5PUB", qso_timestamp: ~U[2026-03-28 19:00:00Z]})
|
||||||
|
conn = log_in_user(conn, owner)
|
||||||
|
|
||||||
|
{:ok, _lv, html} = live(conn, ~p"/contacts")
|
||||||
|
|
||||||
|
assert html =~ "W5PRV"
|
||||||
|
assert html =~ "W5PUB"
|
||||||
|
assert html =~ "badge-warning"
|
||||||
|
end
|
||||||
|
|
||||||
|
test "admin sees all private contacts", %{conn: conn} do
|
||||||
|
admin = user_fixture() |> Ecto.Changeset.change(is_admin: true) |> Repo.update!()
|
||||||
|
_private = create_contact(%{station1: "W5PRV", private: true})
|
||||||
|
_public = create_contact(%{station1: "W5PUB", qso_timestamp: ~U[2026-03-28 19:00:00Z]})
|
||||||
|
conn = log_in_user(conn, admin)
|
||||||
|
|
||||||
|
{:ok, _lv, html} = live(conn, ~p"/contacts")
|
||||||
|
assert html =~ "W5PRV"
|
||||||
|
assert html =~ "W5PUB"
|
||||||
|
end
|
||||||
|
|
||||||
|
test "non-owning user does not see others' private", %{conn: conn} do
|
||||||
|
owner = user_fixture()
|
||||||
|
viewer = user_fixture()
|
||||||
|
_private = create_contact(%{station1: "W5PRV", private: true, user_id: owner.id})
|
||||||
|
_public = create_contact(%{station1: "W5PUB", qso_timestamp: ~U[2026-03-28 19:00:00Z]})
|
||||||
|
conn = log_in_user(conn, viewer)
|
||||||
|
|
||||||
|
{:ok, _lv, html} = live(conn, ~p"/contacts")
|
||||||
|
assert html =~ "W5PUB"
|
||||||
|
refute html =~ "W5PRV"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe "Show private visibility" do
|
||||||
|
import Microwaveprop.AccountsFixtures
|
||||||
|
|
||||||
|
test "anonymous viewer 404s on a private contact", %{conn: conn} do
|
||||||
|
contact = create_contact(%{private: true})
|
||||||
|
|
||||||
|
assert_raise Ecto.NoResultsError, fn ->
|
||||||
|
live(conn, ~p"/contacts/#{contact.id}")
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
test "non-owning user 404s on a private contact", %{conn: conn} do
|
||||||
|
owner = user_fixture()
|
||||||
|
other = user_fixture()
|
||||||
|
contact = create_contact(%{private: true, user_id: owner.id})
|
||||||
|
conn = log_in_user(conn, other)
|
||||||
|
|
||||||
|
assert_raise Ecto.NoResultsError, fn ->
|
||||||
|
live(conn, ~p"/contacts/#{contact.id}")
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
test "owner can view their own private contact", %{conn: conn} do
|
||||||
|
owner = user_fixture()
|
||||||
|
contact = create_contact(%{private: true, user_id: owner.id})
|
||||||
|
conn = log_in_user(conn, owner)
|
||||||
|
|
||||||
|
{:ok, _lv, html} = live(conn, ~p"/contacts/#{contact.id}")
|
||||||
|
assert html =~ "W5XD"
|
||||||
|
end
|
||||||
|
|
||||||
|
test "admin can view anyone's private contact", %{conn: conn} do
|
||||||
|
owner = user_fixture()
|
||||||
|
admin = user_fixture() |> Ecto.Changeset.change(is_admin: true) |> Repo.update!()
|
||||||
|
contact = create_contact(%{private: true, user_id: owner.id})
|
||||||
|
conn = log_in_user(conn, admin)
|
||||||
|
|
||||||
|
{:ok, _lv, html} = live(conn, ~p"/contacts/#{contact.id}")
|
||||||
|
assert html =~ "W5XD"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
describe "Show" do
|
describe "Show" do
|
||||||
test "renders contact detail fields", %{conn: conn} do
|
test "renders contact detail fields", %{conn: conn} do
|
||||||
contact = create_contact()
|
contact = create_contact()
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue