From 74463c75fc39b27bfa9a49bbb3b568eb6af5bd17 Mon Sep 17 00:00:00 2001 From: Max Wardeh Date: Thu, 21 Nov 2024 11:05:01 +0100 Subject: [PATCH] Add skills --- assets/js/app.js | 8 +- assets/js/combobox.js | 56 +++++ lib/maker_passport/accounts.ex | 1 - lib/maker_passport/maker.ex | 168 ++++++++++++++- lib/maker_passport/maker/profile.ex | 6 + lib/maker_passport/maker/profile_skill.ex | 22 ++ lib/maker_passport/maker/skill.ex | 22 ++ .../components/core_components.ex | 20 +- .../profile_form_component.html.heex | 105 ++++----- .../live/profile_live/show.ex | 74 ++++++- .../live/profile_live/show.html.heex | 104 ++++++--- .../profile_live/skills_form_component.ex | 23 ++ .../live/profile_live/typeahead_component.ex | 204 ++++++++++++++++++ .../20241004232834_create_skills.exs | 26 +++ 14 files changed, 735 insertions(+), 104 deletions(-) create mode 100644 assets/js/combobox.js create mode 100644 lib/maker_passport/maker/profile_skill.ex create mode 100644 lib/maker_passport/maker/skill.ex create mode 100644 lib/maker_passport_web/live/profile_live/skills_form_component.ex create mode 100644 lib/maker_passport_web/live/profile_live/typeahead_component.ex create mode 100644 priv/repo/migrations/20241004232834_create_skills.exs diff --git a/assets/js/app.js b/assets/js/app.js index 9d0a497..b1a365f 100644 --- a/assets/js/app.js +++ b/assets/js/app.js @@ -22,12 +22,18 @@ import {Socket} from "phoenix" import {LiveSocket} from "phoenix_live_view" import topbar from "../vendor/topbar" import Uploaders from "./uploaders" +import {Combobox, ComboboxOption} from "./combobox" + +let Hooks = {} +Hooks.Combobox = Combobox +Hooks.ComboboxOption = ComboboxOption let csrfToken = document.querySelector("meta[name='csrf-token']").getAttribute("content") let liveSocket = new LiveSocket("/live", Socket, { longPollFallbackMs: 2500, params: {_csrf_token: csrfToken}, - uploaders: Uploaders + uploaders: Uploaders, + hooks: Hooks }) // Show progress bar on live navigation and form submits diff --git a/assets/js/combobox.js b/assets/js/combobox.js new file mode 100644 index 0000000..c1abfe3 --- /dev/null +++ b/assets/js/combobox.js @@ -0,0 +1,56 @@ +export const Combobox = { + mounted() { + const wrapperEl = this.el.closest('div') + const toggleButton = wrapperEl.querySelector('button[phx-click="toggle-options"]') + const myself = this.el.getAttribute('phx-target') + + this.el.addEventListener('keydown', event => { + if (['ArrowUp', 'ArrowDown'].includes(event.key)) { + // Prevent cursor-move from first to last character + event.preventDefault() + } + + if (event.key === 'Enter' && document.activeElement === this.el) { + // Prevent Submit when combobox is focused + event.preventDefault() + + const listEl = wrapperEl.querySelector('ul') + const value = listEl && listEl.dataset.focusedOption || null + + // The focused value are stored in a data attribute. + // So, send the selected value to the component. + this.pushEventTo(myself, "select-option", {option: value}) + } + }) + + this.el.addEventListener('keyup', event => { + if (['Enter', 'Tab', 'Escape', 'ArrowUp', 'ArrowDown'].includes(event.key)) return + + // Push the search phrase to the LiveComponent + this.pushEventTo(myself, "filter-options", {search_phrase: this.el.value}) + }) + + toggleButton && toggleButton.addEventListener('click', event => { + this.el.focus() + }) + + this.handleEvent("set-input-value", data => { + // This is sent to all instances of this hook so we need to compare id:s + if (data.id !== this.el.id) return + this.el.value = data.label + }) + } + } + + export const ComboboxOption = { + mounted() { + this.el.addEventListener('mouseover', event => { + const el = event.target + const value = el.getAttribute('phx-value-option') + const target = el.getAttribute('phx-target') + + this.pushEventTo(target, "set-focus-to", {value: value}) + }) + } + } + \ No newline at end of file diff --git a/lib/maker_passport/accounts.ex b/lib/maker_passport/accounts.ex index efca55a..8909ad0 100644 --- a/lib/maker_passport/accounts.ex +++ b/lib/maker_passport/accounts.ex @@ -4,7 +4,6 @@ defmodule MakerPassport.Accounts do """ import Ecto.Query, warn: false - import Ecto.Changeset alias MakerPassport.Repo alias MakerPassport.Maker.Profile diff --git a/lib/maker_passport/maker.ex b/lib/maker_passport/maker.ex index 1ce2acb..5c5fddc 100644 --- a/lib/maker_passport/maker.ex +++ b/lib/maker_passport/maker.ex @@ -7,7 +7,7 @@ defmodule MakerPassport.Maker do alias MakerPassport.Accounts alias MakerPassport.Repo - alias MakerPassport.Maker.Profile + alias MakerPassport.Maker.{Profile, Skill, ProfileSkill} @doc """ Returns the list of profiles. @@ -52,7 +52,7 @@ defmodule MakerPassport.Maker do ** (Ecto.NoResultsError) """ - def get_profile!(id), do: Repo.get!(Profile, id) |> Repo.preload(:user) + def get_profile!(id), do: Repo.get!(Profile, id) |> Repo.preload(:user) |> Repo.preload([:skills]) def get_profile_by_user_id!(user_id) do user = @@ -61,6 +61,17 @@ defmodule MakerPassport.Maker do Repo.get!(Profile, user.profile.id) |> Repo.preload(:user) + |> Repo.preload([:skills]) + end + + def get_profile_with_skills_by_user_id!(user_id) do + user = + Accounts.get_user!(user_id) + |> Repo.preload(:profile) + + Repo.get!(Profile, user.profile.id) + |> Repo.preload(:user) + |> Repo.preload([:skills]) end @@ -129,4 +140,157 @@ defmodule MakerPassport.Maker do def change_profile(%Profile{} = profile, attrs \\ %{}) do Profile.changeset(profile, attrs) end + + @doc """ + Returns the list of skills. + + ## Examples + + iex> list_skills() + [%Skill{}, ...] + + """ + def list_skills do + Repo.all(Skill) + end + + @doc """ + Gets a single skill. + + Raises `Ecto.NoResultsError` if the Skill does not exist. + + ## Examples + + iex> get_skill!(123) + %Skill{} + + iex> get_skill!(456) + ** (Ecto.NoResultsError) + + """ + def get_skill!(id), do: Repo.get!(Skill, id) + + @doc """ + Creates a skill. + + ## Examples + + iex> create_skill(%{field: value}) + {:ok, %Skill{}} + + iex> create_skill(%{field: bad_value}) + {:error, %Ecto.Changeset{}} + + """ + def create_skill(attrs \\ %{}) do + %Skill{} + |> Skill.changeset(attrs) + |> Repo.insert() + end + + @doc """ + Updates a skill. + + ## Examples + + iex> update_skill(skill, %{field: new_value}) + {:ok, %Skill{}} + + iex> update_skill(skill, %{field: bad_value}) + {:error, %Ecto.Changeset{}} + + """ + def update_skill(%Skill{} = skill, attrs) do + skill + |> Skill.changeset(attrs) + |> Repo.update() + end + + @doc """ + Deletes a skill. + + ## Examples + + iex> delete_skill(skill) + {:ok, %Skill{}} + + iex> delete_skill(skill) + {:error, %Ecto.Changeset{}} + + """ + def delete_skill(%Skill{} = skill) do + Repo.delete(skill) + end + + @doc """ + Returns an `%Ecto.Changeset{}` for tracking skill changes. + + ## Examples + + iex> change_skill(skill) + %Ecto.Changeset{data: %Skill{}} + + """ + def change_skill(%Skill{} = skill, attrs \\ %{}) do + Skill.changeset(skill, attrs) + end + + def add_skill(profile, skill_text) when is_binary(skill_text) do + skill = + case Repo.get_by(Skill, %{name: skill_text}) do + nil -> + %Skill{} |> Skill.changeset(%{name: skill_text}) |> Repo.insert!() + + skill -> + skill + end + + add_skill(profile, skill.id) + end + + def add_skill(%Profile{} = profile, skill_id) do + add_skill(profile.id, skill_id) + end + + def add_skill(profile_id, skill_id) do + ProfileSkill.changeset(%ProfileSkill{}, %{profile_id: profile_id, skill_id: skill_id}) + |> Repo.insert() + end + + def remove_skill(%Profile{} = profile, skill_id) do + profile_skill = Repo.get_by(ProfileSkill, %{profile_id: profile.id, skill_id: skill_id}) + Repo.delete(profile_skill) + end + + def search_skills(""), do: [] + + def search_skills(search_text) do + Repo.all(from s in Skill, where: ilike(s.name, ^"%#{search_text}%")) + end + + def to_skill_list(skills) do + skills + |> Enum.map(&to_skill_tuple/1) + |> Enum.sort_by(&elem(&1, 0)) + end + + def to_skill_list(skills, profile_id) do + profile_skills = Repo.all(from ps in ProfileSkill, where: ps.profile_id == ^profile_id) + skill_ids_to_exclude = Enum.map(profile_skills, & &1.skill_id) + skills = Enum.reject(skills, &(&1.id in skill_ids_to_exclude)) + skills + |> Enum.map(&to_skill_tuple/1) + |> Enum.sort_by(&elem(&1, 0)) + end + + defp to_skill_tuple(skill) do + {skill.name, skill.id} + end + + def has_skill?(profile, skill_id) do + # Check if the profile has the skill by querying the database + Repo.exists?(from s in ProfileSkill, + where: s.profile_id == ^profile.id and s.skill_id == ^skill_id + ) + end end diff --git a/lib/maker_passport/maker/profile.ex b/lib/maker_passport/maker/profile.ex index 3868e75..3585daf 100644 --- a/lib/maker_passport/maker/profile.ex +++ b/lib/maker_passport/maker/profile.ex @@ -7,6 +7,7 @@ defmodule MakerPassport.Maker.Profile do import Ecto.Changeset alias MakerPassport.Accounts.User + alias MakerPassport.Maker.Skill schema "profiles" do field :bio, :string @@ -15,6 +16,10 @@ defmodule MakerPassport.Maker.Profile do belongs_to :user, User + many_to_many :skills, Skill, + join_through: "profile_skills", + on_replace: :delete + timestamps(type: :utc_datetime) end @@ -35,4 +40,5 @@ defmodule MakerPassport.Maker.Profile do # Add other required fields end def profile_complete?(_user), do: false + end diff --git a/lib/maker_passport/maker/profile_skill.ex b/lib/maker_passport/maker/profile_skill.ex new file mode 100644 index 0000000..08f4f5a --- /dev/null +++ b/lib/maker_passport/maker/profile_skill.ex @@ -0,0 +1,22 @@ +defmodule MakerPassport.Maker.ProfileSkill do + @moduledoc false + use Ecto.Schema + import Ecto.Changeset + + alias MakerPassport.Maker.{Profile, Skill} + + schema "profile_skills" do + + belongs_to :profile, Profile + belongs_to :skill, Skill + + timestamps(type: :utc_datetime) + end + + @doc false + def changeset(profile_skill, attrs) do + profile_skill + |> cast(attrs, [:profile_id, :skill_id]) + |> validate_required([:profile_id, :skill_id]) + end +end diff --git a/lib/maker_passport/maker/skill.ex b/lib/maker_passport/maker/skill.ex new file mode 100644 index 0000000..71b15a7 --- /dev/null +++ b/lib/maker_passport/maker/skill.ex @@ -0,0 +1,22 @@ +defmodule MakerPassport.Maker.Skill do + @moduledoc false + use Ecto.Schema + import Ecto.Changeset + + alias MakerPassport.Maker.Profile + + schema "skills" do + field :name, :string + + many_to_many :profiles, Profile, join_through: "profile_skills" + + timestamps(type: :utc_datetime) + end + + @doc false + def changeset(skill, attrs \\ %{}) do + skill + |> cast(attrs, [:name]) + |> validate_required([:name]) + end +end diff --git a/lib/maker_passport_web/components/core_components.ex b/lib/maker_passport_web/components/core_components.ex index 4e2f959..75568bc 100644 --- a/lib/maker_passport_web/components/core_components.ex +++ b/lib/maker_passport_web/components/core_components.ex @@ -231,8 +231,7 @@ defmodule MakerPassportWeb.CoreComponents do