diff --git a/assets/js/app.js b/assets/js/app.js index b1a365f..c79241c 100644 --- a/assets/js/app.js +++ b/assets/js/app.js @@ -23,10 +23,12 @@ import {LiveSocket} from "phoenix_live_view" import topbar from "../vendor/topbar" import Uploaders from "./uploaders" import {Combobox, ComboboxOption} from "./combobox" +import LiveSelect from "./live_select" let Hooks = {} Hooks.Combobox = Combobox Hooks.ComboboxOption = ComboboxOption +Hooks.LiveSelect = LiveSelect let csrfToken = document.querySelector("meta[name='csrf-token']").getAttribute("content") let liveSocket = new LiveSocket("/live", Socket, { diff --git a/assets/js/live_select.js b/assets/js/live_select.js new file mode 100644 index 0000000..058476d --- /dev/null +++ b/assets/js/live_select.js @@ -0,0 +1,151 @@ +function debounce(func, msec) { + let timer; + return (...args) => { + clearTimeout(timer) + timer = setTimeout(() => { + func.apply(this, args) + }, msec) + } +} + +const LiveSelect = { + textInput() { + return this.el.querySelector("input[type=text]") + }, + + debounceMsec() { + return parseInt(this.el.dataset["debounce"]) + }, + + updateMinLen() { + return parseInt(this.el.dataset["updateMinLen"]) + }, + + maybeStyleClearButton() { + const clear_button = this.el.querySelector('button[phx-click=clear]') + if (clear_button) { + this.textInput().parentElement.style.position = 'relative' + clear_button.style.position = 'absolute' + clear_button.style.top = '0px' + clear_button.style.bottom = '0px' + clear_button.style.right = '5px' + clear_button.style.display = 'block' + } + }, + + pushEventToParent(event, payload) { + const target = this.el.dataset['phxTarget']; + if (target) { + this.pushEventTo(target, event, payload) + } else { + this.pushEvent(event, payload) + } + }, + + attachDomEventHandlers() { + const textInput = this.textInput() + textInput.onkeydown = (event) => { + if (event.code === "Enter") { + event.preventDefault() + } + this.pushEventTo(this.el, 'keydown', {key: event.code}) + } + + const changeEvents = debounce((id, field, text) => { + this.pushEventTo(this.el, "change", {text}) + this.pushEventToParent("live_select_change", {id: this.el.id, field, text}) + }, this.debounceMsec()) + + textInput.oninput = (event) => { + const text = event.target.value.trim() + const field = this.el.dataset['field'] + if (text.length >= this.updateMinLen()) { + changeEvents(this.el.id, field, text) + } else { + this.pushEventTo(this.el, "options_clear", {}) + } + } + + const dropdown = this.el.querySelector("ul") + if (dropdown) { + dropdown.onmousedown = (event) => { + const option = event.target.closest('div[data-idx]') + if (option) { + this.pushEventTo(this.el, 'option_click', {idx: option.dataset.idx}) + event.preventDefault() + } + } + } + + this.el.querySelectorAll("button[data-idx]").forEach(button => { + button.onclick = (event) => { + this.pushEventTo(this.el, 'option_remove', {idx: button.dataset.idx}) + } + }) + }, + + setInputValue(value) { + this.textInput().value = value + }, + + inputEvent(selection, mode) { + const selector = mode === "single" + ? "input.single-mode" + : (selection.length === 0 + ? "input[data-live-select-empty]" + : "input[type=hidden]") + this.el.querySelector(selector).dispatchEvent(new Event('input', {bubbles: true})) + }, + + mounted() { + this.maybeStyleClearButton() + + this.handleEvent("parent_event", ({id, event, payload}) => { + if (this.el.id === id) { + this.pushEventToParent(event, payload) + } + }) + + this.handleEvent("select", ({id, selection, mode, input_event, parent_event}) => { + if (this.el.id === id) { + this.selection = selection + if (mode === "single") { + const label = selection.length > 0 ? selection[0].label : null + this.setInputValue(label) + } else { + this.setInputValue(null) + } + if (input_event) { + this.inputEvent(selection, mode) + } + if (parent_event) { + this.pushEventToParent(parent_event, {id}) + } + } + }) + + this.handleEvent("active", ({id, idx}) => { + if (this.el.id === id) { + const option = this.el.querySelector(`div[data-idx="${idx}"]`) + if (option) { + option.scrollIntoView({block: "nearest"}) + } + } + }) + + this.attachDomEventHandlers() + }, + + updated() { + this.maybeStyleClearButton() + this.attachDomEventHandlers() + }, + + reconnected() { + if (this.selection && this.selection.length > 0) { + this.pushEventTo(this.el, "selection_recovery", this.selection) + } + } +} + +export default LiveSelect \ No newline at end of file diff --git a/assets/tailwind.config.js b/assets/tailwind.config.js index 01cde2a..37c54e0 100644 --- a/assets/tailwind.config.js +++ b/assets/tailwind.config.js @@ -9,7 +9,9 @@ module.exports = { content: [ "./js/**/*.js", "../lib/maker_passport_web.ex", - "../lib/maker_passport_web/**/*.*ex" + "../lib/maker_passport_web/**/*.*ex", + '../deps/live_select/lib/live_select/component.*ex', + ], theme: { extend: { diff --git a/lib/maker_passport/maker.ex b/lib/maker_passport/maker.ex index caf8fb7..54c943c 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, Skill, ProfileSkill} + alias MakerPassport.Maker.{Certification, Profile, ProfileSkill, Skill, Website, Location} @doc """ Returns the list of profiles. @@ -18,11 +18,27 @@ defmodule MakerPassport.Maker do [%Profile{}, ...] """ - def list_profiles do - Repo.all(Profile) |> Repo.preload(:skills) + def list_profiles(skills \\ []) do + Repo.all(Profile) |> Repo.preload([:skills, :user]) + query = + Profile + |> join(:left, [p], s in assoc(p, :skills)) + |> maybe_filter_by_skills(skills) + |> preload([:skills, :user]) + |> distinct([p], p.id) + + Repo.all(query) +end + + defp maybe_filter_by_skills(query, []), do: query + defp maybe_filter_by_skills(query, skills) do + query + |> where([p, s], s.name in ^skills) + |> group_by([p], p.id) + |> having([p, s], count(s.id) == ^length(skills)) end - def list_profiles(criteria) when is_list(criteria) do + def list_profiles_by_criteria(criteria) when is_list(criteria) do query = from(p in Profile, where: not is_nil(p.name)) Enum.reduce(criteria, query, fn @@ -36,6 +52,7 @@ defmodule MakerPassport.Maker do from q in query, preload: ^preload end) |> Repo.all() + |> Repo.preload([:user]) end @doc """ @@ -52,7 +69,13 @@ defmodule MakerPassport.Maker do ** (Ecto.NoResultsError) """ - def get_profile!(id), do: Repo.get!(Profile, id) |> Repo.preload(:user) |> Repo.preload([:skills]) + def get_profile!(id), + do: + Repo.get!(Profile, id) + |> Repo.preload(:user) + |> Repo.preload([:skills]) + |> Repo.preload(:certifications) + |> Repo.preload(:websites) def get_profile_by_user_id!(user_id) do user = @@ -74,8 +97,6 @@ defmodule MakerPassport.Maker do |> Repo.preload([:skills]) end - - @doc """ Creates a profile. @@ -141,7 +162,28 @@ defmodule MakerPassport.Maker do Profile.changeset(profile, attrs) end - @doc """ + @doc """ + Create %Location{} or get %Location{}. + + ## Examples + + iex> get_or_create_location("Uk", "London") + %Location{} + + """ + def get_or_create_location(country, city) do + case Repo.one(from l in Location, where: ilike(l.country, ^country) and ilike(l.city, ^city)) do + nil -> + %Location{country: country, city: city} + |> Location.changeset() + |> Repo.insert!() + + location -> + location + end + end + + @doc """ Returns the list of skills. ## Examples @@ -278,6 +320,7 @@ defmodule MakerPassport.Maker 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)) @@ -289,8 +332,199 @@ defmodule MakerPassport.Maker do 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 + Repo.exists?( + from s in ProfileSkill, + where: s.profile_id == ^profile.id and s.skill_id == ^skill_id ) end + + def search_cities(""), do: [] + + def search_cities(search_text) do + Repo.all(from l in Location, where: ilike(l.city, ^"%#{search_text}%")) + end + + def to_city_list(cities, nil) do + cities + |> Enum.map(&to_city_tuple/1) + |> Enum.sort_by(&elem(&1, 0)) + end + + def to_city_list(cities, location_id) do + cities = Enum.filter(cities, &(&1.id != location_id)) + + cities + |> Enum.map(&to_city_tuple/1) + |> Enum.sort_by(&elem(&1, 0)) + end + + defp to_city_tuple(location) do + {location.city, location.id} + end + + @doc """ + Gets a single website. + + Raises `Ecto.NoResultsError` if the Website does not exist. + + ## Examples + + iex> get_website!(123) + %Website{} + + iex> get_website!(456) + ** (Ecto.NoResultsError) + + """ + def get_website!(id), do: Repo.get!(Website, id) + + @doc """ + Creates a website, + + ## Examples + + iex> create_website(%{field: value}) + {:ok, %Website{}} + + iex> create_website(%{field: bad_value}) + {:error, %Ecto.Changeset{}} + + """ + def create_website(attrs \\ %{}) do + %Website{} + |> Website.changeset(attrs) + |> Repo.insert() + end + + @doc """ + Updates a website. + + ## Examples + + iex> update_website(website, %{field: new_value}) + {:ok, %Website{}} + + iex> update_website(website, %{field: bad_value}) + {:error, %Ecto.Changeset{}} + + """ + def update_website(%Website{} = website, attrs) do + website + |> Website.changeset(attrs) + |> Repo.update() + end + + @doc """ + Deletes a website. + + ## Examples + + iex> delete_website(website) + {:ok, %Website{}} + + iex> delete_website(website) + {:error, %Ecto.Changeset{}} + + """ + def delete_website(%Website{} = website) do + Repo.delete(website) + end + + def remove_website(website_id) do + website = get_website!(website_id) + Repo.delete(website) + end + + @doc """ + Returns an `%Ecto.Changeset{}` for tracking website changes. + + ## Examples + + iex> change_website(website) + %Ecto.Changeset{data: %Website{}} + + """ + def change_website(%Website{} = website, attrs \\ %{}) do + Website.changeset(website, attrs) + end + + def add_website(profile_id, website_params) do + website_params = Map.put(website_params, "profile_id", profile_id) + create_website(website_params) + end + + @doc """ + Gets a single certification. + + Raises `Ecto.NoResultsError` if the Certification does not exist. + + ## Examples + + iex> get_certification!(123) + %Certification{} + + iex> get_certification!(456) + ** (Ecto.NoResultsError) + + """ + def get_certification!(id), do: Repo.get!(Certification, id) + + @doc """ + Creates a certification, + + ## Examples + + iex> create_certification(%{field: value}) + {:ok, %Certification{}} + + iex> create_certification(%{field: bad_value}) + {:error, %Ecto.Changeset{}} + + """ + def create_certification(attrs \\ %{}) do + IO.inspect(attrs) + + %Certification{} + |> Certification.changeset(attrs) + |> Repo.insert() + end + + @doc """ + Deletes a certification. + + ## Examples + + iex> delete_certification(certification) + {:ok, %Certification{}} + + iex> delete_certification(certification) + {:error, %Ecto.Changeset{}} + + """ + def delete_certification(%Certification{} = certification) do + Repo.delete(certification) + end + + @doc """ + Returns an `%Ecto.Changeset{}` for tracking certification changes. + + ## Examples + + iex> change_certification(certification) + %Ecto.Changeset{data: %Certification{}} + + """ + def change_certification(%Certification{} = certification, attrs \\ %{}) do + Certification.changeset(certification, attrs) + end + + def add_certification(profile_id, certification_params) do + certification_params = Map.put(certification_params, "profile_id", profile_id) + create_certification(certification_params) + end + + def remove_certification(certification_id) do + certification = get_certification!(certification_id) + Repo.delete(certification) + end end diff --git a/lib/maker_passport/maker/certification.ex b/lib/maker_passport/maker/certification.ex new file mode 100644 index 0000000..454efa8 --- /dev/null +++ b/lib/maker_passport/maker/certification.ex @@ -0,0 +1,25 @@ +defmodule MakerPassport.Maker.Certification do + @moduledoc false + use Ecto.Schema + import Ecto.Changeset + + alias MakerPassport.Maker.Profile + + schema "maker_certifications" do + field :issue_date, :date + field :issuer_name, :string + field :title, :string + field :url, :string + + belongs_to :profile, Profile + + timestamps(type: :utc_datetime) + end + + @doc false + def changeset(certification, attrs \\ %{}) do + certification + |> cast(attrs, [:title, :issuer_name, :issue_date, :url, :profile_id]) + |> validate_required([:title, :issuer_name, :issue_date, :profile_id]) + end +end diff --git a/lib/maker_passport/maker/location.ex b/lib/maker_passport/maker/location.ex new file mode 100644 index 0000000..c2ee272 --- /dev/null +++ b/lib/maker_passport/maker/location.ex @@ -0,0 +1,23 @@ +defmodule MakerPassport.Maker.Location do + @moduledoc """ + A Maker's profile location. + """ + + use Ecto.Schema + import Ecto.Changeset + + schema "locations" do + field :country, :string + field :city, :string + field :latitude, :float + field :longitude, :float + + timestamps(type: :utc_datetime) + end + + @doc false + def changeset(location, attrs \\ %{}) do + location + |> cast(attrs, [:country, :city, :latitude, :longitude]) + end +end diff --git a/lib/maker_passport/maker/profile.ex b/lib/maker_passport/maker/profile.ex index 3585daf..afd1ab1 100644 --- a/lib/maker_passport/maker/profile.ex +++ b/lib/maker_passport/maker/profile.ex @@ -7,7 +7,7 @@ defmodule MakerPassport.Maker.Profile do import Ecto.Changeset alias MakerPassport.Accounts.User - alias MakerPassport.Maker.Skill + alias MakerPassport.Maker.{Certification, Skill, Location, Website} schema "profiles" do field :bio, :string @@ -15,10 +15,13 @@ defmodule MakerPassport.Maker.Profile do field :profile_image_location, :string, default: "" belongs_to :user, User + belongs_to :location, Location + has_many :certifications, Certification + has_many :websites, Website many_to_many :skills, Skill, - join_through: "profile_skills", - on_replace: :delete + join_through: "profile_skills", + on_replace: :delete timestamps(type: :utc_datetime) end @@ -26,19 +29,16 @@ defmodule MakerPassport.Maker.Profile do @doc false def changeset(profile, attrs) do profile - |> cast(attrs, [:name, :bio, :profile_image_location]) + |> cast(attrs, [:name, :bio, :profile_image_location, :location_id]) |> validate_required([:name]) # |> foreign_key_constraint(:user_id) # |> unique_constraint(:user_id) end def profile_complete?(%User{profile: profile}) when not is_nil(profile) do - # Add all required profile fields here - not is_nil(profile.name) and - not is_nil(profile.bio) and - not is_nil(profile.profile_image_location) - # Add other required fields + required_fields = [:name, :bio, :profile_image_location, :location_id] + Enum.all?(required_fields, &(not is_nil(Map.get(profile, &1)))) end - def profile_complete?(_user), do: false + def profile_complete?(_user), do: false end diff --git a/lib/maker_passport/maker/website.ex b/lib/maker_passport/maker/website.ex new file mode 100644 index 0000000..b2d517d --- /dev/null +++ b/lib/maker_passport/maker/website.ex @@ -0,0 +1,36 @@ +defmodule MakerPassport.Maker.Website do + @moduledoc false + use Ecto.Schema + import Ecto.Changeset + + alias MakerPassport.Maker.Profile + + schema "maker_websites" do + field :title, :string + field :url, :string + + belongs_to :profile, Profile + + timestamps(type: :utc_datetime) + end + + @doc false + def changeset(website, attrs \\ %{}) do + website + |> cast(attrs, [:title, :url, :profile_id]) + |> validate_required([:title, :url, :profile_id]) + |> validate_url(:url) + end + + defp validate_url(changeset, field) do + validate_change(changeset, field, fn _, value -> + case URI.parse(value) do + %URI{scheme: scheme, host: host} when not is_nil(scheme) and not is_nil(host) -> + [] + + _ -> + [{field, "must be a valid URL"}] + end + end) + end +end diff --git a/lib/maker_passport_web/components/core_components.ex b/lib/maker_passport_web/components/core_components.ex index 75568bc..6c0fcc8 100644 --- a/lib/maker_passport_web/components/core_components.ex +++ b/lib/maker_passport_web/components/core_components.ex @@ -202,7 +202,7 @@ defmodule MakerPassportWeb.CoreComponents do def simple_form(assigns) do ~H""" <.form :let={f} for={@for} as={@as} {@rest}> -