Compare commits

...
23 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
Ryan Pandya 5339fdb2b0 Merge branch 'dev' 2022-11-23 14:22:53 -05:00
Ryan Pandya 2f21ccd9de Validations, welcome flow bug fixes 2022-11-23 13:59:17 -05:00
Ryan Pandya afa816d2c2 Addresses and places in a semi working way! 2022-11-09 19:11:46 -08:00
Ryan Pandya c495373a81 Progress on address edit form 2022-11-08 01:13:40 -08:00
Ryan Pandya ce011e7d87 Remove temp peer info code 2022-11-05 22:18:37 -07:00
Ryan Pandya a86e5c9409 Begin implenting Places using LocationIQ 2022-11-05 21:53:48 -07:00
Ryan Pandya fe0d748a53 Added addresses. Cleaning routes. Flow. 2022-11-05 18:12:02 -07:00
Ryan Pandya bb6d7e1e2d Small liveview changes 2022-11-05 14:04:07 -07:00
Ryan Pandya 684adddc55 Start relationship tests; create events 2022-11-03 18:19:17 -04:00
Ryan Pandya 82e86de969 Happy with friend tests for now 2022-11-03 17:47:08 -04:00
Ryan Pandya f49092a08a More frontend tests 2022-11-03 15:48:43 -04:00
Ryan Pandya fc6ecbe9c8 Finally started writing tests 2022-11-03 02:17:26 -04:00
Ryan Pandya 120cb288d2 Mucking everything up combining users/profiles 2022-10-30 00:47:32 -07:00
Ryan Pandya c67fbf6733 Few small changes 2022-10-30 00:30:21 -07:00
Ryan Pandya 8577c9dddb Merge branch 'dev' of http://git.ryanpandya.com:3003/ryan/friends into dev 2022-10-30 00:26:30 -07:00
Ryan Pandya f2ff002b98 Clean up forms 2022-10-30 00:25:34 -07:00
Ryan Pandya 23e738ddc9 Janky initial implementation of auth and profiles 2022-10-29 17:21:52 -07:00
Ryan Pandya fe8fe1b9f7 Janky initial implementation of auth and profiles 2022-10-29 17:20:14 -07:00
Ryan Pandya b3aff0d742 Merge branch 'dev' of http://git.ryanpandya.com:3003/ryan/friends into dev 2022-10-24 00:34:10 -07:00
Ryan Pandya 6430858f92 swoosh -> postmark 2022-10-24 00:33:44 -07:00
35 changed files with 3467 additions and 496 deletions
+5
View File
@@ -3,6 +3,11 @@
@import "tailwindcss/components";
@import "tailwindcss/utilities";
/* mapbox */
.mapboxgl-control-container{
display:none;
}
/* Override some defaults I don't like */
.input{
border-radius: inherit !important;
+101 -1
View File
@@ -24,9 +24,15 @@ import "phoenix_html"
import { Socket } from "phoenix"
import { LiveSocket } from "phoenix_live_view"
import topbar from "../vendor/topbar"
import mapboxgl from "../vendor/mapbox-gl"
let Hooks = {};
let csrfToken = document.querySelector("meta[name='csrf-token']").getAttribute("content")
let liveSocket = new LiveSocket("/live", Socket, {params: {_csrf_token: csrfToken}})
let liveSocket = new LiveSocket("/live", Socket, {
hooks: Hooks,
params: { _csrf_token: csrfToken }
})
// Show progress bar on live navigation and form submits
topbar.config({ barColors: { 0: "#29d" }, shadowColor: "rgba(0, 0, 0, .3)" })
@@ -42,3 +48,97 @@ liveSocket.connect()
// >> liveSocket.disableLatencySim()
window.liveSocket = liveSocket
window.hideElement = function (id) {
var el = document.getElementById(id);
el.hidden = true;
}
window.selectMapResult = function (latlon, display) {
var name_el = document.querySelector("[autocomplete=name]");
var latlon_el = document.querySelector("[autocomplete=latlon]");
name_el.value = display;
latlon_el.value = latlon;
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 = {
initMap() {
mapboxgl.accessToken = 'pk.eyJ1IjoicnlhbnBhbmR5YSIsImEiOiJja3psM2tlcDA1MXl1Mm9uZmo5bGxpNzdxIn0.TwBKpTTypcD5fWFc8XRyHg';
const latlon = JSON.parse(document.querySelector("[autocomplete=latlon]").value).reverse();
const map = new mapboxgl.Map({
container: 'map',
style: 'mapbox://styles/mapbox/outdoors-v11',
center: latlon,
zoom: 8
});
},
mounted() {
if (this.el.getAttribute("latlon") != "null") {
this.initMap();
}
}
}
+2005
View File
File diff suppressed because it is too large Load Diff
+3 -3
View File
@@ -7,8 +7,8 @@
"acorn": "^7.4.1",
"acorn-node": "^1.8.2",
"acorn-walk": "^7.2.0",
"arg": "^5.0.2",
"anymatch": "^3.1.2",
"arg": "^5.0.2",
"autoprefixer": "^10.4.12",
"binary-extensions": "^2.2.0",
"braces": "^3.0.2",
@@ -44,6 +44,7 @@
"is-glob": "^4.0.3",
"is-number": "^7.0.0",
"lilconfig": "^2.0.6",
"mapbox-gl": "^2.10.0",
"merge2": "^1.4.1",
"micromatch": "^4.0.5",
"minimist": "^1.2.7",
@@ -63,10 +64,10 @@
"postcss-nested": "^6.0.0",
"postcss-selector-parser": "^6.0.10",
"postcss-value-parser": "^4.2.0",
"queue-microtask": "^1.2.3",
"quick-lru": "^5.1.1",
"read-cache": "^1.0.0",
"readdirp": "^3.6.0",
"queue-microtask": "^1.2.3",
"resolve": "^1.22.1",
"reusify": "^1.0.4",
"run-parallel": "^1.2.0",
@@ -80,7 +81,6 @@
"xtend": "^4.0.2",
"yaml": "^1.10.2"
},
"devDependencies": {},
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1"
},
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+133 -9
View File
@@ -1,5 +1,6 @@
defmodule Friends.Event do
use Ecto.Schema
import Ecto.Query
alias Friends.{Relationship, Event}
alias Places.Place
@@ -16,33 +17,157 @@ defmodule Friends.Event do
belongs_to(:relationship, Relationship)
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
relationship = Relationship.get_or_new(friend1, friend2)
opts = opts ++ [if opts[:place] do
opts =
opts ++
[
if opts[:place] do
{
:place_id,
Places.Place.get_or_new(opts[:place]).id
}
end]
{:ok, event} = %Event{
end
]
{:ok, event} =
%Event{
story: opts[:story],
date: opts[:date],
place_id: opts[:place_id],
relationship_id: relationship.id
} |> @repo.insert
}
|> @repo.insert
event
end
def person(event) do
Friends.Friend.get_by_id(event.relationship.friend_id)
end
def people(event) do
event.relationship |> Relationship.members
if event.solo, do: event.person, else: event.relationship |> Relationship.members()
end
def age(event) do
years = Date.diff(Date.utc_today, event.date)
years =
Date.diff(Date.utc_today(), event.date)
|> div(365)
if years == 0 do
months = Date.diff(Date.utc_today, event.date)
|> rem(365) |> div(12)
months = Date.diff(Date.utc_today(), event.date) |> rem(365) |> div(12)
{months, :month}
else
{years, :year}
@@ -52,5 +177,4 @@ defmodule Friends.Event do
def print_age(age) do
Friends.Helpers.pluralize(age |> elem(0), age |> elem(1))
end
end
+111 -27
View File
@@ -1,7 +1,7 @@
defmodule Friends.Friend do
use Ecto.Schema
alias Friends.{Relationship, Friend}
alias Friends.{Relationship, Friend, Event}
import Helpers
import Ecto.Query
@@ -23,51 +23,84 @@ defmodule Friends.Friend do
:relationships,
Friends.Friend,
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(
:reverse_relationships,
Friends.Friend,
join_through: Relationship,
join_keys: [relation_id: :id, friend_id: :id]
join_keys: [relation_id: :id, friend_id: :id],
on_delete: :delete_all
)
end
def changeset(friend, params \\ %{}) do
friend
|> Ecto.Changeset.cast(params, [:name, :born, :nickname, :email, :phone, :slug, :user_id])
|> Ecto.Changeset.validate_required([:name, :email, :phone, :born])
|> Ecto.Changeset.validate_format(:name, ~r/\w+\ \w+/)
|> Ecto.Changeset.cast(params, [
:name,
:born,
:nickname,
:email,
:phone,
:slug,
:user_id,
:address_id
])
|> Ecto.Changeset.validate_required([:name],
message: "This field is required."
)
# |> Ecto.Changeset.validate_format(:name, ~r/\w+\ \w+/, message: "Please enter your full name.")
|> Ecto.Changeset.validate_format(
: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-]+)*$"),
message: "Invalid email format."
)
|> Ecto.Changeset.validate_format(
:phone,
Regex.compile!("^[\+]?[(]?[0-9]{3}[)]?[-\s\.]?[0-9]{3}[-\s\.]?[0-9]{4,6}$")
Regex.compile!("^[\+]?[(]?[0-9]{3}[)]?[-\s\.]?[0-9]{3}[-\s\.]?[0-9]{4,6}$"),
message: "Invalid phone format."
)
|> validate_birthdate()
|> Ecto.Changeset.unique_constraint(:name)
|> Ecto.Changeset.unique_constraint(:email)
end
defp validate_birthdate(%{changes: %{born: born}} = changeset) do
today = DateTime.utc_now() |> DateTime.to_date()
case born |> Date.diff(today) |> div(-365) do
age when age < 0 ->
changeset |> Ecto.Changeset.add_error(:born, "Please enter a date in the past.")
age when age > 90 ->
changeset |> Ecto.Changeset.add_error(:born, "Are you sure you're #{age} years old?")
_ ->
changeset
end
end
defp validate_birthdate(changeset), do: changeset
def all() do
preloads = [:relationships, :reverse_relationships, :user, :address]
@repo.all(from(f in Friends.Friend, preload: ^preloads))
end
def new(params \\ %{}) do
%Friend{id: :new}
%Friend{id: nil}
|> struct(params)
end
def get_by_slug(slug) do
@repo.one(
from(f in Friend,
where: f.slug == ^slug,
preload: [:relationships, :reverse_relationships, :address]
where: f.slug == ^slug
)
)
|> load_preloads()
end
def get_by_id(id) do
@@ -82,29 +115,29 @@ defmodule Friends.Friend do
def get_by_email(email) do
@repo.one(
from(f in Friend,
where: f.email == ^email,
preload: [:relationships, :reverse_relationships]
where: f.email == ^email
)
)
|> load_preloads()
end
def create(params) do
%Friend{}
|> Friend.changeset(%{params | id: nil})
def create(params \\ %{id: nil}) do
Friend.new()
|> Friend.changeset(params)
|> Map.put(:action, :insert)
end
def update(params) do
Friend.get_by_id(params.id |> String.to_integer())
Friend.get_by_id(params.id)
|> Friend.changeset(params)
|> Map.put(:action, :update)
end
def commit(changeset) do
changeset
|> generate_slug
|> @repo.insert!
|> @repo.commit!
|> load_preloads
|> assign_user!()
end
def generate_slug(%Ecto.Changeset{} = changeset) do
@@ -125,10 +158,12 @@ defmodule Friends.Friend do
def create_or_update(params) do
case params.id do
:new ->
nil ->
params
|> create()
|> generate_slug()
|> commit()
|> create_birth_event()
_number ->
params
@@ -137,17 +172,17 @@ defmodule Friends.Friend do
end
end
def get_relationships(friend) do
def get_relationships(friend, include_self \\ false) do
friend
|> relations
|> relations(include_self)
|> Enum.map(&relation(friend, &1))
end
def get_events(friend) do
friend
|> get_relationships
|> Enum.map(& &1.events)
|> List.flatten()
|> get_relationships(:all)
|> Enum.flat_map(& &1.events)
|> Enum.dedup()
end
def age(friend) do
@@ -156,7 +191,16 @@ defmodule Friends.Friend do
end
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
def assign_address(%Friend{} = friend, address) do
friend
|> Friends.Friend.changeset(%{
address_id: address.id
})
|> Friends.Repo.update!()
end
def assign_user(%Friend{} = friend) do
@@ -169,6 +213,13 @@ defmodule Friends.Friend do
end
end
def assign_user!(%Friend{} = friend) do
case assign_user(friend) do
%{friend: new_friend, user: _user} -> new_friend
nil -> friend
end
end
def load_user(%Friends.Friend{user: %Ecto.Association.NotLoaded{}} = model) do
model
|> @repo.preload(:user)
@@ -200,5 +251,38 @@ defmodule Friends.Friend do
|> @repo.preload([:user, :relationships, :reverse_relationships, :address])
end
def load_preloads(%Friends.Friend{} = friend), do: friend
def load_preloads(friend), do: friend
def get_address(%Friend{} = friend) do
if friend.address do
{
friend.address.latlon,
friend.address.name
}
else
{nil, nil}
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
+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
+19 -38
View File
@@ -1,8 +1,11 @@
defmodule Friends.Places.Place do
use Ecto.Schema
import Ecto.Query
import Ecto.Changeset
import Helpers
alias Friends.Places.{Place, Search}
@repo Friends.Repo
schema "places" do
@@ -12,50 +15,28 @@ defmodule Friends.Places.Place do
field(:zoom, :integer)
has_many(:events, Friends.Event)
has_many(:friends, Friends.Friend)
has_many(:friends, Friends.Friend, foreign_key: :address_id)
end
def new(place_name, opts \\ nil) do
{:ok, place} =
@repo.insert(%Friends.Places.Place{
name: place_name,
type: opts[:type]
})
def validate(place, params \\ %{}) do
place
|> cast(params, [:name, :type, :latlon, :zoom])
|> unique_constraint(:name)
|> validate_required(:name)
end
def get(place_name) do
@repo.one(
from(p in Place,
where: p.name == ^place_name,
preload: [:events]
def get_or_create(place) do
case @repo.one(
from(
p in Friends.Places.Place,
where: p.name == ^place.name
)
)
end
def get_or_new(name, opts \\ nil) do
case get(name) do
nil -> new(name, opts)
place -> place
end
end
def get_by_slug(slug) do
name = slug |> from_slug
@repo.one(
from(p in Place,
where: p.name == ^name,
preload: [:events]
)
)
end
def changeset(place, params \\ %{}) do
) do
nil ->
place
|> Ecto.Changeset.cast(params, [:name, :type, :latlon, :zoom])
|> Ecto.Changeset.validate_required([:name])
|> Ecto.Changeset.unique_constraint(:name)
|> Friends.Places.Place.validate()
|> @repo.insert!()
found -> found
end
end
end
+43 -7
View File
@@ -1,5 +1,10 @@
defmodule Friends.Places.Search do
def api_key, do: "pk.7c4a6f4bf061fd4a9af9663132c58af3"
import Ecto.Query
@repo Friends.Repo
def api_key,
do:
"pk.eyJ1IjoicnlhbnBhbmR5YSIsImEiOiJja3psM2tlcDA1MXl1Mm9uZmo5bGxpNzdxIn0.TwBKpTTypcD5fWFc8XRyHg"
def viewbox(region) do
[lat_min, lat_max, lon_min, lon_max] = region |> Enum.map(&String.to_float/1)
@@ -12,16 +17,47 @@ defmodule Friends.Places.Search do
]
end
def query(str, region \\ nil) do
viewbox =
if region do
viewbox(query(region))
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
url = "https://us1.locationiq.com/v1/search?key=#{api_key()}&q=#{str}&format=json#{viewbox}"
def autocomplete(str, region \\ nil) do
viewbox =
if region do
viewbox(autocomplete(region))
end
url =
"https://api.mapbox.com/geocoding/v5/mapbox.places/#{str}.json?proximity=ip&types=place%2Cpostcode%2Caddress&access_token=#{api_key}"
response = HTTPoison.get!(url)
results = Poison.decode!(response.body)
results
IO.inspect(results["features"])
known_places(str) ++ results["features"]
end
def parse_features(%{
"center" => lonlat,
"place_name" => name,
"id" => id
}) do
%{
name: name,
value: lonlat |> Enum.reverse() |> Poison.encode!(),
id: id |> String.replace(".", "-")
}
end
end
+101 -24
View File
@@ -4,6 +4,7 @@ defmodule Friends.Relationship do
alias Friends.{Relationship, Friend}
@repo Friends.Repo
@default_type 3
schema "relationships" do
field(:friend_id, :id)
@@ -13,7 +14,7 @@ defmodule Friends.Relationship do
has_many(:events, Friends.Event)
end
@attrs [:friend_id, :relation_id]
@attrs [:friend_id, :relation_id, :type]
def types(index) do
types() |> elem(index)
@@ -22,11 +23,12 @@ defmodule Friends.Relationship do
def types do
# 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},
{:friends, :secondary, :friend},
{:partners, :info, :partner},
{:dating, :success, :date},
{:dating, :success, :dating},
{:engaged, :success, :fiancé},
{:married, :success, :spouse},
{:divorced, :error, :ex},
@@ -35,6 +37,16 @@ defmodule Friends.Relationship do
}
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
rel.type |> types |> elem(0)
end
@@ -55,9 +67,27 @@ defmodule Friends.Relationship do
)
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
struct
|> Ecto.Changeset.cast(params, @attrs)
|> validate_type
|> Ecto.Changeset.unique_constraint(
[:friend_id, :relation_id],
name: :relationships_friend_id_relation_id_index
@@ -68,34 +98,39 @@ defmodule Friends.Relationship do
)
end
def all() do
preloads = []
@repo.all(from(r in Friends.Relationship, preload: ^preloads))
@repo.all(from(r in Friends.Relationship, where: r.type != 0, preload: ^preloads))
end
def new(friend1, friend2, type \\ 2) do
def new(friend1, friend2, type \\ @default_type) do
id1 = friend1.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,
relation_id: id2,
type: type
}
)
type: rel_type
})
relationship
end
def get(friend1, friend2) do
id1 = friend1.id
id2 = friend2.id
rel = @repo.one(
rel =
@repo.one(
from(r in Relationship,
where: r.friend_id == ^id1 and r.relation_id == ^id2,
preload: [:events]
)
)
if rel == nil do
@repo.one(
from(r in Relationship,
@@ -108,16 +143,41 @@ defmodule Friends.Relationship do
end
end
def get_or_new(a,b) do
def get_or_create(a, b) do
case get(a, b) do
nil -> new(a, b)
relationship -> relationship
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
friend1 = slug1 |> Friend.get_by_slug
friend2 = slug2 |> Friend.get_by_slug
friend1 = slug1 |> Friend.get_by_slug()
friend2 = slug2 |> Friend.get_by_slug()
get(friend1, friend2)
end
@@ -130,23 +190,40 @@ defmodule Friends.Relationship do
end
def age(relationship) do
relationship.events
|> Enum.map(fn(event) ->
Date.diff(Date.utc_today, event.date)
end) |> Enum.sort |> List.last |> div(365)
case relationship.events do
[] ->
nil
e ->
e
|> Enum.map(fn event ->
Date.diff(Date.utc_today(), event.date)
end)
|> Enum.sort()
|> List.last()
|> div(365)
end
end
def load_events(%Relationship{
events: %Ecto.Association.NotLoaded{}} = model) do
def load_events(
%Relationship{
events: %Ecto.Association.NotLoaded{}
} = model
) do
model
|> @repo.preload([:events])
end
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
|> @repo.preload([:events])
end
def load_preloads(%Relationship{} = r), do: r
def load_preloads(%Relationship{} = r), do: r
end
+9
View File
@@ -2,4 +2,13 @@ defmodule Friends.Repo do
use Ecto.Repo,
otp_app: :friends,
adapter: Ecto.Adapters.Postgres
def commit!(changeset) do
IO.inspect(changeset)
case changeset.action do
:update -> update!(changeset)
:insert -> insert!(changeset)
end
end
end
+23 -111
View File
@@ -24,6 +24,10 @@ defmodule FriendsWeb do
import Plug.Conn
import FriendsWeb.Gettext
alias FriendsWeb.Router.Helpers, as: Routes
alias Friends.{Friend, Relationship}
alias Friends.Accounts.User
import Helpers
end
end
@@ -48,6 +52,15 @@ defmodule FriendsWeb do
use Phoenix.LiveView,
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())
end
end
@@ -55,6 +68,14 @@ defmodule FriendsWeb do
def live_component do
quote do
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())
end
@@ -63,117 +84,8 @@ defmodule FriendsWeb do
def component do
quote do
use Phoenix.Component
unquote(view_helpers())
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
import Helpers
import FriendsWeb.LiveHelpers
unquote(view_helpers())
end
@@ -1,8 +1,5 @@
defmodule FriendsWeb.FriendsController do
use FriendsWeb, :controller
alias Friends.{Friend, Relationship}
alias Friends.Accounts.User
import Helpers
def index(conn, _params) do
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
@@ -159,7 +159,7 @@ defmodule FriendsWeb.UserAuth do
nil ->
conn
|> put_flash(:info, "You're logged in but we still need to make you a profile!")
|> redirect(to: Routes.friends_edit_path(conn, :overview, :new))
|> redirect(to: Routes.friends_edit_path(conn, :welcome))
|> halt()
# Or make a new one
@@ -0,0 +1,39 @@
defmodule FriendsWeb.Components.Autocomplete do
use FriendsWeb, :live_component
import Helpers
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
~H"""
<div id="search-results" class="absolute w-full bottom-16 left-0">
<%= if @search_results do %>
<ul tabindex="0" class="dropdown-content menu p-0 m-0 shadow bg-base-200 rounded-box overflow-auto">
<%= for r <- @search_results do %>
<li class="p-0 m-0">
<.link id={r[:id]}
phx_value={r[:value]}
class="search_result"
phx-hook="NewRelation"
onMouseDown={"#{@select_fxn}('#{r[:value]}', '#{r[:name]}')"}>
<%= r[:name] |> bolden(@search_query) %>
</.link>
</li>
<% end %>
</ul>
<% end %>
</div>
"""
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,8 +1,5 @@
defmodule FriendsWeb.FriendsLive.Components do
defmodule FriendsWeb.Components do
use FriendsWeb, :live_component
use Phoenix.HTML
import Helpers
alias Friends.Friend
def header(assigns) do
~H"""
@@ -25,8 +22,11 @@ defmodule FriendsWeb.FriendsLive.Components do
"""
end
@spec edit_menu(any) :: Phoenix.LiveView.Rendered.t()
def edit_menu(assigns) do
~H"""
if assigns.live_action == :welcome,
do: "",
else: ~H"""
<div class="hidden sm:tabs sm:mb-8">
<%= for page <- [:overview, :timeline, :relationships] do %>
<% is_active = if(page == @live_action) do "tab-active" end %>
@@ -38,175 +38,73 @@ defmodule FriendsWeb.FriendsLive.Components do
"""
end
def show_page(:main, assigns), do: show_page(:overview, %{assigns | live_action: :overview})
def show_page(:overview, assigns) do
def relationship_details(assigns) do
~H"""
<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>
<div class="flex flex-row items-center gap-3">
<div class="dropdown" id={"type-selector-#{@relationship.id}"}>
<label tabindex="0" class={"hover:badge-ghost badge badge-#{@relationship |> Friends.Relationship.get_color} m-1 text-white"}>
<%= @relationship |> Friends.Relationship.get_relation %>
<%= if @editable do %>
&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 %>
<%= @friend.nickname %>
<% end %>
<div class={"tooltip tooltip-#{@relationship |> Relationship.get_color}"} data-tip="add milestone dates on the detail page.">
<a class="hover:cursor-help" style="text-decoration-style:dashed">(no date)</a>
</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=""><%= @friend.address %></div>
</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 %>
</div>
"""
end
def show_page(:relationships, assigns) do
def relationship_type_selector(assigns) do
~H"""
<div id="relationships" class="flex md:flex-row flex-col gap-8">
<%= for relation <- @friend |> relations do %>
<% relationship = relation(@friend, relation) %>
<div id={"relation-#{relation.id}"} class="card card-compact w-96 bg-base-100 shadow-xl">
<figure><img src="https://placeimg.com/400/225/people" alt={relation.id} /></figure>
<div class="card-body">
<h3 class="card-title">
<%= relation.name %>
<%= if relationship |> Friends.Relationship.get_relation do %>
<div class={"badge badge-#{relationship |> Friends.Relationship.get_color}"}><%= relationship |> Friends.Relationship.get_relation %></div>
<% end %>
</h3>
<p>If a dog chews shoes whose shoes does he choose?</p>
</div>
</div>
<% end %>
<%= if @friend |> relations |> Enum.empty? do %>
<div class="italic p-4">No relationships on record yet.</div>
<% end %>
</div>
<ul tabindex="0" class="absolute dropdown-content menu p-0 shadow bg-base-100 rounded-box w-52 justify-start">
<%= Relationship.types()
|> Tuple.to_list()
|> Enum.map(fn(tuple) ->
type = tuple |> elem(0)
class = tuple |> elem(1) |> to_string
selected = (@relationship |> Relationship.get_type) == type
if class != "hidden" do
if selected do
"<li class='p-2 m-0 text-sm bg-slate-200 select-none' style='font-weight:normal;'>#{type}</li>"
else
"<li
class='p-2 m-0 text-sm hover:bg-slate-400 hover:text-black hover:cursor-pointer'
style='font-weight:normal;'
onClick='#{relationship_type_function(@relationship.id, type)}'
>
#{type}
</li>"
end
end
end) |> Enum.join
|> raw
%>
</ul>
"""
end
def relationship_type_function(id, type) do
"""
relationType("#{id}", "#{type}")
"""
end
###
def edit_page(:overview, assigns) do
~H"""
<%= @peer_data.address |> Tuple.to_list |> Enum.join(".") %>
<.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>
<ul class="py-4 pl-0 h-1/2">
<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>
<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">Email:</strong>
<div class="flex flex-col h-16">
<%= text_input f, :email, class: "input input-primary input-sm md:input-md", phx_debounce: "blur", value: @friend.email %>
<div class="min-w-fit flex place-items-center mr-4"><%= error_tag f, :email %></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 %>
<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">
<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, class: "input input-primary input-sm md:input-md", phx_throttle: "500", value: @address_query %>
<%= hidden_input f, :address_id, value: 0 %>
</div>
</li>
</ul>
<div class="form-control flex flex-row gap-x-4 md:justify-end mb-4 md:w-1/2">
<div class="flex-1">
<.link patch={Routes.friends_show_path(FriendsWeb.Endpoint, :overview, @friend.slug)} class="btn btn-block btn-outline">back</.link>
</div>
<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 != :new do %>
<div class="flex-1">
<button phx-click="delete" phx-value-friend_id={@friend.id} class="btn btn-block btn-error">Delete</button>
</div>
<% end %>
</div>
</.form>
"""
end
def edit_page(:relationships, assigns) do
~H"""
"""
end
def edit_page(:timeline, assigns) do
~H"""
"""
end
end
@@ -0,0 +1,9 @@
defmodule FriendsWeb.Components.Map do
use FriendsWeb, :live_component
def show(assigns) do
~H"""
<div id="map" phx-hook={"showMapbox"} latlon={@address_latlon}></div>
"""
end
end
+150 -32
View File
@@ -1,58 +1,148 @@
defmodule FriendsWeb.FriendsLive.Edit do
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
def mount(%{}, token, socket) do
friend = Friend.new()
live_action = socket.assigns.live_action || :overview
def mount(%{"slug" => slug} = _attrs, token, socket) do
live_action = socket.assigns.live_action || false
friend = Friend.get_by_slug(slug)
editable = friend |> Friend.can_be_edited_by(socket.assigns[:current_user])
# address_viewbox = Places.Search.query()
if(live_action) do
{:ok,
socket
|> assign(:live_action, live_action)
|> assign_current_user(token |> Map.get("user_token"))
|> assign(:friend, friend)
|> assign(:address_latlon, nil)
|> assign(:search_results, nil)
|> title("Welcome")
|> assign(:changeset, %Friend{} |> Friend.changeset())}
end
# Has a slug means it's an edit profile form
def mount(%{"slug" => slug} = _attrs, token, socket) do
live_action = socket.assigns.live_action || :overview
friend = Friend.get_by_slug(slug)
editable = friend |> Friend.can_be_edited_by(socket.assigns[:current_user])
if(live_action) do
{:ok,
socket
|> assign(:mode, :edit)
|> assign(:live_action, live_action)
|> assign_current_user(token |> Map.get("user_token"))
|> assign(:friend, friend)
|> assign(:editable, editable)
|> assign(:action, Routes.friends_path(socket, :update))
|> title(friend.name <> " - " <> (live_action |> titlecase))
|> assign(:changeset, %Friend{} |> Friend.changeset())
|> assign(:action, editable)
|> assign(:address_query, nil)
|> assign(:peer_data, get_connect_info(socket, :peer_data))}
|> assign(:search_query, nil)
|> assign(:search_results, nil)}
else
{:ok, socket |> redirect(to: Routes.friends_show_path(socket, :overview, friend.slug))}
end
end
def handle_params(%{"slug" => slug} = attrs, _url, socket) do
live_action = socket.assigns.live_action || false
# Overview form page
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)
editable = friend |> Friend.can_be_edited_by(socket.assigns[:current_user])
{address_latlon, address_query} = friend |> Friend.get_address()
{:noreply,
socket
|> assign(:mode, :edit)
|> assign_friend(friend)
|> assign(:action, Routes.friends_path(socket, :update))
|> assign(:live_action, live_action)
|> assign(:search_query, address_query)
|> assign(:address_latlon, address_latlon |> Poison.encode!())
|> assign(:search_results, nil)
|> title(friend.name <> " - " <> (live_action |> titlecase))
|> assign(:editable, editable)}
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))
|> assign(:editable, editable)}
|> push_navigate(
to: Routes.friends_show_path(FriendsWeb.Endpoint, :relationships, friend.slug)
)}
end
def handle_event("validate", %{"friend" => form_params}, %{assigns: %{friend: friend}} = socket) do
id = form_params["id"]
# 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
friend = Friend.new()
{:noreply,
socket
|> assign_friend(friend)
|> assign(:live_action, socket.assigns.live_action)
|> assign(:address_query, nil)
|> assign(:action, Routes.friends_path(socket, :update))
|> title("Welcome")}
end
# 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
name = form_params["name"]
nickname = form_params["nickname"]
born = form_params["born"]
email = form_params["email"]
phone = form_params["phone"] |> format_phone
address_query = form_params["address_query"]
address_latlon = form_params["address_latlon"]
new_params = %{
id: id,
@@ -75,6 +165,7 @@ defmodule FriendsWeb.FriendsLive.Edit do
|> assign(:changeset, changeset)
|> assign_friend(friend |> struct(new_params), changeset)
|> assign(:address_query, address_query)
|> assign(:address_latlon, address_latlon)
}
end
@@ -82,23 +173,42 @@ defmodule FriendsWeb.FriendsLive.Edit do
def handle_event(
"save",
%{"friend" => form_params},
%{assigns: %{changeset: changeset}} = socket
%{assigns: %{changeset: _changeset}} = socket
) do
name = form_params["name"]
nickname = form_params["nickname"]
born = form_params["born"]
email = form_params["email"]
phone = form_params["phone"] |> format_phone
id = form_params["id"]
slug = form_params["slug"] || name |> to_slug
id = form_params["id"] || :new
address_latlon = form_params["address_latlon"] |> Poison.decode!()
address_query = form_params["search_query"]
address = %Friends.Places.Place{
name: address_query,
latlon: address_latlon
}
new_address =
case address.latlon do
nil ->
%{id: nil}
_ ->
address
|> Friends.Places.Place.get_or_create()
end
new_params = %{
id: id,
name: name,
nickname: nickname,
slug: name |> to_slug,
slug: slug,
born: born,
phone: phone,
email: email
email: email,
address_id: new_address.id
}
updated_friend = Friend.create_or_update(new_params)
@@ -110,17 +220,25 @@ defmodule FriendsWeb.FriendsLive.Edit do
|> put_flash(:info, "Saved #{updated_friend |> first_name}!")
|> assign(:new_friend, new_changeset)
|> assign(:friend, updated_friend)
|> push_patch(to: "/friend/#{updated_friend.slug}")
|> push_navigate(to: "/friend/#{updated_friend.slug}")
}
end
# Handle deleting a friend
def handle_event("delete", %{"friend_id" => friend_id}, socket) do
friend = Friend.get_by_id(friend_id)
def handle_event("address_search", %{"friend" => %{"search_query" => query}}, socket) do
results = Places.Search.autocomplete(query) |> Enum.map(&Places.Search.parse_features/1)
if query == "" do
{:noreply, socket |> assign(:search_results, nil)}
else
{:noreply,
socket
|> put_flash(:error, "Deleted '#{friend.name}'.")
|> push_navigate(to: "/")}
|> assign(:search_results, results)
|> assign(:search_query, query)
|> assign(:select_fxn, "selectMapResult")}
end
end
def handle_event(_event, _unsigned_params, socket) do
{:noreply, socket}
end
end
+1 -1
View File
@@ -1,6 +1,6 @@
<section class="row">
<article class="column prose">
<%= edit_menu(assigns) %>
<%= edit_page(@live_action, assigns) %>
<%= apply(FriendsWeb.LiveViews.Edit, @live_action, [assigns]) %>
</article>
</section>
-8
View File
@@ -1,14 +1,6 @@
defmodule FriendsWeb.FriendsLive.Friend do
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
def mount(%{}, token, socket) do
{: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
+115 -9
View File
@@ -1,40 +1,146 @@
defmodule FriendsWeb.FriendsLive.Show do
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
friend = Friend.get_by_slug(slug)
editable = friend |> Friend.can_be_edited_by(socket.assigns[:current_user])
{latlon, address} = friend |> Friend.get_address()
if(live_action) do
{:ok,
socket
|> assign(:mode, :show)
|> assign(:live_action, live_action)
|> assign_current_user(token |> Map.get("user_token"))
|> assign(:friend, friend)
|> assign(:address, address)
|> assign(:address_latlon, latlon |> Poison.encode!())
|> title(friend.name <> " - " <> (live_action |> titlecase))
|> assign(:changeset, %Friend{} |> Friend.changeset())
|> assign(:action, editable)}
|> assign(:editable, editable)
|> assign(:action, :moot)}
else
{:ok, socket |> redirect(to: Routes.friends_show_path(socket, :overview, friend.slug))}
end
end
def handle_params(%{"slug" => slug} = attrs, _url, socket) do
live_action = socket.assigns.live_action || false
def handle_params(
%{"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)
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)
|> title(friend.name <> " - " <> (live_action |> titlecase))
|> assign(:editable, editable)}
|> assign(:search_query, nil)
|> 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
+3 -3
View File
@@ -2,11 +2,11 @@
<article class="column prose">
<%= menu(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">
<.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>
<% end %>
</article>
+17 -2
View File
@@ -73,6 +73,8 @@ defmodule FriendsWeb.Router do
post "/confirm", UserConfirmationController, :create
get "/confirm/:token", UserConfirmationController, :edit
post "/confirm/:token", UserConfirmationController, :update
live "/welcome", FriendsLive.Edit, :welcome
end
# Routes that require the user be authenticated:
@@ -103,14 +105,27 @@ defmodule FriendsWeb.Router do
live "/:slug/relationships", FriendsLive.Show, :relationships
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)
scope "/friend/update/", FriendsWeb do
scope "/edit/", FriendsWeb do
pipe_through [:browser, :require_authenticated_user, :capture_profile]
post "/", FriendsController, :update
live "/:slug", FriendsLive.Edit
live "/:slug/overview", FriendsLive.Edit, :overview
live "/:slug/timeline", FriendsLive.Edit, :timeline
live "/:slug/relationships", FriendsLive.Edit, :relationships
end
post "/:slug/update", FriendsController, :update
# API
scope "/api", FriendsWeb do
pipe_through :api
delete "/relationship/:id", RelationshipsController, :delete
end
end
@@ -1,12 +1,16 @@
<ul class="p-2 shadow menu menu-compact dropdown-content bg-base-100 text-neutral rounded-box w-52 flex flex-col gap-4">
<%= if @current_user do %>
<li class="p-2 pb-4 border-b-2"><%= @current_user.email %></li>
<label tabindex="0" class="btn btn-ghost btn-circle avatar">
<div class="w-10 rounded-full">
<img src="https://placeimg.com/80/80/people" />
</div>
</label>
<ul class="mt-3 p-2 shadow menu menu-compact dropdown-content bg-base-100 text-neutral rounded-box w-52">
<li><%= @current_user.email %></li>
<li><%= link "Settings", to: Routes.user_settings_path(@conn, :edit) %></li>
<li><%= link "Log out", to: Routes.user_session_path(@conn, :delete), method: :delete %></li>
</ul>
<% else %>
<%= link "Log in", to: Routes.user_session_path(@conn, :new), class: "btn" %>
<%= link "Register", to: Routes.user_registration_path(@conn, :new), class: "btn btn-primary" %>
<%= link "Log in", to: Routes.user_session_path(@conn, :new), class: "btn" %>
<%= link "Register", to: Routes.user_registration_path(@conn, :new), class: "btn btn-primary" %>
<% end %>
@@ -1,5 +1,5 @@
<!DOCTYPE html>
<html lang="en" data-theme="corporate"> <!-- pastel -->
<html lang="en" data-theme="cupcake"> <!-- pastel -->
<head>
<meta charset="utf-8"/>
<meta http-equiv="X-UA-Compatible" content="IE=edge"/>
@@ -10,10 +10,8 @@ defmodule FriendsWeb.ErrorHelpers do
"""
def error_tag(form, field) do
Enum.map(Keyword.get_values(form.errors, field), fn error ->
content_tag(:span, translate_error(error),
class: "invalid-feedback",
phx_feedback_for: input_name(form, field)
)
field = field |> Atom.to_string() |> String.capitalize()
content_tag(:span, "#{translate_error(error)}", class: "block mt-1 text-sm text-red-700")
end)
end
+51 -3
View File
@@ -1,11 +1,59 @@
defmodule FriendsWeb.LiveHelpers do
use FriendsWeb, :live_component
alias Friends.Friend
use Phoenix.LiveComponent
def titlecase(atom) do
atom |> to_string |> :string.titlecase()
end
def parse_id("new"), do: "new"
def parse_id(int), do: int |> String.to_integer()
def display_phone(phone, changeset) do
if changeset.errors[:phone] do
phone
else
display_phone(phone)
end
end
def display_phone(phone) do
"""
TODO: Actually implement this
"""
has_plus = phone |> String.starts_with?("+")
case phone |> String.length() do
10 ->
country = "+1 "
[area, first, sec1, sec2] =
phone |> to_charlist |> Enum.chunk_every(3) |> Enum.map(&(&1 |> to_string))
"#{country}(#{area}) #{first}-#{sec1}#{sec2}"
11 when has_plus ->
phone
12 when has_plus ->
phone
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
user =
case user_token do
@@ -35,15 +83,15 @@ defmodule FriendsWeb.LiveHelpers do
def assign_friend(socket, friend) do
socket
|> assign(:friend, friend)
|> assign(:editable, friend |> Friend.can_be_edited_by(socket.assigns[:current_user]))
|> assign(:changeset, friend |> Friend.changeset())
|> assign(:relationships, friend.relationships)
end
# Same thing, but this time we have a changeset we want to keep
def assign_friend(socket, friend, changeset) do
socket
|> assign(:friend, friend)
|> assign(:editable, friend |> Friend.can_be_edited_by(socket.assigns[:current_user]))
|> assign(:changeset, changeset)
|> assign(:relationships, friend.relationships)
end
end
+34 -2
View File
@@ -74,16 +74,48 @@ defmodule Helpers do
birthday(friend) |> Date.diff(Date.utc_today())
end
def relations(friend) do
def relations(friend, include_self \\ false) do
list =
[friend.relationships, friend.reverse_relationships]
|> List.flatten()
|> Enum.dedup()
if include_self, do: list, else: list |> Enum.filter(&(&1.id != friend.id))
end
def relation(friend, friend2) do
Friends.Relationship.get(friend, friend2)
end
def events(relationship) do
def events(%Friends.Relationship{} = relationship) do
relationship.events
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
@@ -9,9 +9,14 @@ defmodule FriendsWeb.FriendsLiveTest do
} = friend_fixture(%{email: user_fixture().email}) |> Friends.Friend.assign_user()
end
describe "GET '/friends/:slug'" do
describe "GET '/friend/:slug'" do
test "redirects if live_action not specified", %{conn: conn, friend: friend} do
conn = conn |> get("/friend/#{friend.slug}/")
assert redirected_to(conn) == Routes.friends_show_path(conn, :overview, friend.slug)
end
test "shows the friend overview", %{conn: conn, friend: friend} do
conn = conn |> get("/friends/#{friend.slug}")
conn = conn |> get("/friend/#{friend.slug}/overview")
assert html_response(conn, 200) =~ friend.name
end
end