diff --git a/lib/memex/contexts.ex b/lib/memex/contexts.ex index d8d3f5b..f173d29 100644 --- a/lib/memex/contexts.ex +++ b/lib/memex/contexts.ex @@ -4,21 +4,89 @@ defmodule Memex.Contexts do """ import Ecto.Query, warn: false - alias Memex.Repo - - alias Memex.Contexts.Context + alias Ecto.Changeset + alias Memex.{Accounts.User, Contexts.Context, Repo} @doc """ Returns the list of contexts. ## Examples - iex> list_contexts() + iex> list_contexts(%User{id: 123}) [%Context{}, ...] + iex> list_contexts("my context", %User{id: 123}) + [%Context{title: "my context"}, ...] + """ - def list_contexts do - Repo.all(Context) + @spec list_contexts(User.t()) :: [Context.t()] + @spec list_contexts(search :: String.t() | nil, User.t()) :: [Context.t()] + def list_contexts(search \\ nil, user) + + def list_contexts(search, %{id: user_id}) when search |> is_nil() or search == "" do + Repo.all(from c in Context, where: c.user_id == ^user_id, order_by: c.title) + end + + def list_contexts(search, %{id: user_id}) when search |> is_binary() do + trimmed_search = String.trim(search) + + Repo.all( + from c in Context, + where: c.user_id == ^user_id, + where: + fragment( + "search @@ to_tsquery(websearch_to_tsquery(?)::text || ':*')", + ^trimmed_search + ), + order_by: { + :desc, + fragment( + "ts_rank_cd(search, to_tsquery(websearch_to_tsquery(?)::text || ':*'), 4)", + ^trimmed_search + ) + } + ) + end + + @doc """ + Returns the list of public contexts for viewing. + + ## Examples + + iex> list_public_contexts() + [%Context{}, ...] + + iex> list_public_contexts("my context") + [%Context{title: "my context"}, ...] + + """ + @spec list_public_contexts() :: [Context.t()] + @spec list_public_contexts(search :: String.t() | nil) :: [Context.t()] + def list_public_contexts(search \\ nil) + + def list_public_contexts(search) when search |> is_nil() or search == "" do + Repo.all(from c in Context, where: c.visibility == :public, order_by: c.title) + end + + def list_public_contexts(search) when search |> is_binary() do + trimmed_search = String.trim(search) + + Repo.all( + from c in Context, + where: c.visibility == :public, + where: + fragment( + "search @@ to_tsquery(websearch_to_tsquery(?)::text || ':*')", + ^trimmed_search + ), + order_by: { + :desc, + fragment( + "ts_rank_cd(search, to_tsquery(websearch_to_tsquery(?)::text || ':*'), 4)", + ^trimmed_search + ) + } + ) end @doc """ @@ -28,31 +96,47 @@ defmodule Memex.Contexts do ## Examples - iex> get_context!(123) + iex> get_context!(123, %User{id: 123}) %Context{} - iex> get_context!(456) + iex> get_context!(456, %User{id: 123}) ** (Ecto.NoResultsError) """ - def get_context!(id), do: Repo.get!(Context, id) + @spec get_context!(Context.id(), User.t()) :: Context.t() + def get_context!(id, %{id: user_id}) do + Repo.one!( + from c in Context, + where: c.id == ^id, + where: c.user_id == ^user_id or c.visibility in [:public, :unlisted] + ) + end + + def get_context!(id, _invalid_user) do + Repo.one!( + from c in Context, + where: c.id == ^id, + where: c.visibility in [:public, :unlisted] + ) + end @doc """ Creates a context. ## Examples - iex> create_context(%{field: value}) + iex> create_context(%{field: value}, %User{id: 123}) {:ok, %Context{}} - iex> create_context(%{field: bad_value}) + iex> create_context(%{field: bad_value}, %User{id: 123}) {:error, %Ecto.Changeset{}} """ - def create_context(attrs \\ %{}) do - %Context{} - |> Context.changeset(attrs) - |> Repo.insert() + @spec create_context(User.t()) :: {:ok, Context.t()} | {:error, Context.changeset()} + @spec create_context(attrs :: map(), User.t()) :: + {:ok, Context.t()} | {:error, Context.changeset()} + def create_context(attrs \\ %{}, user) do + Context.create_changeset(attrs, user) |> Repo.insert() end @doc """ @@ -60,16 +144,18 @@ defmodule Memex.Contexts do ## Examples - iex> update_context(context, %{field: new_value}) + iex> update_context(context, %{field: new_value}, %User{id: 123}) {:ok, %Context{}} - iex> update_context(context, %{field: bad_value}) + iex> update_context(context, %{field: bad_value}, %User{id: 123}) {:error, %Ecto.Changeset{}} """ - def update_context(%Context{} = context, attrs) do + @spec update_context(Context.t(), attrs :: map(), User.t()) :: + {:ok, Context.t()} | {:error, Context.changeset()} + def update_context(%Context{} = context, attrs, user) do context - |> Context.changeset(attrs) + |> Context.update_changeset(attrs, user) |> Repo.update() end @@ -78,15 +164,24 @@ defmodule Memex.Contexts do ## Examples - iex> delete_context(context) + iex> delete_context(%Context{user_id: 123}, %User{id: 123}) {:ok, %Context{}} - iex> delete_context(context) + iex> delete_context(%Context{user_id: 123}, %User{role: :admin}) + {:ok, %Context{}} + + iex> delete_context(%Context{}, %User{id: 123}) {:error, %Ecto.Changeset{}} """ - def delete_context(%Context{} = context) do - Repo.delete(context) + @spec delete_context(Context.t(), User.t()) :: + {:ok, Context.t()} | {:error, Context.changeset()} + def delete_context(%Context{user_id: user_id} = context, %{id: user_id}) do + context |> Repo.delete() + end + + def delete_context(%Context{} = context, %{role: :admin}) do + context |> Repo.delete() end @doc """ @@ -98,7 +193,23 @@ defmodule Memex.Contexts do %Ecto.Changeset{data: %Context{}} """ - def change_context(%Context{} = context, attrs \\ %{}) do - Context.changeset(context, attrs) + @spec change_context(Context.t(), User.t()) :: Context.changeset() + @spec change_context(Context.t(), attrs :: map(), User.t()) :: Context.changeset() + def change_context(%Context{} = context, attrs \\ %{}, user) do + context |> Context.update_changeset(attrs, user) + end + + @doc """ + Gets a canonical string representation of the `:tags` field for a Note + """ + @spec get_tags_string(Context.t() | Context.changeset() | [String.t()] | nil) :: String.t() + def get_tags_string(nil), do: "" + def get_tags_string(tags) when tags |> is_list(), do: tags |> Enum.join(",") + def get_tags_string(%Context{tags: tags}), do: tags |> get_tags_string() + + def get_tags_string(%Changeset{} = changeset) do + changeset + |> Changeset.get_field(:tags) + |> get_tags_string() end end diff --git a/lib/memex/contexts/context.ex b/lib/memex/contexts/context.ex index c31cc24..d8e0801 100644 --- a/lib/memex/contexts/context.ex +++ b/lib/memex/contexts/context.ex @@ -1,22 +1,70 @@ defmodule Memex.Contexts.Context do + @moduledoc """ + Represents a document that synthesizes multiple concepts as defined by notes + into a single consideration + """ + use Ecto.Schema import Ecto.Changeset + alias Ecto.{Changeset, UUID} + alias Memex.Accounts.User @primary_key {:id, :binary_id, autogenerate: true} @foreign_key_type :binary_id schema "contexts" do field :content, :string - field :tag, {:array, :string} + field :tags, {:array, :string} + field :tags_string, :string, virtual: true field :title, :string field :visibility, Ecto.Enum, values: [:public, :private, :unlisted] + belongs_to :user, User + timestamps() end + @type t :: %__MODULE__{ + title: String.t(), + content: String.t(), + tags: [String.t()] | nil, + tags_string: String.t(), + visibility: :public | :private | :unlisted, + user: User.t() | Ecto.Association.NotLoaded.t(), + user_id: User.id(), + inserted_at: NaiveDateTime.t(), + updated_at: NaiveDateTime.t() + } + @type id :: UUID.t() + @type changeset :: Changeset.t(t()) + @doc false - def changeset(context, attrs) do - context - |> cast(attrs, [:title, :content, :tag, :visibility]) - |> validate_required([:title, :content, :tag, :visibility]) + @spec create_changeset(attrs :: map(), User.t()) :: changeset() + def create_changeset(attrs, %User{id: user_id}) do + %__MODULE__{} + |> cast(attrs, [:title, :content, :tags, :visibility]) + |> change(user_id: user_id) + |> cast_tags_string(attrs) + |> validate_required([:title, :content, :user_id, :visibility]) end + + @spec update_changeset(t(), attrs :: map(), User.t()) :: changeset() + def update_changeset(%{user_id: user_id} = note, attrs, %User{id: user_id}) do + note + |> cast(attrs, [:title, :content, :tags, :visibility]) + |> cast_tags_string(attrs) + |> validate_required([:title, :content, :visibility]) + end + + defp cast_tags_string(changeset, %{"tags_string" => tags_string}) + when tags_string |> is_binary() do + tags = + tags_string + |> String.split(",", trim: true) + |> Enum.map(fn str -> str |> String.trim() end) + |> Enum.sort() + + changeset |> change(tags: tags) + end + + defp cast_tags_string(changeset, _attrs), do: changeset end diff --git a/lib/memex/contexts/context_note.ex b/lib/memex/contexts/context_note.ex index 412d30b..cf8c7c9 100644 --- a/lib/memex/contexts/context_note.ex +++ b/lib/memex/contexts/context_note.ex @@ -1,20 +1,35 @@ defmodule Memex.Contexts.ContextNote do + @moduledoc """ + Represents a mapping of a note to a context + """ + use Ecto.Schema import Ecto.Changeset + alias Memex.{Contexts.Context, Contexts.ContextNote, Notes.Note} @primary_key {:id, :binary_id, autogenerate: true} @foreign_key_type :binary_id schema "context_notes" do - field :context_id, :binary_id - field :note_id, :binary_id + belongs_to :context, Context + belongs_to :note, Note timestamps() end + @type t :: %ContextNote{ + context: Context.t() | nil, + context_id: Context.id(), + note: Note.t(), + note_id: Note.id(), + inserted_at: NaiveDateTime.t(), + updated_at: NaiveDateTime.t() + } + @type new_context_note :: %ContextNote{} + @doc false - def changeset(context_note, attrs) do - context_note - |> cast(attrs, []) - |> validate_required([]) + def create_changeset(%Context{id: context_id}, %Note{id: note_id}) do + %ContextNote{} + |> change(context_id: context_id, note_id: note_id) + |> validate_required([:context_id, :note_id]) end end diff --git a/lib/memex_web/components/contexts_table_component.ex b/lib/memex_web/components/contexts_table_component.ex new file mode 100644 index 0000000..30b6ae3 --- /dev/null +++ b/lib/memex_web/components/contexts_table_component.ex @@ -0,0 +1,135 @@ +defmodule MemexWeb.Components.ContextsTableComponent do + @moduledoc """ + A component that displays a list of contexts + """ + use MemexWeb, :live_component + alias Ecto.UUID + alias Memex.{Accounts.User, Contexts, Contexts.Context} + alias Phoenix.LiveView.{Rendered, Socket} + + @impl true + @spec update( + %{ + required(:id) => UUID.t(), + required(:current_user) => User.t(), + required(:contexts) => [Context.t()], + optional(any()) => any() + }, + Socket.t() + ) :: {:ok, Socket.t()} + def update(%{id: _id, contexts: _contexts, current_user: _current_user} = assigns, socket) do + socket = + socket + |> assign(assigns) + |> assign_new(:actions, fn -> [] end) + |> display_contexts() + + {:ok, socket} + end + + defp display_contexts( + %{ + assigns: %{ + contexts: contexts, + current_user: current_user, + actions: actions + } + } = socket + ) do + columns = + if actions == [] or current_user |> is_nil() do + [] + else + [%{label: nil, key: :actions, sortable: false}] + end + + columns = [ + %{label: gettext("title"), key: :title}, + %{label: gettext("content"), key: :content}, + %{label: gettext("tags"), key: :tags}, + %{label: gettext("visibility"), key: :visibility} + | columns + ] + + rows = + contexts + |> Enum.map(fn context -> + context + |> get_row_data_for_context(%{ + columns: columns, + current_user: current_user, + actions: actions + }) + end) + + socket |> assign(columns: columns, rows: rows) + end + + @impl true + def render(assigns) do + ~H""" +
+ <.live_component + module={MemexWeb.Components.TableComponent} + id={@id} + columns={@columns} + rows={@rows} + /> +
+ """ + end + + @spec get_row_data_for_context(Context.t(), additional_data :: map()) :: map() + defp get_row_data_for_context(context, %{columns: columns} = additional_data) do + columns + |> Map.new(fn %{key: key} -> + {key, get_value_for_key(key, context, additional_data)} + end) + end + + @spec get_value_for_key(atom(), Context.t(), additional_data :: map()) :: + any() | {any(), Rendered.t()} + defp get_value_for_key(:title, %{id: id, title: title}, _additional_data) do + assigns = %{id: id, title: title} + + title_block = ~H""" + <.link + navigate={Routes.context_show_path(Endpoint, :show, @id)} + class="link" + data-qa={"context-show-#{@id}"} + > + <%= @title %> + + """ + + {title, title_block} + end + + defp get_value_for_key(:content, %{content: content}, _additional_data) do + assigns = %{content: content} + + content_block = ~H""" +
+ <%= @content %> +
+ """ + + {content, content_block} + end + + defp get_value_for_key(:tags, %{tags: tags}, _additional_data) do + tags |> Contexts.get_tags_string() + end + + defp get_value_for_key(:actions, context, %{actions: actions}) do + assigns = %{actions: actions, context: context} + + ~H""" +
+ <%= render_slot(@actions, @context) %> +
+ """ + end + + defp get_value_for_key(key, context, _additional_data), do: context |> Map.get(key) +end diff --git a/lib/memex_web/live/context_live/form_component.ex b/lib/memex_web/live/context_live/form_component.ex index b8fc686..9dec24a 100644 --- a/lib/memex_web/live/context_live/form_component.ex +++ b/lib/memex_web/live/context_live/form_component.ex @@ -4,8 +4,8 @@ defmodule MemexWeb.ContextLive.FormComponent do alias Memex.Contexts @impl true - def update(%{context: context} = assigns, socket) do - changeset = Contexts.change_context(context) + def update(%{context: context, current_user: current_user} = assigns, socket) do + changeset = Contexts.change_context(context, current_user) {:ok, socket @@ -14,39 +14,52 @@ defmodule MemexWeb.ContextLive.FormComponent do end @impl true - def handle_event("validate", %{"context" => context_params}, socket) do + def handle_event( + "validate", + %{"context" => context_params}, + %{assigns: %{context: context, current_user: current_user}} = socket + ) do changeset = - socket.assigns.context - |> Contexts.change_context(context_params) + context + |> Contexts.change_context(context_params, current_user) |> Map.put(:action, :validate) {:noreply, assign(socket, :changeset, changeset)} end - def handle_event("save", %{"context" => context_params}, socket) do - save_context(socket, socket.assigns.action, context_params) + def handle_event("save", %{"context" => context_params}, %{assigns: %{action: action}} = socket) do + save_context(socket, action, context_params) end - defp save_context(socket, :edit, context_params) do - case Contexts.update_context(socket.assigns.context, context_params) do - {:ok, _context} -> + defp save_context( + %{assigns: %{context: context, return_to: return_to, current_user: current_user}} = + socket, + :edit, + context_params + ) do + case Contexts.update_context(context, context_params, current_user) do + {:ok, %{title: title}} -> {:noreply, socket - |> put_flash(:info, "context updated successfully") - |> push_navigate(to: socket.assigns.return_to)} + |> put_flash(:info, gettext("%{title} saved", title: title)) + |> push_navigate(to: return_to)} {:error, %Ecto.Changeset{} = changeset} -> {:noreply, assign(socket, :changeset, changeset)} end end - defp save_context(socket, :new, context_params) do - case Contexts.create_context(context_params) do - {:ok, _context} -> + defp save_context( + %{assigns: %{return_to: return_to, current_user: current_user}} = socket, + :new, + context_params + ) do + case Contexts.create_context(context_params, current_user) do + {:ok, %{title: title}} -> {:noreply, socket - |> put_flash(:info, "context created successfully") - |> push_navigate(to: socket.assigns.return_to)} + |> put_flash(:info, gettext("%{title} created", title: title)) + |> push_navigate(to: return_to)} {:error, %Ecto.Changeset{} = changeset} -> {:noreply, assign(socket, changeset: changeset)} diff --git a/lib/memex_web/live/context_live/form_component.html.heex b/lib/memex_web/live/context_live/form_component.html.heex index 16e9bc8..e2ba8e9 100644 --- a/lib/memex_web/live/context_live/form_component.html.heex +++ b/lib/memex_web/live/context_live/form_component.html.heex @@ -1,6 +1,4 @@ -
-

<%= @title %>

- +
<.form :let={f} for={@changeset} @@ -8,27 +6,44 @@ phx-target={@myself} phx-change="validate" phx-submit="save" + phx-debounce="300" + class="flex flex-col justify-start items-stretch space-y-4" > - <%= label(f, :title) %> - <%= text_input(f, :title) %> + <%= text_input(f, :title, + class: "input input-primary", + placeholder: gettext("title") + ) %> <%= error_tag(f, :title) %> - <%= label(f, :content) %> - <%= textarea(f, :content) %> + <%= textarea(f, :content, + id: "context-form-content", + class: "input input-primary h-64 min-h-64", + phx_hook: "MaintainAttrs", + phx_update: "ignore", + placeholder: gettext("content") + ) %> <%= error_tag(f, :content) %> - <%= label(f, :tag) %> - <%= multiple_select(f, :tag, "Option 1": "option1", "Option 2": "option2") %> - <%= error_tag(f, :tag) %> - - <%= label(f, :visibility) %> - <%= select(f, :visibility, Ecto.Enum.values(Memex.Contexts.Context, :visibility), - prompt: "Choose a value" + <%= text_input(f, :tags_string, + id: "tags-input", + class: "input input-primary", + placeholder: gettext("tag1,tag2"), + phx_update: "ignore", + value: Contexts.get_tags_string(@changeset) ) %> - <%= error_tag(f, :visibility) %> + <%= error_tag(f, :tags_string) %> -
- <%= submit("Save", phx_disable_with: "Saving...") %> +
+ <%= select(f, :visibility, Ecto.Enum.values(Memex.Contexts.Context, :visibility), + class: "grow input input-primary", + prompt: gettext("select privacy") + ) %> + + <%= submit(dgettext("actions", "save"), + phx_disable_with: gettext("saving..."), + class: "mx-auto btn btn-primary" + ) %>
+ <%= error_tag(f, :visibility) %>
diff --git a/lib/memex_web/live/context_live/index.ex b/lib/memex_web/live/context_live/index.ex index de1fa0d..d4c0666 100644 --- a/lib/memex_web/live/context_live/index.ex +++ b/lib/memex_web/live/context_live/index.ex @@ -1,46 +1,80 @@ defmodule MemexWeb.ContextLive.Index do use MemexWeb, :live_view - - alias Memex.Contexts - alias Memex.Contexts.Context + alias Memex.{Contexts, Contexts.Context} @impl true + def mount(%{"search" => search}, _session, socket) do + {:ok, socket |> assign(search: search) |> display_contexts()} + end + def mount(_params, _session, socket) do - {:ok, assign(socket, :contexts, list_contexts())} + {:ok, socket |> assign(search: nil) |> display_contexts()} end @impl true - def handle_params(params, _url, socket) do - {:noreply, apply_action(socket, socket.assigns.live_action, params)} + def handle_params(params, _url, %{assigns: %{live_action: live_action}} = socket) do + {:noreply, apply_action(socket, live_action, params)} end - defp apply_action(socket, :edit, %{"id" => id}) do + defp apply_action(%{assigns: %{current_user: current_user}} = socket, :edit, %{"id" => id}) do + %{title: title} = context = Contexts.get_context!(id, current_user) + socket - |> assign(:page_title, "edit context") - |> assign(:context, Contexts.get_context!(id)) + |> assign(page_title: gettext("edit %{title}", title: title)) + |> assign(context: context) end - defp apply_action(socket, :new, _params) do + defp apply_action(%{assigns: %{current_user: %{id: current_user_id}}} = socket, :new, _params) do socket - |> assign(:page_title, "new context") - |> assign(:context, %Context{}) + |> assign(page_title: gettext("new context")) + |> assign(context: %Context{user_id: current_user_id}) end defp apply_action(socket, :index, _params) do socket - |> assign(:page_title, "listing contexts") - |> assign(:context, nil) + |> assign(page_title: gettext("contexts")) + |> assign(search: nil) + |> assign(context: nil) + |> display_contexts() + end + + defp apply_action(socket, :search, %{"search" => search}) do + socket + |> assign(page_title: gettext("contexts")) + |> assign(search: search) + |> assign(context: nil) + |> display_contexts() end @impl true - def handle_event("delete", %{"id" => id}, socket) do - context = Contexts.get_context!(id) - {:ok, _} = Contexts.delete_context(context) + def handle_event("delete", %{"id" => id}, %{assigns: %{current_user: current_user}} = socket) do + context = Contexts.get_context!(id, current_user) + {:ok, %{title: title}} = Contexts.delete_context(context, current_user) - {:noreply, assign(socket, :contexts, list_contexts())} + socket = + socket + |> assign(contexts: Contexts.list_contexts(current_user)) + |> put_flash(:info, gettext("%{title} deleted", title: title)) + + {:noreply, socket} end - defp list_contexts do - Contexts.list_contexts() + @impl true + def handle_event("search", %{"search" => %{"search_term" => ""}}, socket) do + {:noreply, socket |> push_patch(to: Routes.context_index_path(Endpoint, :index))} + end + + def handle_event("search", %{"search" => %{"search_term" => search_term}}, socket) do + {:noreply, + socket |> push_patch(to: Routes.context_index_path(Endpoint, :search, search_term))} + end + + defp display_contexts(%{assigns: %{current_user: current_user, search: search}} = socket) + when not (current_user |> is_nil()) do + socket |> assign(contexts: Contexts.list_contexts(search, current_user)) + end + + defp display_contexts(%{assigns: %{search: search}} = socket) do + socket |> assign(contexts: Contexts.list_public_contexts(search)) end end diff --git a/lib/memex_web/live/context_live/index.html.heex b/lib/memex_web/live/context_live/index.html.heex index 3e8a1cf..ab8ae63 100644 --- a/lib/memex_web/live/context_live/index.html.heex +++ b/lib/memex_web/live/context_live/index.html.heex @@ -1,10 +1,69 @@ -

listing contexts

+
+

+ <%= gettext("contexts") %> +

+ + <.form + :let={f} + for={:search} + phx-change="search" + phx-submit="search" + class="self-stretch flex flex-col items-stretch" + > + <%= text_input(f, :search_term, + class: "input input-primary", + value: @search, + phx_debounce: 300, + placeholder: gettext("search") + ) %> + + + <%= if @contexts |> Enum.empty?() do %> +

+ <%= gettext("no contexts found") %> +

+ <% else %> + <.live_component + module={MemexWeb.Components.ContextsTableComponent} + id="contexts-index-table" + current_user={@current_user} + contexts={@contexts} + > + <:actions :let={context}> + <%= if @current_user do %> + <.link + patch={Routes.context_index_path(@socket, :edit, context)} + data-qa={"context-edit-#{context.id}"} + > + <%= dgettext("actions", "edit") %> + + <.link + href="#" + phx-click="delete" + phx-value-id={context.id} + data-confirm={dgettext("prompts", "are you sure?")} + data-qa={"delete-context-#{context.id}"} + > + <%= dgettext("actions", "delete") %> + + <% end %> + + + <% end %> + + <%= if @current_user do %> + <.link patch={Routes.context_index_path(@socket, :new)} class="self-end btn btn-primary"> + <%= dgettext("actions", "new context") %> + + <% end %> +
<%= if @live_action in [:new, :edit] do %> <.modal return_to={Routes.context_index_path(@socket, :index)}> <.live_component module={MemexWeb.ContextLive.FormComponent} id={@context.id || :new} + current_user={@current_user} title={@page_title} action={@live_action} context={@context} @@ -12,55 +71,3 @@ /> <% end %> - - - - - - - - - - - - - - <%= for context <- @contexts do %> - - - - - - - - - <% end %> - -
TitleContentTagVisibility
<%= context.title %><%= context.content %><%= context.tag %><%= context.visibility %> - - <.link navigate={Routes.context_show_path(@socket, :show, context)}> - <%= dgettext("actions", "show") %> - - - - <.link patch={Routes.context_index_path(@socket, :edit, context)}> - <%= dgettext("actions", "edit") %> - - - - <.link - href="#" - phx-click="delete" - phx-value-id={context.id} - data-confirm={dgettext("prompts", "are you sure?")} - > - <%= dgettext("actions", "delete") %> - - -
- - - <.link patch={Routes.context_index_path(@socket, :new)}> - <%= dgettext("actions", "new context") %> - - diff --git a/lib/memex_web/live/context_live/show.ex b/lib/memex_web/live/context_live/show.ex index 6f77a96..3e3239a 100644 --- a/lib/memex_web/live/context_live/show.ex +++ b/lib/memex_web/live/context_live/show.ex @@ -9,13 +9,33 @@ defmodule MemexWeb.ContextLive.Show do end @impl true - def handle_params(%{"id" => id}, _, socket) do + def handle_params( + %{"id" => id}, + _, + %{assigns: %{live_action: live_action, current_user: current_user}} = socket + ) do {:noreply, socket - |> assign(:page_title, page_title(socket.assigns.live_action)) - |> assign(:context, Contexts.get_context!(id))} + |> assign(:page_title, page_title(live_action)) + |> assign(:context, Contexts.get_context!(id, current_user))} end - defp page_title(:show), do: "show context" - defp page_title(:edit), do: "edit context" + @impl true + def handle_event( + "delete", + _params, + %{assigns: %{context: context, current_user: current_user}} = socket + ) do + {:ok, %{title: title}} = Contexts.delete_context(context, current_user) + + socket = + socket + |> put_flash(:info, gettext("%{title} deleted", title: title)) + |> push_navigate(to: Routes.context_index_path(Endpoint, :index)) + + {:noreply, socket} + end + + defp page_title(:show), do: gettext("show context") + defp page_title(:edit), do: gettext("edit context") end diff --git a/lib/memex_web/live/context_live/show.html.heex b/lib/memex_web/live/context_live/show.html.heex index 510618b..c1fe16e 100644 --- a/lib/memex_web/live/context_live/show.html.heex +++ b/lib/memex_web/live/context_live/show.html.heex @@ -1,10 +1,51 @@ -

show context

+
+

+ <%= @context.title %> +

+ +

<%= if @context.tags, do: @context.tags |> Enum.join(", ") %>

+ + + +

+ <%= gettext("Visibility: %{visibility}", visibility: @context.visibility) %> +

+ +
+ <.link class="btn btn-primary" patch={Routes.context_index_path(@socket, :index)}> + <%= dgettext("actions", "back") %> + + <%= if @current_user do %> + <.link class="btn btn-primary" patch={Routes.context_show_path(@socket, :edit, @context)}> + <%= dgettext("actions", "edit") %> + + + + <% end %> +
+
<%= if @live_action in [:edit] do %> <.modal return_to={Routes.context_show_path(@socket, :show, @context)}> <.live_component module={MemexWeb.ContextLive.FormComponent} id={@context.id} + current_user={@current_user} title={@page_title} action={@live_action} context={@context} @@ -12,37 +53,3 @@ /> <% end %> - - - - - <.link patch={Routes.context_show_path(@socket, :edit, @context)} class="button"> - <%= dgettext("actions", "edit") %> - - -| - - <.link navigate={Routes.context_index_path(@socket, :index)}> - <%= dgettext("actions", "Back") %> - - diff --git a/lib/memex_web/router.ex b/lib/memex_web/router.ex index 0b15cc6..5ce90ce 100644 --- a/lib/memex_web/router.ex +++ b/lib/memex_web/router.ex @@ -63,7 +63,7 @@ defmodule MemexWeb.Router do live "/contexts/new", ContextLive.Index, :new live "/contexts/:id/edit", ContextLive.Index, :edit - live "/contexts/:id/show/edit", ContextLive.Show, :edit + live "/context/:id/edit", ContextLive.Show, :edit live "/pipelines/new", PipelineLive.Index, :new live "/pipelines/:id/edit", PipelineLive.Index, :edit @@ -83,7 +83,8 @@ defmodule MemexWeb.Router do live "/note/:id", NoteLive.Show, :show live "/contexts", ContextLive.Index, :index - live "/contexts/:id", ContextLive.Show, :show + live "/contexts/:search", ContextLive.Index, :search + live "/context/:id", ContextLive.Show, :show live "/pipelines", PipelineLive.Index, :index live "/pipelines/:id", PipelineLive.Show, :show diff --git a/priv/gettext/actions.pot b/priv/gettext/actions.pot index e1bff9b..bc02bce 100644 --- a/priv/gettext/actions.pot +++ b/priv/gettext/actions.pot @@ -10,7 +10,6 @@ msgid "" msgstr "" -#: lib/memex_web/live/context_live/show.html.heex:46 #: lib/memex_web/live/pipeline_live/show.html.heex:41 #, elixir-autogen, elixir-format msgid "Back" @@ -73,7 +72,8 @@ msgstr "" msgid "create invite" msgstr "" -#: lib/memex_web/live/context_live/index.html.heex:53 +#: lib/memex_web/live/context_live/index.html.heex:47 +#: lib/memex_web/live/context_live/show.html.heex:37 #: lib/memex_web/live/note_live/index.html.heex:47 #: lib/memex_web/live/note_live/show.html.heex:37 #: lib/memex_web/live/pipeline_live/index.html.heex:51 @@ -86,8 +86,8 @@ msgstr "" msgid "delete user" msgstr "" -#: lib/memex_web/live/context_live/index.html.heex:43 -#: lib/memex_web/live/context_live/show.html.heex:40 +#: lib/memex_web/live/context_live/index.html.heex:38 +#: lib/memex_web/live/context_live/show.html.heex:27 #: lib/memex_web/live/note_live/index.html.heex:38 #: lib/memex_web/live/note_live/show.html.heex:27 #: lib/memex_web/live/pipeline_live/index.html.heex:41 @@ -112,7 +112,7 @@ msgstr "" msgid "log in" msgstr "" -#: lib/memex_web/live/context_live/index.html.heex:64 +#: lib/memex_web/live/context_live/index.html.heex:56 #, elixir-autogen, elixir-format msgid "new context" msgstr "" @@ -138,17 +138,18 @@ msgstr "" msgid "register" msgstr "" +#: lib/memex_web/live/context_live/form_component.html.heex:42 #: lib/memex_web/live/note_live/form_component.html.heex:42 #, elixir-autogen, elixir-format msgid "save" msgstr "" -#: lib/memex_web/live/context_live/index.html.heex:38 #: lib/memex_web/live/pipeline_live/index.html.heex:36 #, elixir-autogen, elixir-format msgid "show" msgstr "" +#: lib/memex_web/live/context_live/show.html.heex:23 #: lib/memex_web/live/note_live/show.html.heex:23 #, elixir-autogen, elixir-format msgid "back" diff --git a/priv/gettext/default.pot b/priv/gettext/default.pot index 7be0417..7c719a4 100644 --- a/priv/gettext/default.pot +++ b/priv/gettext/default.pot @@ -10,17 +10,21 @@ msgid "" msgstr "" +#: lib/memex_web/live/context_live/form_component.ex:61 #: lib/memex_web/live/note_live/form_component.ex:60 #, elixir-autogen, elixir-format msgid "%{title} created" msgstr "" +#: lib/memex_web/live/context_live/index.ex:57 +#: lib/memex_web/live/context_live/show.ex:33 #: lib/memex_web/live/note_live/index.ex:57 #: lib/memex_web/live/note_live/show.ex:33 #, elixir-autogen, elixir-format msgid "%{title} deleted" msgstr "" +#: lib/memex_web/live/context_live/form_component.ex:44 #: lib/memex_web/live/note_live/form_component.ex:43 #, elixir-autogen, elixir-format msgid "%{title} saved" @@ -101,6 +105,7 @@ msgstr "" msgid "Uses left" msgstr "" +#: lib/memex_web/live/context_live/show.html.heex:18 #: lib/memex_web/live/note_live/show.html.heex:18 #, elixir-autogen, elixir-format msgid "Visibility: %{visibility}" @@ -126,13 +131,18 @@ msgstr "" msgid "confirm new password" msgstr "" +#: lib/memex_web/components/contexts_table_component.ex:48 #: lib/memex_web/components/notes_table_component.ex:48 +#: lib/memex_web/live/context_live/form_component.html.heex:23 #: lib/memex_web/live/note_live/form_component.html.heex:23 #, elixir-autogen, elixir-format msgid "content" msgstr "" #: lib/memex_web/components/topbar.ex:52 +#: lib/memex_web/live/context_live/index.ex:35 +#: lib/memex_web/live/context_live/index.ex:43 +#: lib/memex_web/live/context_live/index.html.heex:3 #, elixir-autogen, elixir-format msgid "contexts" msgstr "" @@ -168,6 +178,7 @@ msgstr "" msgid "document your processes, attaching contexts to each step" msgstr "" +#: lib/memex_web/live/context_live/index.ex:23 #: lib/memex_web/live/note_live/index.ex:23 #, elixir-autogen, elixir-format msgid "edit %{title}" @@ -331,11 +342,13 @@ msgstr "" msgid "report bugs or request features" msgstr "" +#: lib/memex_web/live/context_live/form_component.html.heex:43 #: lib/memex_web/live/note_live/form_component.html.heex:43 #, elixir-autogen, elixir-format msgid "saving..." msgstr "" +#: lib/memex_web/live/context_live/form_component.html.heex:39 #: lib/memex_web/live/note_live/form_component.html.heex:39 #, elixir-autogen, elixir-format msgid "select privacy" @@ -351,17 +364,21 @@ msgstr "" msgid "settings" msgstr "" +#: lib/memex_web/live/context_live/form_component.html.heex:30 #: lib/memex_web/live/note_live/form_component.html.heex:30 #, elixir-autogen, elixir-format msgid "tag1,tag2" msgstr "" +#: lib/memex_web/components/contexts_table_component.ex:49 #: lib/memex_web/components/notes_table_component.ex:49 #, elixir-autogen, elixir-format msgid "tags" msgstr "" +#: lib/memex_web/components/contexts_table_component.ex:47 #: lib/memex_web/components/notes_table_component.ex:47 +#: lib/memex_web/live/context_live/form_component.html.heex:14 #: lib/memex_web/live/note_live/form_component.html.heex:14 #, elixir-autogen, elixir-format msgid "title" @@ -392,6 +409,7 @@ msgstr "" msgid "view the source code" msgstr "" +#: lib/memex_web/components/contexts_table_component.ex:50 #: lib/memex_web/components/notes_table_component.ex:50 #, elixir-autogen, elixir-format msgid "visibility" @@ -412,6 +430,7 @@ msgstr "" msgid "new note" msgstr "" +#: lib/memex_web/live/context_live/index.html.heex:17 #: lib/memex_web/live/note_live/index.html.heex:17 #, elixir-autogen, elixir-format msgid "search" @@ -421,3 +440,23 @@ msgstr "" #, elixir-autogen, elixir-format msgid "show note" msgstr "" + +#: lib/memex_web/live/context_live/show.ex:40 +#, elixir-autogen, elixir-format +msgid "edit context" +msgstr "" + +#: lib/memex_web/live/context_live/index.ex:29 +#, elixir-autogen, elixir-format +msgid "new context" +msgstr "" + +#: lib/memex_web/live/context_live/index.html.heex:23 +#, elixir-autogen, elixir-format +msgid "no contexts found" +msgstr "" + +#: lib/memex_web/live/context_live/show.ex:39 +#, elixir-autogen, elixir-format +msgid "show context" +msgstr "" diff --git a/priv/gettext/prompts.pot b/priv/gettext/prompts.pot index 4247e90..a6ec60f 100644 --- a/priv/gettext/prompts.pot +++ b/priv/gettext/prompts.pot @@ -141,7 +141,8 @@ msgstr "" msgid "are you sure you want to make %{invite_name} unlimited?" msgstr "" -#: lib/memex_web/live/context_live/index.html.heex:51 +#: lib/memex_web/live/context_live/index.html.heex:44 +#: lib/memex_web/live/context_live/show.html.heex:34 #: lib/memex_web/live/note_live/index.html.heex:44 #: lib/memex_web/live/note_live/show.html.heex:34 #: lib/memex_web/live/pipeline_live/index.html.heex:49 diff --git a/priv/repo/migrations/20220726001039_create_contexts.exs b/priv/repo/migrations/20220726001039_create_contexts.exs index 177b2bc..82d050c 100644 --- a/priv/repo/migrations/20220726001039_create_contexts.exs +++ b/priv/repo/migrations/20220726001039_create_contexts.exs @@ -6,10 +6,30 @@ defmodule Memex.Repo.Migrations.CreateContexts do add :id, :binary_id, primary_key: true add :title, :string add :content, :text - add :tag, {:array, :string} + add :tags, {:array, :string} add :visibility, :string + add :user_id, references(:users, on_delete: :delete_all, type: :binary_id) + timestamps() end + + flush() + + execute """ + ALTER TABLE contexts + ADD COLUMN search tsvector + GENERATED ALWAYS AS ( + setweight(to_tsvector('english', coalesce(title, '')), 'A') || + setweight(to_tsvector('english', coalesce(immutable_array_to_string(tags, ' '), '')), 'B') || + setweight(to_tsvector('english', coalesce(content, '')), 'C') + ) STORED + """ + + execute("CREATE INDEX contexts_trgm_idx ON contexts USING GIN (search)") + end + + def down do + drop table(:contexts) end end diff --git a/test/memex/contexts_test.exs b/test/memex/contexts_test.exs index f2062ee..69438dd 100644 --- a/test/memex/contexts_test.exs +++ b/test/memex/contexts_test.exs @@ -1,71 +1,113 @@ defmodule Memex.ContextsTest do use Memex.DataCase - - alias Memex.Contexts + import Memex.ContextsFixtures + alias Memex.{Contexts, Contexts.Context} + @moduletag :contexts_test + @invalid_attrs %{content: nil, tag: nil, title: nil, visibility: nil} describe "contexts" do - alias Memex.Contexts.Context - - import Memex.ContextsFixtures - - @invalid_attrs %{content: nil, tag: nil, title: nil, visibility: nil} - - test "list_contexts/0 returns all contexts" do - context = context_fixture() - assert Contexts.list_contexts() == [context] + setup do + [user: user_fixture()] end - test "get_context!/1 returns the context with given id" do - context = context_fixture() - assert Contexts.get_context!(context.id) == context + test "list_contexts/1 returns all contexts for a user", %{user: user} do + context_a = context_fixture(%{title: "a", visibility: :public}, user) + context_b = context_fixture(%{title: "b", visibility: :unlisted}, user) + context_c = context_fixture(%{title: "c", visibility: :private}, user) + assert Contexts.list_contexts(user) == [context_a, context_b, context_c] end - test "create_context/1 with valid data creates a context" do - valid_attrs = %{content: "some content", tag: [], title: "some title", visibility: :public} + test "list_public_contexts/0 returns public contexts", %{user: user} do + public_context = context_fixture(%{visibility: :public}, user) + context_fixture(%{visibility: :unlisted}, user) + context_fixture(%{visibility: :private}, user) + assert Contexts.list_public_contexts() == [public_context] + end - assert {:ok, %Context{} = context} = Contexts.create_context(valid_attrs) + test "get_context!/1 returns the context with given id", %{user: user} do + context = context_fixture(%{visibility: :public}, user) + assert Contexts.get_context!(context.id, user) == context + + context = context_fixture(%{visibility: :unlisted}, user) + assert Contexts.get_context!(context.id, user) == context + + context = context_fixture(%{visibility: :private}, user) + assert Contexts.get_context!(context.id, user) == context + end + + test "get_context!/1 only returns unlisted or public contexts for other users", %{user: user} do + another_user = user_fixture() + context = context_fixture(%{visibility: :public}, another_user) + assert Contexts.get_context!(context.id, user) == context + + context = context_fixture(%{visibility: :unlisted}, another_user) + assert Contexts.get_context!(context.id, user) == context + + context = context_fixture(%{visibility: :private}, another_user) + + assert_raise Ecto.NoResultsError, fn -> + Contexts.get_context!(context.id, user) + end + end + + test "create_context/1 with valid data creates a context", %{user: user} do + valid_attrs = %{ + "content" => "some content", + "tags_string" => "tag1,tag2", + "title" => "some title", + "visibility" => :public + } + + assert {:ok, %Context{} = context} = Contexts.create_context(valid_attrs, user) assert context.content == "some content" - assert context.tag == [] + assert context.tags == ["tag1", "tag2"] assert context.title == "some title" assert context.visibility == :public end - test "create_context/1 with invalid data returns error changeset" do - assert {:error, %Ecto.Changeset{}} = Contexts.create_context(@invalid_attrs) + test "create_context/1 with invalid data returns error changeset", %{user: user} do + assert {:error, %Ecto.Changeset{}} = Contexts.create_context(@invalid_attrs, user) end - test "update_context/2 with valid data updates the context" do - context = context_fixture() + test "update_context/2 with valid data updates the context", %{user: user} do + context = context_fixture(user) update_attrs = %{ - content: "some updated content", - tag: [], - title: "some updated title", - visibility: :private + "content" => "some updated content", + "tags_string" => "tag1,tag2", + "title" => "some updated title", + "visibility" => :private } - assert {:ok, %Context{} = context} = Contexts.update_context(context, update_attrs) + assert {:ok, %Context{} = context} = Contexts.update_context(context, update_attrs, user) assert context.content == "some updated content" - assert context.tag == [] + assert context.tags == ["tag1", "tag2"] assert context.title == "some updated title" assert context.visibility == :private end - test "update_context/2 with invalid data returns error changeset" do - context = context_fixture() - assert {:error, %Ecto.Changeset{}} = Contexts.update_context(context, @invalid_attrs) - assert context == Contexts.get_context!(context.id) + test "update_context/2 with invalid data returns error changeset", %{user: user} do + context = context_fixture(user) + assert {:error, %Ecto.Changeset{}} = Contexts.update_context(context, @invalid_attrs, user) + assert context == Contexts.get_context!(context.id, user) end - test "delete_context/1 deletes the context" do - context = context_fixture() - assert {:ok, %Context{}} = Contexts.delete_context(context) - assert_raise Ecto.NoResultsError, fn -> Contexts.get_context!(context.id) end + test "delete_context/1 deletes the context", %{user: user} do + context = context_fixture(user) + assert {:ok, %Context{}} = Contexts.delete_context(context, user) + assert_raise Ecto.NoResultsError, fn -> Contexts.get_context!(context.id, user) end end - test "change_context/1 returns a context changeset" do - context = context_fixture() - assert %Ecto.Changeset{} = Contexts.change_context(context) + test "delete_context/1 deletes the context for an admin user", %{user: user} do + admin_user = admin_fixture() + context = context_fixture(user) + assert {:ok, %Context{}} = Contexts.delete_context(context, admin_user) + assert_raise Ecto.NoResultsError, fn -> Contexts.get_context!(context.id, user) end + end + + test "change_context/1 returns a context changeset", %{user: user} do + context = context_fixture(user) + assert %Ecto.Changeset{} = Contexts.change_context(context, user) end end end diff --git a/test/memex_web/live/context_live_test.exs b/test/memex_web/live/context_live_test.exs index 2522147..6e17aa5 100644 --- a/test/memex_web/live/context_live_test.exs +++ b/test/memex_web/live/context_live_test.exs @@ -23,18 +23,17 @@ defmodule MemexWeb.ContextLiveTest do "visibility" => nil } - defp create_context(_) do - context = context_fixture() - %{context: context} + defp create_context(%{user: user}) do + [context: context_fixture(user)] end describe "Index" do - setup [:create_context] + setup [:register_and_log_in_user, :create_context] test "lists all contexts", %{conn: conn, context: context} do {:ok, _index_live, html} = live(conn, Routes.context_index_path(conn, :index)) - assert html =~ "listing contexts" + assert html =~ "contexts" assert html =~ context.content end @@ -56,15 +55,15 @@ defmodule MemexWeb.ContextLiveTest do |> render_submit() |> follow_redirect(conn, Routes.context_index_path(conn, :index)) - assert html =~ "context created successfully" + assert html =~ "#{@create_attrs |> Map.get("title")} created" assert html =~ "some content" end test "updates context in listing", %{conn: conn, context: context} do {:ok, index_live, _html} = live(conn, Routes.context_index_path(conn, :index)) - assert index_live |> element("#context-#{context.id} a", "edit") |> render_click() =~ - "edit context" + assert index_live |> element("[data-qa=\"context-edit-#{context.id}\"]") |> render_click() =~ + "edit" assert_patch(index_live, Routes.context_index_path(conn, :edit, context)) @@ -78,20 +77,20 @@ defmodule MemexWeb.ContextLiveTest do |> render_submit() |> follow_redirect(conn, Routes.context_index_path(conn, :index)) - assert html =~ "context updated successfully" + assert html =~ "#{@update_attrs |> Map.get("title")} saved" assert html =~ "some updated content" end test "deletes context in listing", %{conn: conn, context: context} do {:ok, index_live, _html} = live(conn, Routes.context_index_path(conn, :index)) - assert index_live |> element("#context-#{context.id} a", "delete") |> render_click() + assert index_live |> element("[data-qa=\"delete-context-#{context.id}\"]") |> render_click() refute has_element?(index_live, "#context-#{context.id}") end end describe "show" do - setup [:create_context] + setup [:register_and_log_in_user, :create_context] test "displays context", %{conn: conn, context: context} do {:ok, _show_live, html} = live(conn, Routes.context_show_path(conn, :show, context)) @@ -103,8 +102,7 @@ defmodule MemexWeb.ContextLiveTest do test "updates context within modal", %{conn: conn, context: context} do {:ok, show_live, _html} = live(conn, Routes.context_show_path(conn, :show, context)) - assert show_live |> element("a", "edit") |> render_click() =~ - "edit context" + assert show_live |> element("a", "edit") |> render_click() =~ "edit" assert_patch(show_live, Routes.context_show_path(conn, :edit, context)) @@ -118,8 +116,20 @@ defmodule MemexWeb.ContextLiveTest do |> render_submit() |> follow_redirect(conn, Routes.context_show_path(conn, :show, context)) - assert html =~ "context updated successfully" + assert html =~ "#{@update_attrs |> Map.get("title")} saved" assert html =~ "some updated content" end + + test "deletes context", %{conn: conn, context: context} do + {:ok, show_live, _html} = live(conn, Routes.context_show_path(conn, :show, context)) + + {:ok, index_live, _html} = + show_live + |> element("[data-qa=\"delete-context-#{context.id}\"]") + |> render_click() + |> follow_redirect(conn, Routes.context_index_path(conn, :index)) + + refute has_element?(index_live, "#context-#{context.id}") + end end end diff --git a/test/support/fixtures/contexts_fixtures.ex b/test/support/fixtures/contexts_fixtures.ex index 0965668..b930f21 100644 --- a/test/support/fixtures/contexts_fixtures.ex +++ b/test/support/fixtures/contexts_fixtures.ex @@ -3,20 +3,23 @@ defmodule Memex.ContextsFixtures do This module defines test helpers for creating entities via the `Memex.Contexts` context. """ + alias Memex.{Accounts.User, Contexts, Contexts.Context} @doc """ Generate a context. """ - def context_fixture(attrs \\ %{}) do + @spec context_fixture(User.t()) :: Context.t() + @spec context_fixture(attrs :: map(), User.t()) :: Context.t() + def context_fixture(attrs \\ %{}, user) do {:ok, context} = attrs |> Enum.into(%{ content: "some content", tag: [], title: "some title", - visibility: :public + visibility: :private }) - |> Memex.Contexts.create_context() + |> Contexts.create_context(user) context end