Compare commits

...
3 Commits
Author SHA1 Message Date
Ryan Pandya b9c7cb7b84 Lots of changes! 2022-11-27 20:59:46 -05:00
Ryan Pandya b85e3cc96c Refactor & fix friend search + address search 2022-11-26 00:16:31 -05:00
Ryan Pandya fd5bcbfc05 solo relationships, birth events, formatting 2022-11-23 17:42:49 -05:00
24 changed files with 1139 additions and 491 deletions
+62 -1
View File
@@ -53,7 +53,7 @@ window.hideElement = function (id) {
el.hidden = true; el.hidden = true;
} }
window.selectResult = function (latlon, display) { window.selectMapResult = function (latlon, display) {
var name_el = document.querySelector("[autocomplete=name]"); var name_el = document.querySelector("[autocomplete=name]");
var latlon_el = document.querySelector("[autocomplete=latlon]"); var latlon_el = document.querySelector("[autocomplete=latlon]");
@@ -63,6 +63,65 @@ window.selectResult = function (latlon, display) {
window.liveSocket.hooks.showMapbox.initMap(); window.liveSocket.hooks.showMapbox.initMap();
}; };
window.selectRelation = function (id, name) {
var e = new Event('selectRelation');
e.data = {
id: id,
name: name
}
window.dispatchEvent(e);
}
window.deleteRelation = function (id) {
var e = new Event('deleteRelation');
e.data = {
id: id
}
window.dispatchEvent(e);
}
window.relationType = function (rel_id, type) {
var e = new Event('relationType');
e.data = {
rel_id: rel_id,
type: type
}
window.dispatchEvent(e);
}
Hooks.NewRelation = {
mounted() {
var list_el = document.querySelector("div#relationships");
window.addEventListener("selectRelation", e => {
this.pushEvent("phx:select_relation", e.data, function (reply) {
console.log(reply);
})
})
}
}
Hooks.RelationshipCard = {
mounted() {
console.log("Mounted card for relationship " + this.el.getAttribute("relationship-id"));
window.addEventListener("deleteRelation", e => {
this.pushEvent("phx:delete_relation", e.data, function (reply) {
console.log(reply);
})
})
window.addEventListener("relationType", e => {
this.pushEvent("phx:relation_type", e.data, function (reply) {
document.querySelector("#type-selector-" + e.data.rel_id).hidden = true;
})
})
}
}
Hooks.showMapbox = { Hooks.showMapbox = {
initMap() { initMap() {
mapboxgl.accessToken = 'pk.eyJ1IjoicnlhbnBhbmR5YSIsImEiOiJja3psM2tlcDA1MXl1Mm9uZmo5bGxpNzdxIn0.TwBKpTTypcD5fWFc8XRyHg'; mapboxgl.accessToken = 'pk.eyJ1IjoicnlhbnBhbmR5YSIsImEiOiJja3psM2tlcDA1MXl1Mm9uZmo5bGxpNzdxIn0.TwBKpTTypcD5fWFc8XRyHg';
@@ -78,6 +137,8 @@ Hooks.showMapbox = {
}, },
mounted() { mounted() {
if (this.el.getAttribute("latlon") != "null") {
this.initMap(); this.initMap();
} }
} }
}
+133 -9
View File
@@ -1,5 +1,6 @@
defmodule Friends.Event do defmodule Friends.Event do
use Ecto.Schema use Ecto.Schema
import Ecto.Query
alias Friends.{Relationship, Event} alias Friends.{Relationship, Event}
alias Places.Place alias Places.Place
@@ -16,33 +17,157 @@ defmodule Friends.Event do
belongs_to(:relationship, Relationship) belongs_to(:relationship, Relationship)
end end
def changeset(event, params \\ %{}) do
event
|> Ecto.Changeset.cast(params, [
:name,
:date,
:story,
:defining,
:solo,
:place_id,
:relationship_id
])
|> Ecto.Changeset.validate_required([:name, :date, :solo, :relationship_id],
message: "This field is required."
)
|> validate_date_in_past()
|> validate_unique_event()
end
defp validate_unique_event(changeset) do
changeset
end
defp validate_date_in_past(%{changes: %{date: date}} = changeset) do
today = DateTime.utc_now() |> DateTime.to_date()
case date |> Date.diff(today) do
age when age < 0 ->
changeset |> Ecto.Changeset.add_error(:born, "Please enter a date in the past.")
_ ->
changeset
end
end
defp validate_date_in_past(changeset), do: changeset
def new(params \\ %{}) do
%Event{id: nil}
|> struct(params)
end
def get_by_id(id) do
@repo.one(
from(e in Event,
where: e.id == ^id,
preload: [:relationship, :place]
)
)
end
def get(%{relationship_id: rel, date: date, name: name}) do
@repo.one(
from(e in Event,
where: e.relationship_id == ^rel and e.date == ^date and e.name == ^name,
preload: [:relationship, :place]
)
)
end
def get_or_create(params) do
case get(params) do
nil -> create(params) |> commit()
event -> event
end
end
def create(params \\ %{id: nil}) do
Event.new(params)
|> Event.changeset()
|> Map.put(:action, :insert)
end
def update(params) do
Event.get_by_id(params.id |> String.to_integer())
|> Event.changeset(params)
|> Map.put(:action, :update)
end
def commit(changeset) do
changeset
|> @repo.commit!
|> load_preloads
end
def load_preloads(
%Event{
place: %Ecto.Association.NotLoaded{},
relationship: %Ecto.Association.NotLoaded{}
} = model
) do
model
|> @repo.preload([:place, :relationship])
end
def load_preloads(event), do: event
def create_or_update(params) do
case params.id do
"new" ->
params
|> create()
|> commit()
_number ->
params
|> update()
|> commit()
end
end
def meet(friend1, friend2, opts \\ nil) do def meet(friend1, friend2, opts \\ nil) do
relationship = Relationship.get_or_new(friend1, friend2) relationship = Relationship.get_or_new(friend1, friend2)
opts = opts ++ [if opts[:place] do
opts =
opts ++
[
if opts[:place] do
{ {
:place_id, :place_id,
Places.Place.get_or_new(opts[:place]).id Places.Place.get_or_new(opts[:place]).id
} }
end] end
{:ok, event} = %Event{ ]
{:ok, event} =
%Event{
story: opts[:story], story: opts[:story],
date: opts[:date], date: opts[:date],
place_id: opts[:place_id], place_id: opts[:place_id],
relationship_id: relationship.id relationship_id: relationship.id
} |> @repo.insert }
|> @repo.insert
event event
end end
def person(event) do
Friends.Friend.get_by_id(event.relationship.friend_id)
end
def people(event) do def people(event) do
event.relationship |> Relationship.members if event.solo, do: event.person, else: event.relationship |> Relationship.members()
end end
def age(event) do def age(event) do
years = Date.diff(Date.utc_today, event.date) years =
Date.diff(Date.utc_today(), event.date)
|> div(365) |> div(365)
if years == 0 do if years == 0 do
months = Date.diff(Date.utc_today, event.date) months = Date.diff(Date.utc_today(), event.date) |> rem(365) |> div(12)
|> rem(365) |> div(12)
{months, :month} {months, :month}
else else
{years, :year} {years, :year}
@@ -52,5 +177,4 @@ defmodule Friends.Event do
def print_age(age) do def print_age(age) do
Friends.Helpers.pluralize(age |> elem(0), age |> elem(1)) Friends.Helpers.pluralize(age |> elem(0), age |> elem(1))
end end
end end
+56 -21
View File
@@ -1,7 +1,7 @@
defmodule Friends.Friend do defmodule Friends.Friend do
use Ecto.Schema use Ecto.Schema
alias Friends.{Relationship, Friend} alias Friends.{Relationship, Friend, Event}
import Helpers import Helpers
import Ecto.Query import Ecto.Query
@@ -23,14 +23,16 @@ defmodule Friends.Friend do
:relationships, :relationships,
Friends.Friend, Friends.Friend,
join_through: Relationship, join_through: Relationship,
join_keys: [friend_id: :id, relation_id: :id] join_keys: [friend_id: :id, relation_id: :id],
on_delete: :delete_all
) )
many_to_many( many_to_many(
:reverse_relationships, :reverse_relationships,
Friends.Friend, Friends.Friend,
join_through: Relationship, join_through: Relationship,
join_keys: [relation_id: :id, friend_id: :id] join_keys: [relation_id: :id, friend_id: :id],
on_delete: :delete_all
) )
end end
@@ -46,8 +48,10 @@ defmodule Friends.Friend do
:user_id, :user_id,
:address_id :address_id
]) ])
|> Ecto.Changeset.validate_required([:name, :email, :phone, :born], message: "This field is required.") |> Ecto.Changeset.validate_required([:name],
|> Ecto.Changeset.validate_format(:name, ~r/\w+\ \w+/, message: "Please enter your full name.") message: "This field is required."
)
# |> Ecto.Changeset.validate_format(:name, ~r/\w+\ \w+/, message: "Please enter your full name.")
|> Ecto.Changeset.validate_format( |> Ecto.Changeset.validate_format(
:email, :email,
Regex.compile!("^[a-zA-Z0-9.!#$%&*+/=?^_`{|}~-]+@[a-zA-Z0-9-]+(?:\.[a-zA-Z0-9-]+)*$"), Regex.compile!("^[a-zA-Z0-9.!#$%&*+/=?^_`{|}~-]+@[a-zA-Z0-9-]+(?:\.[a-zA-Z0-9-]+)*$"),
@@ -64,15 +68,20 @@ defmodule Friends.Friend do
end end
defp validate_birthdate(%{changes: %{born: born}} = changeset) do defp validate_birthdate(%{changes: %{born: born}} = changeset) do
today = DateTime.utc_now |> DateTime.to_date today = DateTime.utc_now() |> DateTime.to_date()
case born |> Date.diff(today) |> div(-365) do case born |> Date.diff(today) |> div(-365) do
age when age < 0 -> age when age < 0 ->
changeset |> Ecto.Changeset.add_error(:born, "Please enter a date in the past.") changeset |> Ecto.Changeset.add_error(:born, "Please enter a date in the past.")
age when age > 90 -> age when age > 90 ->
changeset |> Ecto.Changeset.add_error(:born, "Are you sure you're #{age} years old?") changeset |> Ecto.Changeset.add_error(:born, "Are you sure you're #{age} years old?")
_ -> changeset
_ ->
changeset
end end
end end
defp validate_birthdate(changeset), do: changeset defp validate_birthdate(changeset), do: changeset
def all() do def all() do
@@ -81,7 +90,7 @@ defmodule Friends.Friend do
end end
def new(params \\ %{}) do def new(params \\ %{}) do
%Friend{id: :new} %Friend{id: nil}
|> struct(params) |> struct(params)
end end
@@ -113,13 +122,13 @@ defmodule Friends.Friend do
end end
def create(params \\ %{id: nil}) do def create(params \\ %{id: nil}) do
%Friend{} Friend.new()
|> Friend.changeset(%{params | id: nil}) |> Friend.changeset(params)
|> Map.put(:action, :insert) |> Map.put(:action, :insert)
end end
def update(params) do def update(params) do
Friend.get_by_id(params.id |> String.to_integer()) Friend.get_by_id(params.id)
|> Friend.changeset(params) |> Friend.changeset(params)
|> Map.put(:action, :update) |> Map.put(:action, :update)
end end
@@ -149,11 +158,12 @@ defmodule Friends.Friend do
def create_or_update(params) do def create_or_update(params) do
case params.id do case params.id do
"new" -> nil ->
params params
|> create() |> create()
|> generate_slug |> generate_slug()
|> commit() |> commit()
|> create_birth_event()
_number -> _number ->
params params
@@ -162,17 +172,17 @@ defmodule Friends.Friend do
end end
end end
def get_relationships(friend) do def get_relationships(friend, include_self \\ false) do
friend friend
|> relations |> relations(include_self)
|> Enum.map(&relation(friend, &1)) |> Enum.map(&relation(friend, &1))
end end
def get_events(friend) do def get_events(friend) do
friend friend
|> get_relationships |> get_relationships(:all)
|> Enum.map(& &1.events) |> Enum.flat_map(& &1.events)
|> List.flatten() |> Enum.dedup()
end end
def age(friend) do def age(friend) do
@@ -181,7 +191,8 @@ defmodule Friends.Friend do
end end
def can_be_edited_by(friend, user) do def can_be_edited_by(friend, user) do
if user |> is_nil(), do: false, else: friend.id == user.profile.id true
# if user |> is_nil(), do: false, else: friend.id == user.profile.id
end end
def assign_address(%Friend{} = friend, address) do def assign_address(%Friend{} = friend, address) do
@@ -203,8 +214,10 @@ defmodule Friends.Friend do
end end
def assign_user!(%Friend{} = friend) do def assign_user!(%Friend{} = friend) do
%{friend: new_friend, user: _user} = assign_user(friend) case assign_user(friend) do
new_friend %{friend: new_friend, user: _user} -> new_friend
nil -> friend
end
end end
def load_user(%Friends.Friend{user: %Ecto.Association.NotLoaded{}} = model) do def load_user(%Friends.Friend{user: %Ecto.Association.NotLoaded{}} = model) do
@@ -250,4 +263,26 @@ defmodule Friends.Friend do
{nil, nil} {nil, nil}
end end
end end
def create_birth_event(%Friend{id: id} = friend) do
solo_relationship = Relationship.get_or_create(friend, friend)
Event.get_or_create(%{
name: "Born",
date: friend.born,
solo: true,
defining: true,
relationship_id: solo_relationship.id
})
end
def create_relationship(friend1, friend2) do
rel = Relationship.get_or_create(friend1, friend2)
rel |> Relationship.members()
end
def delete_relationship(friend1, friend2) do
Relationship.delete(friend1, friend2)
friend1.id |> Friend.get_by_id()
end
end end
+44
View File
@@ -0,0 +1,44 @@
defmodule Friends.Friend.Search do
alias Friends.Friend
import Ecto.Query
@repo Friends.Repo
def autocomplete(str, friend) do
@repo.all(
from(
f in Friend,
where: ilike(f.name, ^"%#{str}%")
)
)
|> remove_self(friend)
|> remove_existing(friend)
end
defp remove_self(list, friend) do
list
|> Enum.filter(fn result ->
result.id != friend.id
end)
end
defp remove_existing(list, friend) do
existing =
friend
|> Helpers.relations()
|> Enum.map(& &1.id)
list
|> Enum.filter(fn result ->
result.id not in existing
end)
end
def parse_result(friend) do
%{
id: "friend-#{friend.id}",
value: friend.id,
name: friend.name
}
end
end
+10 -1
View File
@@ -26,8 +26,17 @@ defmodule Friends.Places.Place do
end end
def get_or_create(place) do def get_or_create(place) do
case @repo.one(
from(
p in Friends.Places.Place,
where: p.name == ^place.name
)
) do
nil ->
place place
|> Friends.Places.Place.validate() |> Friends.Places.Place.validate()
|> Friends.Repo.insert!() |> @repo.insert!()
found -> found
end
end end
end end
+22 -2
View File
@@ -1,4 +1,7 @@
defmodule Friends.Places.Search do defmodule Friends.Places.Search do
import Ecto.Query
@repo Friends.Repo
def api_key, def api_key,
do: do:
"pk.eyJ1IjoicnlhbnBhbmR5YSIsImEiOiJja3psM2tlcDA1MXl1Mm9uZmo5bGxpNzdxIn0.TwBKpTTypcD5fWFc8XRyHg" "pk.eyJ1IjoicnlhbnBhbmR5YSIsImEiOiJja3psM2tlcDA1MXl1Mm9uZmo5bGxpNzdxIn0.TwBKpTTypcD5fWFc8XRyHg"
@@ -14,6 +17,22 @@ defmodule Friends.Places.Search do
] ]
end end
def known_places(str) do
@repo.all(
from(
p in Friends.Places.Place,
where: ilike(p.name, ^"%#{str}%")
)
)
|> Enum.map(
&%{
"center" => &1.latlon,
"place_name" => &1.name,
"id" => "known.#{&1.id}"
}
)
end
def autocomplete(str, region \\ nil) do def autocomplete(str, region \\ nil) do
viewbox = viewbox =
if region do if region do
@@ -25,8 +44,9 @@ defmodule Friends.Places.Search do
response = HTTPoison.get!(url) response = HTTPoison.get!(url)
results = Poison.decode!(response.body) results = Poison.decode!(response.body)
IO.inspect(results["features"])
results["features"] known_places(str) ++ results["features"]
end end
def parse_features(%{ def parse_features(%{
@@ -36,7 +56,7 @@ defmodule Friends.Places.Search do
}) do }) do
%{ %{
name: name, name: name,
latlon: lonlat |> Enum.reverse() |> Poison.encode!(), value: lonlat |> Enum.reverse() |> Poison.encode!(),
id: id |> String.replace(".", "-") id: id |> String.replace(".", "-")
} }
end end
+101 -24
View File
@@ -4,6 +4,7 @@ defmodule Friends.Relationship do
alias Friends.{Relationship, Friend} alias Friends.{Relationship, Friend}
@repo Friends.Repo @repo Friends.Repo
@default_type 3
schema "relationships" do schema "relationships" do
field(:friend_id, :id) field(:friend_id, :id)
@@ -13,7 +14,7 @@ defmodule Friends.Relationship do
has_many(:events, Friends.Event) has_many(:events, Friends.Event)
end end
@attrs [:friend_id, :relation_id] @attrs [:friend_id, :relation_id, :type]
def types(index) do def types(index) do
types() |> elem(index) types() |> elem(index)
@@ -22,11 +23,12 @@ defmodule Friends.Relationship do
def types do def types do
# Tuple: name of the type, associated color, and what that person "is" to the other # Tuple: name of the type, associated color, and what that person "is" to the other
{ {
{:acquaintances, :info, nil}, {:self, :hidden, :self},
{:acquaintances, :info, :known},
{:family, :primary, :relative}, {:family, :primary, :relative},
{:friends, :secondary, :friend}, {:friends, :secondary, :friend},
{:partners, :info, :partner}, {:partners, :info, :partner},
{:dating, :success, :date}, {:dating, :success, :dating},
{:engaged, :success, :fiancé}, {:engaged, :success, :fiancé},
{:married, :success, :spouse}, {:married, :success, :spouse},
{:divorced, :error, :ex}, {:divorced, :error, :ex},
@@ -35,6 +37,16 @@ defmodule Friends.Relationship do
} }
end end
def type_index(type) do
types()
|> Tuple.to_list()
|> Enum.find_index(
&(&1
|> elem(0)
|> to_string() == type)
)
end
def get_type(rel) do def get_type(rel) do
rel.type |> types |> elem(0) rel.type |> types |> elem(0)
end end
@@ -55,9 +67,27 @@ defmodule Friends.Relationship do
) )
end end
def validate_type(%{changes: %{type: type}} = changeset) do
if type |> is_integer() and type >= 0 and type < types() |> Tuple.to_list() |> length do
changeset
else
changeset |> Ecto.Changeset.add_error(:type, "Invalid type")
end
end
def validate_type(changeset), do: changeset
def update(rel, params \\ %{}) do
rel
|> changeset(params)
|> Map.put(:action, :update)
|> @repo.update!()
end
def changeset(struct, params \\ %{}) do def changeset(struct, params \\ %{}) do
struct struct
|> Ecto.Changeset.cast(params, @attrs) |> Ecto.Changeset.cast(params, @attrs)
|> validate_type
|> Ecto.Changeset.unique_constraint( |> Ecto.Changeset.unique_constraint(
[:friend_id, :relation_id], [:friend_id, :relation_id],
name: :relationships_friend_id_relation_id_index name: :relationships_friend_id_relation_id_index
@@ -68,34 +98,39 @@ defmodule Friends.Relationship do
) )
end end
def all() do def all() do
preloads = [] preloads = []
@repo.all(from(r in Friends.Relationship, preload: ^preloads)) @repo.all(from(r in Friends.Relationship, where: r.type != 0, preload: ^preloads))
end end
def new(friend1, friend2, type \\ 2) do def new(friend1, friend2, type \\ @default_type) do
id1 = friend1.id id1 = friend1.id
id2 = friend2.id id2 = friend2.id
{:ok, relationship} = @repo.insert(
%Relationship{ rel_type = if id1 == id2, do: 0, else: type
relationship =
@repo.insert!(%Relationship{
friend_id: id1, friend_id: id1,
relation_id: id2, relation_id: id2,
type: type type: rel_type
} })
)
relationship relationship
end end
def get(friend1, friend2) do def get(friend1, friend2) do
id1 = friend1.id id1 = friend1.id
id2 = friend2.id id2 = friend2.id
rel = @repo.one(
rel =
@repo.one(
from(r in Relationship, from(r in Relationship,
where: r.friend_id == ^id1 and r.relation_id == ^id2, where: r.friend_id == ^id1 and r.relation_id == ^id2,
preload: [:events] preload: [:events]
) )
) )
if rel == nil do if rel == nil do
@repo.one( @repo.one(
from(r in Relationship, from(r in Relationship,
@@ -108,16 +143,41 @@ defmodule Friends.Relationship do
end end
end end
def get_or_new(a,b) do def get_or_create(a, b) do
case get(a, b) do case get(a, b) do
nil -> new(a, b) nil -> new(a, b)
relationship -> relationship relationship -> relationship
end end
end end
def get_by_id(id) do
case @repo.one(
from(r in Relationship,
where: r.id == ^id
)
) do
nil -> nil
rel -> rel |> load_preloads()
end
end
def delete(rel) do
rel |> Friends.Repo.delete!()
end
def delete(a, b) do
get(a, b) |> delete
end
def change_type(rel, type) do
rel
|> changeset(%{type: type})
|> update()
end
def get_by_slugs([slug1, slug2]) do def get_by_slugs([slug1, slug2]) do
friend1 = slug1 |> Friend.get_by_slug friend1 = slug1 |> Friend.get_by_slug()
friend2 = slug2 |> Friend.get_by_slug friend2 = slug2 |> Friend.get_by_slug()
get(friend1, friend2) get(friend1, friend2)
end end
@@ -130,23 +190,40 @@ defmodule Friends.Relationship do
end end
def age(relationship) do def age(relationship) do
relationship.events case relationship.events do
|> Enum.map(fn(event) -> [] ->
Date.diff(Date.utc_today, event.date) nil
end) |> Enum.sort |> List.last |> div(365)
e ->
e
|> Enum.map(fn event ->
Date.diff(Date.utc_today(), event.date)
end)
|> Enum.sort()
|> List.last()
|> div(365)
end
end end
def load_events(%Relationship{ def load_events(
events: %Ecto.Association.NotLoaded{}} = model) do %Relationship{
events: %Ecto.Association.NotLoaded{}
} = model
) do
model model
|> @repo.preload([:events]) |> @repo.preload([:events])
end end
def load_events(%Relationship{} = r), do: r def load_events(%Relationship{} = r), do: r
def load_preloads(%Relationship{
events: %Ecto.Association.NotLoaded{}} = model) do def load_preloads(
%Relationship{
events: %Ecto.Association.NotLoaded{}
} = model
) do
model model
|> @repo.preload([:events]) |> @repo.preload([:events])
end end
def load_preloads(%Relationship{} = r), do: r
def load_preloads(%Relationship{} = r), do: r
end end
+23 -111
View File
@@ -24,6 +24,10 @@ defmodule FriendsWeb do
import Plug.Conn import Plug.Conn
import FriendsWeb.Gettext import FriendsWeb.Gettext
alias FriendsWeb.Router.Helpers, as: Routes alias FriendsWeb.Router.Helpers, as: Routes
alias Friends.{Friend, Relationship}
alias Friends.Accounts.User
import Helpers
end end
end end
@@ -48,6 +52,15 @@ defmodule FriendsWeb do
use Phoenix.LiveView, use Phoenix.LiveView,
layout: {FriendsWeb.LayoutView, "live.html"} layout: {FriendsWeb.LayoutView, "live.html"}
alias FriendsWeb.Router.Helpers, as: Routes
import Helpers
import Helpers.Names
import FriendsWeb.LiveHelpers
import FriendsWeb.Components
alias Friends.{Friend, Relationship, Places}
unquote(view_helpers()) unquote(view_helpers())
end end
end end
@@ -55,6 +68,14 @@ defmodule FriendsWeb do
def live_component do def live_component do
quote do quote do
use Phoenix.LiveComponent use Phoenix.LiveComponent
import Helpers
import FriendsWeb.LiveHelpers
alias Friends.{Friend, Relationship, Places}
alias FriendsWeb.Components.{Autocomplete, Map, Cards}
alias FriendsWeb.Components
alias FriendsWeb.Router.Helpers, as: Routes
alias FriendsWeb.LiveViews
alias Phoenix.LiveView.JS
unquote(view_helpers()) unquote(view_helpers())
end end
@@ -63,117 +84,8 @@ defmodule FriendsWeb do
def component do def component do
quote do quote do
use Phoenix.Component use Phoenix.Component
import Helpers
unquote(view_helpers()) import FriendsWeb.LiveHelpers
end
end
def router do
quote do
use Phoenix.Router
import Phoenix.Component
import Plug.Conn
import Phoenix.Controller
import Phoenix.LiveView.Router
end
end
def channel do
quote do
use Phoenix.Channel
import FriendsWeb.Gettext
end
end
defp view_helpers do
quote do
# Use all HTML functionality (forms, tags, etc)
use Phoenix.HTML
# Import LiveView and .heex helpers (live_render, live_patch, <.form>, etc)
import Phoenix.LiveView.Helpers
# Import basic rendering functionality (render, render_layout, etc)
import Phoenix.View
import FriendsWeb.ErrorHelpers
import FriendsWeb.Gettext
alias FriendsWeb.Router.Helpers, as: Routes
end
end
@doc """
When used, dispatch to the appropriate controller/view/etc.
"""
defmacro __using__(which) when is_atom(which) do
apply(__MODULE__, which, [])
end
end
defmodule FriendsWeb do
@moduledoc """
The entrypoint for defining your web interface, such
as controllers, views, channels and so on.
This can be used in your application as:
use FriendsWeb, :controller
use FriendsWeb, :view
The definitions below will be executed for every view,
controller, etc, so keep them short and clean, focused
on imports, uses and aliases.
Do NOT define functions inside the quoted expressions
below. Instead, define any helper function in modules
and import those modules here.
"""
def controller do
quote do
use Phoenix.Controller, namespace: FriendsWeb
import Plug.Conn
import FriendsWeb.Gettext
alias FriendsWeb.Router.Helpers, as: Routes
end
end
def view do
quote do
use Phoenix.View,
root: "lib/friends_web/templates",
namespace: FriendsWeb
# Import convenience functions from controllers
import Phoenix.Controller,
only: [get_flash: 1, get_flash: 2, view_module: 1, view_template: 1]
import Phoenix.Component
# Include shared imports and aliases for views
unquote(view_helpers())
end
end
def live_view do
quote do
use Phoenix.LiveView,
layout: {FriendsWeb.LayoutView, "live.html"}
unquote(view_helpers())
end
end
def live_component do
quote do
use Phoenix.LiveComponent
unquote(view_helpers())
end
end
def component do
quote do
use Phoenix.Component
unquote(view_helpers()) unquote(view_helpers())
end end
@@ -1,8 +1,5 @@
defmodule FriendsWeb.FriendsController do defmodule FriendsWeb.FriendsController do
use FriendsWeb, :controller use FriendsWeb, :controller
alias Friends.{Friend, Relationship}
alias Friends.Accounts.User
import Helpers
def index(conn, _params) do def index(conn, _params) do
conn conn
@@ -0,0 +1,9 @@
defmodule FriendsWeb.RelationshipsController do
use FriendsWeb, :controller
def delete(id) do
rel = Relationship.get_by_id(id)
IO.inspect("Deleting #{rel}")
end
end
@@ -4,19 +4,29 @@ defmodule FriendsWeb.Components.Autocomplete do
alias Phoenix.LiveView.JS alias Phoenix.LiveView.JS
def bolden(string, substring) do
String.replace(
string,
substring,
"<b>#{substring}</b>"
)
|> Phoenix.HTML.raw()
end
def search_results(assigns) do def search_results(assigns) do
~H""" ~H"""
<div id="search-results" class="absolute w-full md:w-max bottom-16"> <div id="search-results" class="absolute w-full bottom-16 left-0">
<%= if @search_results do %> <%= if @search_results do %>
<ul tabindex="0" class="dropdown-content menu p-2 shadow bg-base-100 rounded-box w-2/3 overflow-auto"> <ul tabindex="0" class="dropdown-content menu p-0 m-0 shadow bg-base-200 rounded-box overflow-auto">
<%= for r <- @search_results do %> <%= for r <- @search_results do %>
<li class="py-0 my-0"> <li class="p-0 m-0">
<.link id={r[:id]} <.link id={r[:id]}
latlon={r[:latlon]} phx_value={r[:value]}
class="search_result" class="search_result"
onMouseDown={"selectResult('#{r[:latlon]}', '#{r[:name]}')"}> phx-hook="NewRelation"
<%= r[:name] %> onMouseDown={"#{@select_fxn}('#{r[:value]}', '#{r[:name]}')"}>
<%= r[:name] |> bolden(@search_query) %>
</.link> </.link>
</li> </li>
<% end %> <% end %>
@@ -24,29 +34,6 @@ defmodule FriendsWeb.Components.Autocomplete do
<% end %> <% end %>
</div> </div>
<script language="javascript">
window.elSearch_results = document.querySelector("#search-results");
window.elSearchInput = document.querySelector("input[autocomplete=value]");
window.elSearchResultID = document.querySelector("input[autocomplete=id]");
window.hideResults = function () {
window.elSearch_results.classList.add("hidden");
}
window.showResults = function () {
document.getElementById("search-results").classList.remove("hidden");
}
window.selectSearchResult = function (result) {
const elSelectedResult = document.querySelector("#" + result);
const searchResultID = elSelectedResult.attributes.id.value;
const display = elSelectedResult.innerText;
window.elSearchInput.value = display;
window.elSearchResultID.value = searchResultID;
}
</script>
""" """
end end
end end
@@ -0,0 +1,43 @@
defmodule FriendsWeb.Components.Cards do
use FriendsWeb, :live_component
import Helpers.Names
def relationship_card(assigns) do
~H"""
<div id={"relation-#{@relation.id}"} class="relative overflow-visible card card-compact w-96 bg-base-100 shadow-xl" phx-hook="RelationshipCard" relationship-id={@relationship.id}>
<%= if @editable do %>
<!-- The button to open modal -->
<label for={"delete-relationship-#{@relation.id}"} class="btn btn-error absolute top-2 right-2">delete</label>
<% end %>
<figure class="p-0 m-0"><img class="py-0 my-0" src="https://placeimg.com/400/225/people" alt={@relation.id} /></figure>
<div class="card-body">
<div class="flex flex-row justify-between items-center">
<h2 class="card-title py-0 my-0"><.link navigate={Routes.friends_show_path(FriendsWeb.Endpoint, :overview, @relation.slug)} class="no-underline font-bold hover:underline"><%=@relation.name%></.link></h2>
<.link patch={Routes.relationship_show_path(FriendsWeb.Endpoint, :overview, @friend.slug, @relation.slug)}>(details)</.link>
</div>
<Components.relationship_details editable={@editable} relationship={@relationship} />
</div>
</div>
<input type="checkbox" id={"delete-relationship-#{@relation.id}"} class="modal-toggle" />
<div class="modal modal-bottom sm:modal-middle">
<div class="modal-box">
<h3 class="font-bold text-lg mt-0 pt-0">Are you sure you want to delete <%=@friend |> first_name%>'s relationship with <%=@relation |> first_name%>?</h3>
<p class="py-4">Unless these two people really don't know each other, you probably want to change the relationship type, e.g. from "dating" to "ex".</p>
<div class="modal-action">
<label for={"delete-relationship-#{@relation.id}"} class="btn btn-sm btn-ghost">Never mind</label>
<label for={"delete-relationship-#{@relation.id}"} class="btn btn-sm btn-primary" onClick={"javascript:deleteRelation(#{@relationship.id})"}>Accept</label>
</div>
</div>
</div>
"""
end
def confirm_dialog(id) do
JS.show(to: "#warning-relation-#{id}")
end
end
@@ -1,10 +1,5 @@
defmodule FriendsWeb.FriendsLive.Components do defmodule FriendsWeb.Components do
use FriendsWeb, :live_component use FriendsWeb, :live_component
use Phoenix.HTML
import Helpers
alias Friends.Friend
alias FriendsWeb.Components.{Autocomplete, Map}
alias Phoenix.LiveView.JS
def header(assigns) do def header(assigns) do
~H""" ~H"""
@@ -43,200 +38,73 @@ defmodule FriendsWeb.FriendsLive.Components do
""" """
end end
def show_page(:main, assigns), do: show_page(:overview, %{assigns | live_action: :overview}) def relationship_details(assigns) do
def show_page(:overview, assigns) do
~H""" ~H"""
<Map.show address_latlon={@latlon} /> <div class="flex flex-row items-center gap-3">
<ul class="py-4 pl-0 md:text-xl h-1/2"> <div class="dropdown" id={"type-selector-#{@relationship.id}"}>
<li class="flex flex-row mb-8 gap-6"> <label tabindex="0" class={"hover:badge-ghost badge badge-#{@relationship |> Friends.Relationship.get_color} m-1 text-white"}>
<strong class="w-28 text-right">Nickname:</strong> <%= @relationship |> Friends.Relationship.get_relation %>
<div class="">
<%= if is_nil(@friend.nickname) do %> <%= if @editable do %>
<span class="italic">none</span> &nbsp;
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-chevron-down" viewBox="0 0 16 16"> <path fill-rule="evenodd" d="M1.646 4.646a.5.5 0 0 1 .708 0L8 10.293l5.646-5.647a.5.5 0 0 1 .708.708l-6 6a.5.5 0 0 1-.708 0l-6-6a.5.5 0 0 1 0-.708z"/> </svg>
<% end %>
</label>
<Components.relationship_type_selector relationship={@relationship} />
</div>
<span>since</span>
<%= if @relationship |> Relationship.age do %>
<%=
@relationship |> Relationship.age
%>
<% else %> <% else %>
<%= @friend.nickname %> <div class={"tooltip tooltip-#{@relationship |> Relationship.get_color}"} data-tip="add milestone dates on the detail page.">
<% end %> <a class="hover:cursor-help" style="text-decoration-style:dashed">(no date)</a>
</div> </div>
</li>
<li class="flex flex-row mb-8 gap-6">
<strong class="w-28 text-right">Birthday:</strong>
<div class=""><%= @friend.born |> Calendar.strftime("%B %d, %Y") %>
<br class="md:hidden"/>
<span class="font-light">(<%= @friend |> Friend.age %> years old)</span>
</div>
</li>
<li class="flex flex-row mb-8 gap-6">
<strong class="w-28 text-right">Email:</strong>
<div class=""><%= @friend.email %></div>
</li>
<li class="flex flex-row mb-8 gap-6">
<strong class="w-28 text-right">Phone:</strong>
<div class=""><%= @friend.phone %></div>
</li>
<li class="flex flex-row mb-8 gap-6">
<strong class="w-28 text-right">Address:</strong>
<div class=""><%= @address %></div>
<input type="hidden" autocomplete="latlon" value={@latlon}/>
</li>
</ul>
"""
end
def show_page(:timeline, assigns) do
~H"""
<div id="timeline" class="flex md:flex-row flex-col gap-8 p-8">
<%= for event <- @friend |> Friends.Friend.get_events do %>
<ul>
<li>
<%= event.name %>
</li>
</ul>
<% end %>
<%= if @friend |> Friends.Friend.get_events |> Enum.empty? do %>
<div class="italic">None yet.</div>
<% end %> <% end %>
</div> </div>
""" """
end end
def show_page(:relationships, assigns) do def relationship_type_selector(assigns) do
~H""" ~H"""
<div id="relationships" class="flex md:flex-row flex-col gap-8"> <ul tabindex="0" class="absolute dropdown-content menu p-0 shadow bg-base-100 rounded-box w-52 justify-start">
<%= for relation <- @friend |> relations do %> <%= Relationship.types()
<% relationship = relation(@friend, relation) %> |> Tuple.to_list()
<div id={"relation-#{relation.id}"} class="card card-compact w-96 bg-base-100 shadow-xl"> |> Enum.map(fn(tuple) ->
<figure><img src="https://placeimg.com/400/225/people" alt={relation.id} /></figure> type = tuple |> elem(0)
<div class="card-body"> class = tuple |> elem(1) |> to_string
<h3 class="card-title">
<%= relation.name %> selected = (@relationship |> Relationship.get_type) == type
<%= if relationship |> Friends.Relationship.get_relation do %>
<div class={"badge badge-#{relationship |> Friends.Relationship.get_color}"}><%= relationship |> Friends.Relationship.get_relation %></div> if class != "hidden" do
<% end %> if selected do
</h3> "<li class='p-2 m-0 text-sm bg-slate-200 select-none' style='font-weight:normal;'>#{type}</li>"
<p>If a dog chews shoes whose shoes does he choose?</p> else
</div> "<li
</div> class='p-2 m-0 text-sm hover:bg-slate-400 hover:text-black hover:cursor-pointer'
<% end %> style='font-weight:normal;'
<%= if @friend |> relations |> Enum.empty? do %> onClick='#{relationship_type_function(@relationship.id, type)}'
<div class="italic p-4">No relationships on record yet.</div> >
<% end %> #{type}
</div> </li>"
end
end
end) |> Enum.join
|> raw
%>
</ul>
"""
end
def relationship_type_function(id, type) do
"""
relationType("#{id}", "#{type}")
""" """
end end
### ###
def edit_page(:welcome, assigns) do
top = ~H"""
<h1>Welcome!</h1>
<p>Before we get started, we just need some basic info about you:</p>
<%= edit_page(:overview, assigns) %>
"""
end
def edit_page(:overview, assigns) do
~H"""
<.form
for={@changeset}
let={f}
action={@action}
phx_change= "validate"
phx_submit= "save"
>
<%= hidden_input f, :id, value: @friend.id %>
<div class="border-b-4 flex flex-row">
<%= text_input f, :name, placeholder: "Full Name",
class: "m-0 p-0 pb-2 pl-2 input input-bordered border-dashed",
style: "color: var(--tw-prose-headings);
font-weight: 800;
font-size: 2.25em;
min-width: 50%;
text-indent: 4px;
line-height: 1.1111111;",
value: @friend.name,
phx_debounce: :blur %>
<div class="min-w-fit flex place-items-center mx-4"><%= error_tag f, :name %></div>
</div>
<%= if @address_latlon do %>
<Map.show address_latlon={@address_latlon} />
<% end %>
<ul class="py-4 pl-0 h-1/2">
<li class="flex flex-row gap-x-6">
<strong class="md:text-xl w-20 md:w-28 shrink-0 text-right">Email:</strong>
<div class="flex flex-col h-16">
<%= text_input f, :email, class: "input input-primary input-sm md:input-md input-disabled", phx_debounce: "blur", value: @current_user.email %>
<div class="min-w-fit flex place-items-center mr-4"><%= error_tag f, :email %></div>
</div>
</li>
<%= if @live_action != :welcome do %>
<li class="flex flex-row gap-x-6 h-16">
<strong class="md:text-xl w-20 md:w-28 shrink-0 text-right">Nickname:</strong>
<div class=""><%= text_input f, :nickname, class: "input input-primary input-sm md:input-md", phx_debounce: "blur", value: @friend.nickname %></div>
</li>
<% end %>
<li class="flex flex-row gap-x-6">
<strong class="md:text-xl w-20 md:w-28 shrink-0 text-right">Birthday:</strong>
<div class="flex flex-col h-16">
<%= date_input f, :born, class: "input input-primary input-sm md:input-md", phx_debounce: "blur", value: @friend.born %>
<div class="min-w-fit flex place-items-center mr-4"><%= error_tag f, :born %></div>
</div>
</li>
<li class="flex flex-row gap-x-6">
<strong class="md:text-xl w-20 md:w-28 shrink-0 text-right">Phone:</strong>
<div class="flex flex-col h-16">
<%= text_input f, :phone, class: "input input-primary input-sm md:input-md", phx_debounce: "blur", value: @friend.phone |> FriendsWeb.LiveHelpers.display_phone(@changeset) %>
<div class="min-w-fit flex place-items-center mr-4"><%= error_tag f, :phone %></div>
</div>
</li>
<li class="flex flex-row gap-x-6 relative">
<strong class="md:text-xl w-20 md:w-28 shrink-0 text-right">Address:</strong>
<div class="flex flex-col h-16">
<%= text_input f, :address_query, value: @address_query,
class: "input input-primary input-sm md:input-md",
phx_debounce: "500",
phx_change: :address_search,
phx_click: JS.show(to: "#search-results"),
phx_blur: JS.hide(to: "#search-results"),
autocomplete: "name" %>
<%= hidden_input f, :address_latlon, value: @address_latlon,
id: "address-latlon", autocomplete: "latlon",
phx_change: "validate"
%>
</div>
<Autocomplete.search_results search_results={@search_results}/>
</li>
</ul>
<div class="form-control flex flex-row gap-x-4 md:justify-end mb-4 md:w-1/2">
<%= if @live_action != :welcome do %>
<div class="flex-1">
<.link patch={Routes.friends_show_path(FriendsWeb.Endpoint, :overview, @friend.slug)} class="btn btn-block btn-outline">back</.link>
</div>
<% end %>
<div class="flex-1">
<%= if @changeset.valid? do %>
<%= submit "Save", phx_disable_with: "Saving...", class: "btn btn-block" %>
<% else %>
<%= submit "Save", class: "btn btn-block btn-disabled" %>
<% end %>
</div>
<%= if @live_action != :welcome and @current_user.profile.id == @friend.id do %>
<div class="flex-1">
<.link href={Routes.user_settings_path(FriendsWeb.Endpoint, :edit)} class="btn btn-block btn-error">Delete</.link>
</div>
<% end %>
</div>
</.form>
"""
end
def edit_page(:relationships, assigns) do
~H"""
"""
end
def edit_page(:timeline, assigns) do
~H"""
"""
end
end end
+82 -15
View File
@@ -1,11 +1,5 @@
defmodule FriendsWeb.FriendsLive.Edit do defmodule FriendsWeb.FriendsLive.Edit do
use FriendsWeb, :live_view use FriendsWeb, :live_view
import FriendsWeb.LiveHelpers
import FriendsWeb.FriendsLive.Components
import Helpers
import Helpers.Names
alias Friends.{Friend, Places}
# No slug means it's a new profile form # No slug means it's a new profile form
def mount(%{}, token, socket) do def mount(%{}, token, socket) do
@@ -28,25 +22,33 @@ defmodule FriendsWeb.FriendsLive.Edit do
live_action = socket.assigns.live_action || :overview live_action = socket.assigns.live_action || :overview
friend = Friend.get_by_slug(slug) friend = Friend.get_by_slug(slug)
editable = friend |> Friend.can_be_edited_by(socket.assigns[:current_user])
if(live_action) do if(live_action) do
{:ok, {:ok,
socket socket
|> assign(:mode, :edit)
|> assign(:live_action, live_action) |> assign(:live_action, live_action)
|> assign_current_user(token |> Map.get("user_token")) |> assign_current_user(token |> Map.get("user_token"))
|> assign(:friend, friend) |> assign(:friend, friend)
|> assign(:editable, editable)
|> assign(:action, Routes.friends_path(socket, :update)) |> assign(:action, Routes.friends_path(socket, :update))
|> title(friend.name <> " - " <> (live_action |> titlecase)) |> title(friend.name <> " - " <> (live_action |> titlecase))
|> assign(:changeset, %Friend{} |> Friend.changeset()) |> assign(:changeset, %Friend{} |> Friend.changeset())
|> assign(:address_query, nil) |> assign(:search_query, nil)
|> assign(:search_results, nil)} |> assign(:search_results, nil)}
else else
{:ok, socket |> redirect(to: Routes.friends_show_path(socket, :overview, friend.slug))} {:ok, socket |> redirect(to: Routes.friends_show_path(socket, :overview, friend.slug))}
end end
end end
def handle_params(%{"slug" => slug} = _attrs, _url, socket) do # Overview form page
live_action = socket.assigns.live_action || false def handle_params(
%{"slug" => slug} = _attrs,
_url,
%{assigns: %{live_action: :overview}} = socket
) do
live_action = socket.assigns.live_action
friend = Friend.get_by_slug(slug) friend = Friend.get_by_slug(slug)
editable = friend |> Friend.can_be_edited_by(socket.assigns[:current_user]) editable = friend |> Friend.can_be_edited_by(socket.assigns[:current_user])
@@ -55,16 +57,66 @@ defmodule FriendsWeb.FriendsLive.Edit do
{:noreply, {:noreply,
socket socket
|> assign(:mode, :edit)
|> assign_friend(friend) |> assign_friend(friend)
|> assign(:action, Routes.friends_path(socket, :update)) |> assign(:action, Routes.friends_path(socket, :update))
|> assign(:live_action, live_action) |> assign(:live_action, live_action)
|> assign(:address_query, address_query) |> assign(:search_query, address_query)
|> assign(:address_latlon, address_latlon |> Poison.encode!()) |> assign(:address_latlon, address_latlon |> Poison.encode!())
|> assign(:search_results, nil) |> assign(:search_results, nil)
|> title(friend.name <> " - " <> (live_action |> titlecase)) |> title(friend.name <> " - " <> (live_action |> titlecase))
|> assign(:editable, editable)} |> assign(:editable, editable)}
end end
# Add a relationship
def handle_params(
%{"slug" => slug} = _attrs,
_url,
%{assigns: %{live_action: :relationships}} = socket
) do
live_action = socket.assigns.live_action
friend = Friend.get_by_slug(slug)
editable = friend |> Friend.can_be_edited_by(socket.assigns[:current_user])
{:noreply,
socket
|> assign(:mode, :edit)
|> assign_friend(friend)
|> assign(:relationships, friend |> relations)
|> assign(:live_action, live_action)
|> assign(:search_query, nil)
|> assign(:relation_id, nil)
|> assign(:search_results, nil)
|> assign(:editable, editable)
|> title(friend.name <> " - " <> (live_action |> titlecase))
|> push_navigate(
to: Routes.friends_show_path(FriendsWeb.Endpoint, :relationships, friend.slug)
)}
end
# Timeline edit
def handle_params(
%{"slug" => slug} = _attrs,
_url,
%{assigns: %{live_action: :timeline}} = socket
) do
live_action = socket.assigns.live_action
friend = Friend.get_by_slug(slug)
{:noreply,
socket
|> assign(:mode, :edit)
|> assign_friend(friend)
|> assign(:live_action, live_action)
|> assign(:search_query, nil)
|> assign(:relation_id, nil)
|> assign(:search_results, nil)
|> title(friend.name <> " - " <> (live_action |> titlecase))}
end
# Catch-all (aka, new friend form)
def handle_params(_attrs, _token, socket) do def handle_params(_attrs, _token, socket) do
friend = Friend.new() friend = Friend.new()
@@ -77,7 +129,12 @@ defmodule FriendsWeb.FriendsLive.Edit do
|> title("Welcome")} |> title("Welcome")}
end end
def handle_event("validate", %{"friend" => form_params}, %{assigns: %{friend: friend}} = socket) do # Validate overview form page
def handle_event(
"validate",
%{"friend" => form_params},
%{assigns: %{friend: friend, live_action: :overview}} = socket
) do
id = form_params["id"] |> parse_id id = form_params["id"] |> parse_id
name = form_params["name"] name = form_params["name"]
nickname = form_params["nickname"] nickname = form_params["nickname"]
@@ -126,7 +183,7 @@ defmodule FriendsWeb.FriendsLive.Edit do
slug = form_params["slug"] || name |> to_slug slug = form_params["slug"] || name |> to_slug
id = form_params["id"] || :new id = form_params["id"] || :new
address_latlon = form_params["address_latlon"] |> Poison.decode!() address_latlon = form_params["address_latlon"] |> Poison.decode!()
address_query = form_params["address_query"] address_query = form_params["search_query"]
address = %Friends.Places.Place{ address = %Friends.Places.Place{
name: address_query, name: address_query,
@@ -134,8 +191,14 @@ defmodule FriendsWeb.FriendsLive.Edit do
} }
new_address = new_address =
case address.latlon do
nil ->
%{id: nil}
_ ->
address address
|> Friends.Places.Place.get_or_create() |> Friends.Places.Place.get_or_create()
end
new_params = %{ new_params = %{
id: id, id: id,
@@ -161,17 +224,21 @@ defmodule FriendsWeb.FriendsLive.Edit do
} }
end end
def handle_event("address_search", %{"friend" => %{"address_query" => query}}, socket) do def handle_event("address_search", %{"friend" => %{"search_query" => query}}, socket) do
results = Places.Search.autocomplete(query) |> Enum.map(&Places.Search.parse_features/1) results = Places.Search.autocomplete(query) |> Enum.map(&Places.Search.parse_features/1)
if query == "" do if query == "" do
{:noreply, socket |> assign(:search_results, nil)} {:noreply, socket |> assign(:search_results, nil)}
else else
{:noreply, socket |> assign(:search_results, results)} {:noreply,
socket
|> assign(:search_results, results)
|> assign(:search_query, query)
|> assign(:select_fxn, "selectMapResult")}
end end
end end
def handle_event(event, unsigned_params, socket) do def handle_event(_event, _unsigned_params, socket) do
{:noreply, socket} {:noreply, socket}
end end
end end
+1 -1
View File
@@ -1,6 +1,6 @@
<section class="row"> <section class="row">
<article class="column prose"> <article class="column prose">
<%= edit_menu(assigns) %> <%= edit_menu(assigns) %>
<%= edit_page(@live_action, assigns) %> <%= apply(FriendsWeb.LiveViews.Edit, @live_action, [assigns]) %>
</article> </article>
</section> </section>
-8
View File
@@ -1,14 +1,6 @@
defmodule FriendsWeb.FriendsLive.Friend do defmodule FriendsWeb.FriendsLive.Friend do
use FriendsWeb, :live_view use FriendsWeb, :live_view
alias FriendsWeb.FriendsLive.Components
alias FriendsWeb.Router.Helpers, as: Routes
alias Friends.Friend
import FriendsWeb.LiveHelpers
import Helpers
import Helpers.Names
# Initialize variables on first load # Initialize variables on first load
def mount(%{}, token, socket) do def mount(%{}, token, socket) do
{:ok, {:ok,
@@ -0,0 +1,121 @@
defmodule FriendsWeb.LiveViews.Edit do
use FriendsWeb, :live_component
def welcome(assigns) do
top = ~H"""
<h1>Welcome!</h1>
<p>Before we get started, we just need some basic info about you:</p>
<%= overview(assigns) %>
"""
end
def overview(assigns) do
~H"""
<.form
for={@changeset}
let={f}
action={@action}
phx_change= "validate"
phx_submit= "save"
>
<%= hidden_input f, :id, value: @friend.id %>
<div class="border-b-4 flex flex-row">
<%= text_input f, :name, placeholder: "Full Name",
class: "m-0 p-0 pb-2 pl-2 input input-bordered border-dashed",
style: "color: var(--tw-prose-headings);
font-weight: 800;
font-size: 2.25em;
min-width: 50%;
text-indent: 4px;
line-height: 1.1111111;",
value: @friend.name,
phx_debounce: :blur %>
<div class="min-w-fit flex place-items-center mx-4"><%= error_tag f, :name %></div>
</div>
<Map.show address_latlon={@address_latlon} />
<ul class="py-4 pl-0 h-1/2">
<li class="flex flex-row gap-x-6">
<strong class="md:text-xl w-20 md:w-28 shrink-0 text-right">Email:</strong>
<div class="flex flex-col h-16">
<%= text_input f, :email, class: "input input-primary input-sm md:input-md input-disabled", phx_debounce: "blur", value: @friend.email %>
<div class="min-w-fit flex place-items-center mr-4"><%= error_tag f, :email %></div>
</div>
</li>
<%= if @live_action != :welcome do %>
<li class="flex flex-row gap-x-6 h-16">
<strong class="md:text-xl w-20 md:w-28 shrink-0 text-right">Nickname:</strong>
<div class=""><%= text_input f, :nickname, class: "input input-primary input-sm md:input-md", phx_debounce: "blur", value: @friend.nickname %></div>
</li>
<% end %>
<li class="flex flex-row gap-x-6">
<strong class="md:text-xl w-20 md:w-28 shrink-0 text-right">Birthday:</strong>
<div class="flex flex-col h-16">
<%= date_input f, :born, class: "input input-primary input-sm md:input-md", phx_debounce: "blur", value: @friend.born %>
<div class="min-w-fit flex place-items-center mr-4"><%= error_tag f, :born %></div>
</div>
</li>
<li class="flex flex-row gap-x-6">
<strong class="md:text-xl w-20 md:w-28 shrink-0 text-right">Phone:</strong>
<div class="flex flex-col h-16">
<%= text_input f, :phone, class: "input input-primary input-sm md:input-md", phx_debounce: "blur", value: @friend.phone |> FriendsWeb.LiveHelpers.display_phone(@changeset) %>
<div class="min-w-fit flex place-items-center mr-4"><%= error_tag f, :phone %></div>
</div>
</li>
<li class="flex flex-row gap-x-6 relative">
<strong class="md:text-xl w-20 md:w-28 shrink-0 text-right">Address:</strong>
<div class="flex flex-col h-16">
<%= text_input f, :search_query, value: @search_query,
class: "input input-primary input-sm md:input-md",
phx_debounce: "500",
phx_change: :address_search,
phx_click: JS.show(to: "#search-results"),
phx_blur: JS.hide(to: "#search-results"),
autocomplete: "name" %>
<%= hidden_input f, :address_latlon, value: @address_latlon,
id: "address-latlon", autocomplete: "latlon",
phx_change: "validate"
%>
</div>
<Autocomplete.search_results
search_results={@search_results}
search_query={@search_query}
select_fxn="selectMapResult"
/>
</li>
</ul>
<div class="form-control flex flex-row gap-x-4 md:justify-end mb-4 md:w-1/2">
<%= if @live_action != :welcome do %>
<div class="flex-1">
<.link patch={Routes.friends_show_path(FriendsWeb.Endpoint, :overview, @friend.slug)} class="btn btn-block btn-outline">back</.link>
</div>
<% end %>
<div class="flex-1">
<%= if @changeset.valid? do %>
<%= submit "Save", phx_disable_with: "Saving...", class: "btn btn-block" %>
<% else %>
<%= submit "Save", class: "btn btn-block btn-disabled" %>
<% end %>
</div>
<%= if @live_action != :welcome and @current_user.profile.id == @friend.id do %>
<div class="flex-1">
<.link href={Routes.user_settings_path(FriendsWeb.Endpoint, :edit)} class="btn btn-block btn-error">Delete</.link>
</div>
<% end %>
</div>
</.form>
"""
end
def relationships(assigns) do
# Just for illustration; this will never run
# as it's redirected via FriendsWeb.FriendsLive.Edit's
# handle_params function
FriendsWeb.LiveViews.Show.relationships(assigns)
end
def timeline(assigns) do
~H"""
"""
end
end
@@ -0,0 +1,115 @@
defmodule FriendsWeb.LiveViews.Show do
use FriendsWeb, :live_component
alias FriendsWeb.Components.Cards
def main(assigns), do: overview(%{assigns | live_action: :overview})
def overview(assigns) do
~H"""
<%= if @address_latlon != "null" do %>
<Map.show address_latlon={@address_latlon} />
<% end %>
<ul class="py-4 pl-0 md:text-xl h-1/2">
<li class="flex flex-row mb-8 gap-6">
<strong class="w-28 text-right">Nickname:</strong>
<div class="">
<%= if is_nil(@friend.nickname) do %>
<span class="italic">none</span>
<% else %>
<%= @friend.nickname %>
<% end %>
</div>
</li>
<li class="flex flex-row mb-8 gap-6">
<strong class="w-28 text-right">Birthday:</strong>
<div class=""><%= @friend.born |> Calendar.strftime("%B %d, %Y") %>
<br class="md:hidden"/>
<span class="font-light">(<%= @friend |> Friend.age %> years old)</span>
</div>
</li>
<li class="flex flex-row mb-8 gap-6">
<strong class="w-28 text-right">Email:</strong>
<div class=""><%= @friend.email %></div>
</li>
<li class="flex flex-row mb-8 gap-6">
<strong class="w-28 text-right">Phone:</strong>
<div class=""><%= @friend.phone %></div>
</li>
<li class="flex flex-row mb-8 gap-6">
<strong class="w-28 text-right">Address:</strong>
<%= if @address_latlon == "null" do %>
<span class="italic">none</span>
<% else %>
<div class=""><%= @address %></div>
<% end %>
<input type="hidden" autocomplete="latlon" value={@address_latlon}/>
</li>
</ul>
"""
end
def relationships(assigns) do
~H"""
<div id="relationships" class="flex md:flex-row flex-col gap-8 p-8">
<%= for relation <- @relationships do %>
<% relationship = relation(@friend, relation) %>
<Cards.relationship_card
friend={@friend}
relation={relation}
relationship={relationship}
editable={@editable}
mode={@mode}
/>
<% end %>
<%= if @relationships |> Enum.empty? do %>
<div class="italic">No relationships on record yet.</div>
<% end %>
</div>
<%= if @editable do %>
<.form for={@changeset} let={f} class="border-t-4">
<ul class="py-4 pl-0 h-1/2">
<li class="flex flex-row gap-x-6 relative items-center">
<strong class="md:text-xl basis-auto shrink-0 text-right">Type a name:</strong>
<div class="flex flex-col relative">
<%= text_input f, :search_query, value: @search_query,
class: "input input-primary input-sm md:input-md",
phx_debounce: "500",
phx_change: :relation_search,
phx_click: JS.show(to: "#search-results"),
phx_blur: JS.hide(to: "#search-results"),
autocomplete: "name" %>
<%= hidden_input f, :relation_id, value: @relation_id,
id: "relation-id", autocomplete: "relation-id",
phx_change: "validate"
%>
<Autocomplete.search_results
search_results={@search_results}
search_query={@search_query}
select_fxn="selectRelation"
/>
</div>
</li>
</ul>
</.form>
<% end %>
"""
end
def timeline(assigns) do
~H"""
<div id="timeline" class="flex md:flex-row flex-col gap-8 p-8">
<%= for event <- @friend |> Friends.Friend.get_events do %>
<ul>
<li>
<b><%= event.name %></b> |
<span><%= event.date |> format_date %></span>
</li>
</ul>
<% end %>
<%= if @friend |> Friends.Friend.get_events |> Enum.empty? do %>
<div class="italic">No events on record yet.</div>
<% end %>
</div>
"""
end
end
+112 -10
View File
@@ -1,10 +1,7 @@
defmodule FriendsWeb.FriendsLive.Show do defmodule FriendsWeb.FriendsLive.Show do
use FriendsWeb, :live_view use FriendsWeb, :live_view
import FriendsWeb.LiveHelpers
import FriendsWeb.FriendsLive.Components
alias Friends.Friend
def mount(%{"slug" => slug} = _attrs, token, socket) do def mount(%{"slug" => slug}, token, socket) do
live_action = socket.assigns.live_action || false live_action = socket.assigns.live_action || false
friend = Friend.get_by_slug(slug) friend = Friend.get_by_slug(slug)
@@ -15,30 +12,135 @@ defmodule FriendsWeb.FriendsLive.Show do
if(live_action) do if(live_action) do
{:ok, {:ok,
socket socket
|> assign(:mode, :show)
|> assign(:live_action, live_action) |> assign(:live_action, live_action)
|> assign_current_user(token |> Map.get("user_token")) |> assign_current_user(token |> Map.get("user_token"))
|> assign(:friend, friend) |> assign(:friend, friend)
|> assign(:address, address) |> assign(:address, address)
|> assign(:latlon, latlon |> Poison.encode!()) |> assign(:address_latlon, latlon |> Poison.encode!())
|> title(friend.name <> " - " <> (live_action |> titlecase)) |> title(friend.name <> " - " <> (live_action |> titlecase))
|> assign(:changeset, %Friend{} |> Friend.changeset()) |> assign(:changeset, %Friend{} |> Friend.changeset())
|> assign(:action, editable)} |> assign(:editable, editable)
|> assign(:action, :moot)}
else else
{:ok, socket |> redirect(to: Routes.friends_show_path(socket, :overview, friend.slug))} {:ok, socket |> redirect(to: Routes.friends_show_path(socket, :overview, friend.slug))}
end end
end end
def handle_params(%{"slug" => slug} = attrs, _url, socket) do def handle_params(
live_action = socket.assigns.live_action || false %{"slug" => slug},
_url,
%{assigns: %{live_action: :overview}} = socket
) do
friend = Friend.get_by_slug(slug)
live_action = socket.assigns.live_action
{:noreply,
socket
|> assign(:mode, :show)
|> title(friend.name <> " - " <> (live_action |> titlecase))
|> assign_friend(friend)}
end
def handle_params(
%{"slug" => slug} = params,
url,
%{assigns: %{live_action: :relationships}} = socket
) do
live_action = socket.assigns.live_action
friend = Friend.get_by_slug(slug) friend = Friend.get_by_slug(slug)
editable = friend |> Friend.can_be_edited_by(socket.assigns[:current_user]) editable = friend |> Friend.can_be_edited_by(socket.assigns[:current_user])
{:noreply, {:noreply,
socket socket
|> assign(:mode, :edit)
|> assign_friend(friend) |> assign_friend(friend)
|> assign(:relationships, friend |> relations)
|> assign(:live_action, live_action) |> assign(:live_action, live_action)
|> title(friend.name <> " - " <> (live_action |> titlecase)) |> assign(:search_query, nil)
|> assign(:editable, editable)} |> assign(:relation_id, nil)
|> assign(:search_results, nil)
|> assign(:editable, editable)
|> title(friend.name <> " - " <> (live_action |> titlecase))}
end
def handle_event(
"relation_search",
%{"friend" => %{"search_query" => query}},
%{assigns: %{friend: friend}} = socket
) do
if query == "" do
{:noreply, socket |> assign(:search_results, nil)}
else
results =
(Friend.Search.autocomplete(query, friend) ++
[Friend.new(%{name: query})])
|> Enum.map(&Friend.Search.parse_result/1)
{:noreply,
socket
|> assign(:search_results, results)
|> assign(:search_query, query)
|> assign(:select_fxn, "selectRelation")}
end
end
def handle_event(
"phx:select_relation",
%{"id" => rel_id, "name" => rel_name},
%{assigns: %{friend: friend, relationships: relationships}} = socket
) do
new_rel =
case rel_id do
"new" -> Friend.create(%{name: rel_name})
_num -> Friend.get_by_id(rel_id |> String.to_integer())
end
[updated_friend, updated_relation] = friend |> Friend.create_relationship(new_rel)
{:noreply,
socket
|> assign_friend(updated_friend)
|> assign(:relationships, updated_friend |> relations)}
end
def handle_event(
"phx:delete_relation",
%{"id" => rel_id},
%{assigns: %{friend: friend, relationships: relationships}} = socket
) do
rel = Relationship.get_by_id(rel_id)
IO.inspect("Deleting #{rel.id}")
rel |> Relationship.delete()
updated_friend = Friend.get_by_id(friend.id)
{:noreply,
socket
|> assign_friend(updated_friend)
|> assign(:relationships, updated_friend |> relations)}
end
def handle_event(
"phx:relation_type",
%{"rel_id" => rel_id, "type" => type},
%{assigns: %{friend: friend, relationships: relationships}} = socket
) do
rel = Relationship.get_by_id(rel_id)
IO.inspect("Changing type of relationship #{rel.id} to #{type}")
rel |> Relationship.change_type(type |> Relationship.type_index())
updated_friend = Friend.get_by_id(friend.id)
{:noreply,
socket
|> assign_friend(updated_friend)
|> assign(:relationships, updated_friend |> relations)
|> push_navigate(to: Routes.friends_show_path(socket, :relationships, friend.slug))}
end end
end end
+3 -3
View File
@@ -2,11 +2,11 @@
<article class="column prose"> <article class="column prose">
<%= menu(assigns) %> <%= menu(assigns) %>
<%= header(assigns) %> <%= header(assigns) %>
<%= show_page(@live_action, assigns) %> <%= apply(FriendsWeb.LiveViews.Show, @live_action, [assigns]) %>
<%= if @editable do %> <%= if @editable and @mode != :edit do %>
<div class="form-control flex flex-row mb-4"> <div class="form-control flex flex-row mb-4">
<.link navigate={Routes.friends_edit_path(FriendsWeb.Endpoint, :overview, @friend.slug)} class="btn btn-block md:btn-wide text-white">edit</.link> <.link navigate={Routes.friends_edit_path(FriendsWeb.Endpoint, @live_action, @friend.slug)} class="btn btn-block md:btn-wide text-white"><%=@live_action |> get_edit_text%></.link>
</div> </div>
<% end %> <% end %>
</article> </article>
+12
View File
@@ -105,6 +105,11 @@ defmodule FriendsWeb.Router do
live "/:slug/relationships", FriendsLive.Show, :relationships live "/:slug/relationships", FriendsLive.Show, :relationships
end end
scope "/relationship", FriendsWeb do
pipe_through [:browser]
live "/:slug1/:slug2", RelationshipLive.Show, :overview
end
# Edit modes (require being logged in and having a profile) # Edit modes (require being logged in and having a profile)
scope "/edit/", FriendsWeb do scope "/edit/", FriendsWeb do
pipe_through [:browser, :require_authenticated_user, :capture_profile] pipe_through [:browser, :require_authenticated_user, :capture_profile]
@@ -116,4 +121,11 @@ defmodule FriendsWeb.Router do
live "/:slug/timeline", FriendsLive.Edit, :timeline live "/:slug/timeline", FriendsLive.Edit, :timeline
live "/:slug/relationships", FriendsLive.Edit, :relationships live "/:slug/relationships", FriendsLive.Edit, :relationships
end end
# API
scope "/api", FriendsWeb do
pipe_through :api
delete "/relationship/:id", RelationshipsController, :delete
end
end end
@@ -1,5 +1,5 @@
<!DOCTYPE html> <!DOCTYPE html>
<html lang="en" data-theme="corporate"> <!-- pastel --> <html lang="en" data-theme="cupcake"> <!-- pastel -->
<head> <head>
<meta charset="utf-8"/> <meta charset="utf-8"/>
<meta http-equiv="X-UA-Compatible" content="IE=edge"/> <meta http-equiv="X-UA-Compatible" content="IE=edge"/>
+27 -6
View File
@@ -1,6 +1,6 @@
defmodule FriendsWeb.LiveHelpers do defmodule FriendsWeb.LiveHelpers do
use FriendsWeb, :live_component
alias Friends.Friend alias Friends.Friend
use Phoenix.LiveComponent
def titlecase(atom) do def titlecase(atom) do
atom |> to_string |> :string.titlecase() atom |> to_string |> :string.titlecase()
@@ -16,23 +16,44 @@ defmodule FriendsWeb.LiveHelpers do
display_phone(phone) display_phone(phone)
end end
end end
def display_phone(phone) do def display_phone(phone) do
""" """
TODO: Actually implement this TODO: Actually implement this
""" """
has_plus = phone |> String.starts_with?("+") has_plus = phone |> String.starts_with?("+")
case phone |> String.length do
case phone |> String.length() do
10 -> 10 ->
country = "+1 " country = "+1 "
[area, first, sec1, sec2] = phone |> to_charlist |> Enum.chunk_every(3) |> Enum.map(&(&1 |> to_string))
[area, first, sec1, sec2] =
phone |> to_charlist |> Enum.chunk_every(3) |> Enum.map(&(&1 |> to_string))
"#{country}(#{area}) #{first}-#{sec1}#{sec2}" "#{country}(#{area}) #{first}-#{sec1}#{sec2}"
IO.inspect "#{country}(#{area}) #{first}-#{sec1}#{sec2}"
11 when has_plus -> 11 when has_plus ->
phone phone
12 when has_plus ->
phone
end end
end end
def get_edit_text(live_action) do
case live_action do
:timeline -> "add a moment"
:relationships -> "add a relationship"
_ -> "edit"
end
end
def format_date(date) do
date
|> Calendar.strftime("%b %d, %Y")
end
def assign_current_user(socket, user_token) do def assign_current_user(socket, user_token) do
user = user =
case user_token do case user_token do
@@ -62,15 +83,15 @@ defmodule FriendsWeb.LiveHelpers do
def assign_friend(socket, friend) do def assign_friend(socket, friend) do
socket socket
|> assign(:friend, friend) |> assign(:friend, friend)
|> assign(:editable, friend |> Friend.can_be_edited_by(socket.assigns[:current_user]))
|> assign(:changeset, friend |> Friend.changeset()) |> assign(:changeset, friend |> Friend.changeset())
|> assign(:relationships, friend.relationships)
end end
# Same thing, but this time we have a changeset we want to keep # Same thing, but this time we have a changeset we want to keep
def assign_friend(socket, friend, changeset) do def assign_friend(socket, friend, changeset) do
socket socket
|> assign(:friend, friend) |> assign(:friend, friend)
|> assign(:editable, friend |> Friend.can_be_edited_by(socket.assigns[:current_user]))
|> assign(:changeset, changeset) |> assign(:changeset, changeset)
|> assign(:relationships, friend.relationships)
end end
end end
+34 -2
View File
@@ -74,16 +74,48 @@ defmodule Helpers do
birthday(friend) |> Date.diff(Date.utc_today()) birthday(friend) |> Date.diff(Date.utc_today())
end end
def relations(friend) do def relations(friend, include_self \\ false) do
list =
[friend.relationships, friend.reverse_relationships] [friend.relationships, friend.reverse_relationships]
|> List.flatten() |> List.flatten()
|> Enum.dedup()
if include_self, do: list, else: list |> Enum.filter(&(&1.id != friend.id))
end end
def relation(friend, friend2) do def relation(friend, friend2) do
Friends.Relationship.get(friend, friend2) Friends.Relationship.get(friend, friend2)
end end
def events(relationship) do def events(%Friends.Relationship{} = relationship) do
relationship.events relationship.events
end end
def random_string(length) do
:crypto.strong_rand_bytes(length)
|> Base.url_encode64()
|> binary_part(0, length)
|> String.replace(~r/-/, "")
end
def unique_friend_email, do: "user#{System.unique_integer()}@example.com"
def valid_friend_name, do: "#{random_string(5)} Mc#{random_string(5)}"
def valid_friend_phone, do: "+1 (917) 624 2939" |> Helpers.format_phone()
def valid_friend_birthdate, do: ~D"1990-05-05"
def valid_friend_attributes(attrs \\ %{}) do
Enum.into(attrs, %{
id: nil,
name: valid_friend_name(),
phone: valid_friend_phone(),
born: valid_friend_birthdate(),
email: unique_friend_email()
})
end
def friend_fixture(attrs \\ %{}) do
attrs
|> valid_friend_attributes()
|> Friends.Friend.create_or_update()
end
end end