Compare commits

..
36 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 5ca34c2b6f Begin implenting Places using LocationIQ 2022-11-05 22:06:04 -07:00
Ryan Pandya bbf526d6a8 Added addresses. Cleaning routes. Flow. 2022-11-05 22:06:04 -07:00
Ryan Pandya c72c09b1b2 Small liveview changes 2022-11-05 22:06:04 -07:00
Ryan Pandya 90c3b06cfc Start relationship tests; create events 2022-11-05 22:06:04 -07:00
Ryan Pandya d331da9e17 Happy with friend tests for now 2022-11-05 22:06:04 -07:00
Ryan Pandya aa0c43aeb1 More frontend tests 2022-11-05 22:06:04 -07:00
Ryan Pandya 075ae78df2 Finally started writing tests 2022-11-05 22:06:04 -07:00
Ryan Pandya 1a4a7f0d0c Mucking everything up combining users/profiles 2022-11-05 22:06:04 -07:00
Ryan Pandya f90c8621b9 Few small changes 2022-11-05 22:06:04 -07:00
Ryan Pandya 2456c6d14b Janky initial implementation of auth and profiles 2022-11-05 22:06:04 -07:00
Ryan Pandya eccaca068d Janky initial implementation of auth and profiles 2022-11-05 22:03:46 -07:00
Ryan Pandya 0c2f304b2f Clean up forms 2022-11-05 22:01:58 -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 90007d40e7 Janky initial implementation of auth and profiles 2022-10-29 17:22:34 -07:00
Ryan Pandya 2284a437fa Janky initial implementation of auth and profiles 2022-10-29 17:22:34 -07:00
Ryan Pandya 117ccf5e16 swoosh -> postmark 2022-10-29 17:22: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
74 changed files with 4753 additions and 605 deletions
+5
View File
@@ -3,6 +3,11 @@
@import "tailwindcss/components"; @import "tailwindcss/components";
@import "tailwindcss/utilities"; @import "tailwindcss/utilities";
/* mapbox */
.mapboxgl-control-container{
display:none;
}
/* Override some defaults I don't like */ /* Override some defaults I don't like */
.input{ .input{
border-radius: inherit !important; border-radius: inherit !important;
+101 -1
View File
@@ -24,9 +24,15 @@ import "phoenix_html"
import { Socket } from "phoenix" import { Socket } from "phoenix"
import { LiveSocket } from "phoenix_live_view" import { LiveSocket } from "phoenix_live_view"
import topbar from "../vendor/topbar" import topbar from "../vendor/topbar"
import mapboxgl from "../vendor/mapbox-gl"
let Hooks = {};
let csrfToken = document.querySelector("meta[name='csrf-token']").getAttribute("content") 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 // Show progress bar on live navigation and form submits
topbar.config({ barColors: { 0: "#29d" }, shadowColor: "rgba(0, 0, 0, .3)" }) topbar.config({ barColors: { 0: "#29d" }, shadowColor: "rgba(0, 0, 0, .3)" })
@@ -42,3 +48,97 @@ liveSocket.connect()
// >> liveSocket.disableLatencySim() // >> liveSocket.disableLatencySim()
window.liveSocket = liveSocket 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": "^7.4.1",
"acorn-node": "^1.8.2", "acorn-node": "^1.8.2",
"acorn-walk": "^7.2.0", "acorn-walk": "^7.2.0",
"arg": "^5.0.2",
"anymatch": "^3.1.2", "anymatch": "^3.1.2",
"arg": "^5.0.2",
"autoprefixer": "^10.4.12", "autoprefixer": "^10.4.12",
"binary-extensions": "^2.2.0", "binary-extensions": "^2.2.0",
"braces": "^3.0.2", "braces": "^3.0.2",
@@ -44,6 +44,7 @@
"is-glob": "^4.0.3", "is-glob": "^4.0.3",
"is-number": "^7.0.0", "is-number": "^7.0.0",
"lilconfig": "^2.0.6", "lilconfig": "^2.0.6",
"mapbox-gl": "^2.10.0",
"merge2": "^1.4.1", "merge2": "^1.4.1",
"micromatch": "^4.0.5", "micromatch": "^4.0.5",
"minimist": "^1.2.7", "minimist": "^1.2.7",
@@ -63,10 +64,10 @@
"postcss-nested": "^6.0.0", "postcss-nested": "^6.0.0",
"postcss-selector-parser": "^6.0.10", "postcss-selector-parser": "^6.0.10",
"postcss-value-parser": "^4.2.0", "postcss-value-parser": "^4.2.0",
"queue-microtask": "^1.2.3",
"quick-lru": "^5.1.1", "quick-lru": "^5.1.1",
"read-cache": "^1.0.0", "read-cache": "^1.0.0",
"readdirp": "^3.6.0", "readdirp": "^3.6.0",
"queue-microtask": "^1.2.3",
"resolve": "^1.22.1", "resolve": "^1.22.1",
"reusify": "^1.0.4", "reusify": "^1.0.4",
"run-parallel": "^1.2.0", "run-parallel": "^1.2.0",
@@ -80,7 +81,6 @@
"xtend": "^4.0.2", "xtend": "^4.0.2",
"yaml": "^1.10.2" "yaml": "^1.10.2"
}, },
"devDependencies": {},
"scripts": { "scripts": {
"test": "echo \"Error: no test specified\" && exit 1" "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
+4 -2
View File
@@ -4,9 +4,11 @@ import Config
config :friends, Friends.Repo, config :friends, Friends.Repo,
username: "postgres", username: "postgres",
password: "pleasework", password: "pleasework",
hostname: "10.0.0.22", # hostname: "10.0.0.22",
hostname: "localhost",
database: "friends_crm", database: "friends_crm",
port: "2345", #port: "2345",
port: "5432",
stacktrace: true, stacktrace: true,
show_sensitive_data_on_connection_error: true, show_sensitive_data_on_connection_error: true,
pool_size: 10 pool_size: 10
+4 -3
View File
@@ -12,9 +12,10 @@ config :bcrypt_elixir, :log_rounds, 1
config :friends, Friends.Repo, config :friends, Friends.Repo,
username: "postgres", username: "postgres",
password: "pleasework", password: "pleasework",
hostname: "10.0.0.22", #hostname: "10.0.0.22",
port: "2345", #port: "2345",
database: "friends_test#{System.get_env("MIX_TEST_PARTITION")}", hostname: "localhost", port: "5432",
database: "friends_test",
pool: Ecto.Adapters.SQL.Sandbox, pool: Ecto.Adapters.SQL.Sandbox,
pool_size: 10 pool_size: 10
+33
View File
@@ -76,10 +76,43 @@ defmodule Friends.Accounts do
""" """
def register_user(attrs) do def register_user(attrs) do
%User{} %User{}
|> Repo.preload(:profile)
|> User.registration_changeset(attrs) |> User.registration_changeset(attrs)
|> Repo.insert() |> Repo.insert()
end end
@doc """
Gets or links a user profile if it exists.
## Examples
iex> assign_profile(%User{email: some_friend@exists.com})
{:ok, friend}
iex> assign_profile(%User{email: another_friend@exists.com})
iex> assign_profile
"""
def assign_profile(%User{} = user) do
case user.email |> Friends.Friend.get_by_email do
nil ->
nil
friend ->
new_friend = friend
|> Friends.Friend.changeset(%{
user_id: user.id})
|> Friends.Repo.update!()
|> Friends.Friend.load_user
%{
friend: new_friend,
user: new_friend.user |> Friends.Accounts.User.load_profile
}
end
end
@doc """ @doc """
Returns an `%Ecto.Changeset{}` for tracking user changes. Returns an `%Ecto.Changeset{}` for tracking user changes.
+14 -1
View File
@@ -2,6 +2,8 @@ defmodule Friends.Accounts.User do
use Ecto.Schema use Ecto.Schema
import Ecto.Changeset import Ecto.Changeset
@repo Friends.Repo
schema "users" do schema "users" do
field :email, :string field :email, :string
field :password, :string, virtual: true, redact: true field :password, :string, virtual: true, redact: true
@@ -9,6 +11,17 @@ defmodule Friends.Accounts.User do
field :confirmed_at, :naive_datetime field :confirmed_at, :naive_datetime
timestamps() timestamps()
has_one :profile, Friends.Friend
end
def load_profile(%Friends.Accounts.User{profile: %Ecto.Association.NotLoaded{}} = model) do
model
|> @repo.preload(:profile)
end
def load_profile(nil) do
%{profile: nil}
end end
@doc """ @doc """
@@ -47,7 +60,7 @@ defmodule Friends.Accounts.User do
defp validate_password(changeset, opts) do defp validate_password(changeset, opts) do
changeset changeset
|> validate_required([:password]) |> validate_required([:password])
|> validate_length(:password, min: 12, max: 72) |> validate_length(:password, min: 6, max: 72)
# |> validate_format(:password, ~r/[a-z]/, message: "at least one lower case character") # |> validate_format(:password, ~r/[a-z]/, message: "at least one lower case character")
# |> validate_format(:password, ~r/[A-Z]/, message: "at least one upper case character") # |> validate_format(:password, ~r/[A-Z]/, message: "at least one upper case character")
# |> validate_format(:password, ~r/[!?@#$%^&*_0-9]/, message: "at least one digit or punctuation character") # |> validate_format(:password, ~r/[!?@#$%^&*_0-9]/, message: "at least one digit or punctuation character")
@@ -8,7 +8,7 @@ defmodule Friends.Accounts.UserNotifier do
email = email =
new() new()
|> to(recipient) |> to(recipient)
|> from({"Friends", "contact@example.com"}) |> from({"Friends App", "ryan@pandu.ski"})
|> subject(subject) |> subject(subject)
|> text_body(body) |> text_body(body)
+180
View File
@@ -0,0 +1,180 @@
defmodule Friends.Event do
use Ecto.Schema
import Ecto.Query
alias Friends.{Relationship, Event}
alias Places.Place
@repo Friends.Repo
schema "events" do
field(:date, :date)
field(:name, :string)
field(:story, :string)
field(:defining, :boolean)
field(:solo, :boolean)
belongs_to(:place, Place)
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
{
:place_id,
Places.Place.get_or_new(opts[:place]).id
}
end
]
{:ok, event} =
%Event{
story: opts[:story],
date: opts[:date],
place_id: opts[:place_id],
relationship_id: relationship.id
}
|> @repo.insert
event
end
def person(event) do
Friends.Friend.get_by_id(event.relationship.friend_id)
end
def people(event) do
if event.solo, do: event.person, else: event.relationship |> Relationship.members()
end
def age(event) do
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, :month}
else
{years, :year}
end
end
def print_age(age) do
Friends.Helpers.pluralize(age |> elem(0), age |> elem(1))
end
end
+206 -37
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
@@ -16,104 +16,273 @@ defmodule Friends.Friend do
field(:slug, :string) field(:slug, :string)
field(:memories, {:array, :string}) field(:memories, {:array, :string})
# has_many(:photos, Media.Photo) belongs_to :user, Friends.Accounts.User, foreign_key: :user_id
belongs_to :address, Friends.Places.Place, foreign_key: :address_id
many_to_many( many_to_many(
: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
def changeset(friend, params \\ %{}) do def changeset(friend, params \\ %{}) do
friend friend
|> Ecto.Changeset.cast(params, [:name, :born, :nickname, :email, :phone, :slug]) |> Ecto.Changeset.cast(params, [
|> Ecto.Changeset.validate_required([:name, :email, :phone, :born]) :name,
|> Ecto.Changeset.validate_format(:name, ~r/\w+\ \w+/) :born,
|> Ecto.Changeset.validate_format(:email, Regex.compile!("^[a-zA-Z0-9.!#$%&*+/=?^_`{|}~-]+@[a-zA-Z0-9-]+(?:\.[a-zA-Z0-9-]+)*$")) :nickname,
|> Ecto.Changeset.validate_format(:phone, Regex.compile!("^[\+]?[(]?[0-9]{3}[)]?[-\s\.]?[0-9]{3}[-\s\.]?[0-9]{4,6}$")) :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-]+)*$"),
message: "Invalid email format."
)
|> Ecto.Changeset.validate_format(
:phone,
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(:name)
|> Ecto.Changeset.unique_constraint(:email)
end 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 def all() do
preloads = [:relationships, :reverse_relationships] preloads = [:relationships, :reverse_relationships, :user, :address]
@repo.all(from(f in Friends.Friend, preload: ^preloads)) @repo.all(from(f in Friends.Friend, preload: ^preloads))
end end
def new(params \\ %{}) do def new(params \\ %{}) do
%Friend{id: "new"} %Friend{id: nil}
|> struct(params) |> struct(params)
end end
def get_by_slug(slug) do def get_by_slug(slug) do
@repo.one( @repo.one(
from(f in Friend, from(f in Friend,
where: f.slug == ^slug, where: f.slug == ^slug
preload: [:relationships, :reverse_relationships]
) )
) )
|> load_preloads()
end end
def get_by_id(id) do def get_by_id(id) do
@repo.one( @repo.one(
from(f in Friend, from(f in Friend,
where: f.id == ^id, where: f.id == ^id,
preload: [:relationships, :reverse_relationships] preload: [:relationships, :reverse_relationships, :address]
) )
) )
end end
def get_by_email(email) do
@repo.one(
from(f in Friend,
where: f.email == ^email
)
)
|> load_preloads()
end
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)
|> Friend.changeset(params)
|> Map.put(:action, :update)
end
def commit(changeset) do
changeset
|> @repo.commit!
|> load_preloads
|> assign_user!()
end
def generate_slug(%Ecto.Changeset{} = changeset) do
slug = changeset.changes.name |> to_slug
changeset
|> changeset(%{
slug: slug
})
end
def generate_slug(%Friend{} = friend) do
%{
friend
| slug: friend.name |> to_slug
}
end
def create_or_update(params) do def create_or_update(params) do
case params.id do case params.id do
"new" -> nil ->
%Friend{} |> Friend.changeset(%{params | id: nil}) params
|> Map.put(:action, :insert) |> create()
|> @repo.insert! |> generate_slug()
|> commit()
|> create_birth_event()
_number -> _number ->
Friend.get_by_id(params.id |> String.to_integer) params
|> Friend.changeset(params) |> update()
|> Map.put(:action, :update) |> commit()
|> @repo.update!
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
# Age in years # Age in years
Date.diff(Date.utc_today,friend.born) |> div(365) Date.diff(Date.utc_today(), friend.born) |> div(365)
end end
# TODO: Refactor def can_be_edited_by(friend, user) do
def create_birth_event(friend) do true
if is_nil @repo.all( # if user |> is_nil(), do: false, else: friend.id == user.profile.id
from(e in Friends.Event, end
where: e.name == "born"
)) |> List.flatten do 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
case friend.email |> Friends.Accounts.get_user_by_email() do
nil ->
nil
user ->
user |> Friends.Accounts.assign_profile()
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)
end
def load_user(%Friends.Friend{} = friend), do: friend
def load_relationships(
%Friends.Friend{
relationships: %Ecto.Association.NotLoaded{},
reverse_relationships: %Ecto.Association.NotLoaded{}
} = model
) do
model
|> @repo.preload([:relationships, :reverse_relationships])
end
def load_relationships(%Friends.Friend{} = friend), do: friend
def load_preloads(
%Friends.Friend{
relationships: %Ecto.Association.NotLoaded{},
reverse_relationships: %Ecto.Association.NotLoaded{},
user: %Ecto.Association.NotLoaded{},
address: %Ecto.Association.NotLoaded{}
} = model
) do
model
|> @repo.preload([:user, :relationships, :reverse_relationships, :address])
end
def load_preloads(friend), do: friend
def get_address(%Friend{} = friend) do
if friend.address do
{
friend.address.latlon,
friend.address.name
}
else else
"find" {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
+42
View File
@@ -0,0 +1,42 @@
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
field(:name, :string)
field(:type, :string)
field(:latlon, {:array, :float})
field(:zoom, :integer)
has_many(:events, Friends.Event)
has_many(:friends, Friends.Friend, foreign_key: :address_id)
end
def validate(place, params \\ %{}) do
place
|> cast(params, [:name, :type, :latlon, :zoom])
|> unique_constraint(:name)
|> validate_required(:name)
end
def get_or_create(place) do
case @repo.one(
from(
p in Friends.Places.Place,
where: p.name == ^place.name
)
) do
nil ->
place
|> Friends.Places.Place.validate()
|> @repo.insert!()
found -> found
end
end
end
+63
View File
@@ -0,0 +1,63 @@
defmodule Friends.Places.Search do
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)
[
lat_min - 1,
lat_max + 1,
lon_min - 1,
lon_max + 1
]
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
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)
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
+110 -20
View File
@@ -1,9 +1,10 @@
defmodule Friends.Relationship do defmodule Friends.Relationship do
use Ecto.Schema use Ecto.Schema
import Ecto.Query import Ecto.Query
alias Friends.{Relationship,Event,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 \\ 0) 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,10 +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{
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
model
|> @repo.preload([:events])
end
def load_preloads(%Relationship{} = r), do: r
end end
+9
View File
@@ -2,4 +2,13 @@ defmodule Friends.Repo do
use Ecto.Repo, use Ecto.Repo,
otp_app: :friends, otp_app: :friends,
adapter: Ecto.Adapters.Postgres adapter: Ecto.Adapters.Postgres
def commit!(changeset) do
IO.inspect(changeset)
case changeset.action do
:update -> update!(changeset)
:insert -> insert!(changeset)
end
end
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
@@ -0,0 +1,9 @@
defmodule FriendsWeb.FriendsController do
use FriendsWeb, :controller
def index(conn, _params) do
conn
|> assign(:all_friends, Friend.all())
|> render("index.html")
end
end
@@ -1,16 +1,21 @@
defmodule FriendsWeb.PageController do defmodule FriendsWeb.PageController do
use FriendsWeb, :controller use FriendsWeb, :controller
alias Friends.{Friend, Relationship} alias Friends.{Friend, Repo, Accounts.User, Accounts}
import Helpers
import Helpers.Names
def index(conn, _params) do def index(conn, _params) do
new_friend = Friend.new() |> Friend.changeset()
new_friend = Friend.new |> Friend.changeset if(conn |> get_session(:linked)) do
# If logged in and linked, redirect to friend_path :index
conn |> redirect(to: FriendsWeb.Router.Helpers.friends_path(conn, :index))
else
# Otherwise, show the landing page
conn conn
|> assign(:new_friend, new_friend) |> assign(:new_friend, new_friend)
|> assign(:all_friends, Friend.all) |> assign(:friends, Friend.all())
|> assign(:users, Friends.Accounts.User |> Friends.Repo.all)
|> render("index.html") |> render("index.html")
end end
end end
end
@@ -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
@@ -91,7 +91,13 @@ defmodule FriendsWeb.UserAuth do
def fetch_current_user(conn, _opts) do def fetch_current_user(conn, _opts) do
{user_token, conn} = ensure_user_token(conn) {user_token, conn} = ensure_user_token(conn)
user = user_token && Accounts.get_user_by_session_token(user_token) user = user_token && Accounts.get_user_by_session_token(user_token)
assign(conn, :current_user, user)
assign(
conn,
:current_user,
user
|> Friends.Repo.preload(:profile)
)
end end
defp ensure_user_token(conn) do defp ensure_user_token(conn) do
@@ -121,13 +127,58 @@ defmodule FriendsWeb.UserAuth do
end end
end end
@doc """
Used to detect or link a profile to a user.
"""
def capture_profile(conn, _opts) do
current_user = conn.assigns[:current_user]
if current_user do
# There's a user, but is there a profile?
profile = current_user |> Map.get(:profile)
case profile do
nil ->
# No profile linked
conn
|> create_or_link_profile(current_user)
_profile ->
# Profile already linked
conn |> put_session(:linked, true)
end
else
# Not logged in
conn
end
end
defp create_or_link_profile(conn, user) do
case user.email |> Friends.Friend.get_by_email() do
# Find a profile and link it
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, :welcome))
|> halt()
# Or make a new one
_profile ->
user
|> Friends.Accounts.assign_profile()
conn
|> put_session(:linked, true)
end
end
@doc """ @doc """
Used for routes that require the user to be authenticated. Used for routes that require the user to be authenticated.
If you want to enforce the user email is confirmed before If you want to enforce the user email is confirmed before
they use the application at all, here would be a good place. they use the application at all, here would be a good place.
""" """
def require_authenticated_user(conn, _opts) do def require_authenticated_user(conn, _opts \\ nil) do
if conn.assigns[:current_user] do if conn.assigns[:current_user] do
conn conn
else else
@@ -0,0 +1,32 @@
defmodule FriendsWeb.UserProfileController do
use FriendsWeb, :controller
alias Friends.Accounts
alias FriendsWeb.Router.Helpers, as: Routes
plug :assign_profile
@doc """
Checks if the user has linked a profile yet,
and redirects to the profile creation flow
if not.
"""
def main(conn) do
conn
|> put_flash(:info, "test")
end
defp assign_profile(conn, _opts) do
user = conn.assigns.current_user
msg = case user.profile do
nil -> "No profile!"
profile -> "Yes profile! #{profile.name}"
end
conn
|> put_flash(:info, msg)
end
end
+2 -1
View File
@@ -10,7 +10,8 @@ defmodule FriendsWeb.Endpoint do
signing_salt: "jNBoklme" signing_salt: "jNBoklme"
] ]
socket "/live", Phoenix.LiveView.Socket, websocket: [connect_info: [session: @session_options]] socket "/live", Phoenix.LiveView.Socket,
websocket: [connect_info: [:peer_data, session: @session_options]]
# Serve at "/" the static files from "priv/static" directory. # Serve at "/" the static files from "priv/static" directory.
# #
@@ -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
@@ -0,0 +1,110 @@
defmodule FriendsWeb.Components do
use FriendsWeb, :live_component
def header(assigns) do
~H"""
<div class="border-b-4 flex flex-row justify-between">
<h1 class="mb-0 pl-2" style="height:48px; text-indent:5px"><%= @friend.name %></h1>
</div>
"""
end
def menu(assigns) do
~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 %>
<.link patch={Routes.friends_show_path(FriendsWeb.Endpoint, page, @friend.slug)} class={"font-bold sm:tab-lg flex-grow no-underline tab tab-lifted #{is_active}"}>
<%= page |> to_string |> :string.titlecase() %>
</.link>
<% end %>
</div>
"""
end
@spec edit_menu(any) :: Phoenix.LiveView.Rendered.t()
def edit_menu(assigns) do
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 %>
<.link patch={Routes.friends_edit_path(FriendsWeb.Endpoint, page, @friend.slug)} class={"font-bold sm:tab-lg flex-grow no-underline tab tab-lifted #{is_active}"}>
<%= page |> to_string |> :string.titlecase() %>
</.link>
<% end %>
</div>
"""
end
def relationship_details(assigns) do
~H"""
<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 %>
<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>
<% end %>
</div>
"""
end
def relationship_type_selector(assigns) do
~H"""
<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
###
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
+244
View File
@@ -0,0 +1,244 @@
defmodule FriendsWeb.FriendsLive.Edit do
use FriendsWeb, :live_view
# No slug means it's a new profile form
def mount(%{}, token, socket) do
friend = Friend.new()
live_action = socket.assigns.live_action || :overview
{: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(:search_query, nil)
|> assign(:search_results, nil)}
else
{:ok, socket |> redirect(to: Routes.friends_show_path(socket, :overview, friend.slug))}
end
end
# 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))
|> 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
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,
name: name,
nickname: nickname,
slug: friend.slug,
born: born,
phone: phone,
email: email
}
changeset =
%Friend{}
|> Friend.changeset(new_params)
|> Map.put(:action, :validate)
{
:noreply,
socket
|> assign(:changeset, changeset)
|> assign_friend(friend |> struct(new_params), changeset)
|> assign(:address_query, address_query)
|> assign(:address_latlon, address_latlon)
}
end
# Handle form saving
def handle_event(
"save",
%{"friend" => form_params},
%{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
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: slug,
born: born,
phone: phone,
email: email,
address_id: new_address.id
}
updated_friend = Friend.create_or_update(new_params)
new_changeset = updated_friend |> Friend.changeset()
{
:noreply,
socket
|> put_flash(:info, "Saved #{updated_friend |> first_name}!")
|> assign(:new_friend, new_changeset)
|> assign(:friend, updated_friend)
|> push_navigate(to: "/friend/#{updated_friend.slug}")
}
end
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
|> 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
@@ -0,0 +1,6 @@
<section class="row">
<article class="column prose">
<%= edit_menu(assigns) %>
<%= apply(FriendsWeb.LiveViews.Edit, @live_action, [assigns]) %>
</article>
</section>
+11 -177
View File
@@ -1,189 +1,23 @@
defmodule FriendsWeb.FriendLive.Friend do defmodule FriendsWeb.FriendsLive.Friend do
use FriendsWeb, :live_view use FriendsWeb, :live_view
use Phoenix.HTML
import Helpers
import Helpers.Names
alias Friends.{Friend,Relationship}
alias FriendsWeb.FriendLive.Components
alias FriendsWeb.Router.Helpers, as: Routes
# Initialize variables on first load # Initialize variables on first load
def mount(%{}, _token, socket) do def mount(%{}, token, socket) do
{:ok, socket {:ok,
socket
|> title("New Friend") |> title("New Friend")
|> assign(:changeset, %Friend{} |> Friend.changeset) |> assign_current_user(token |> Map.get("user_token"))
} |> assign(:changeset, %Friend{} |> Friend.changeset())}
end
# Show Friend
def handle_params(%{"slug" => slug} = attrs, _token, socket) do
friend = Friend.get_by_slug(slug)
page = if (attrs |> Map.get("page")) in ["overview", "calendar", "relationships"] do
attrs["page"]
else
"overview"
end
{:noreply, socket
|> title(friend.name <> " - " <> (page |> :string.titlecase()))
|> assign_friend(friend)
|> page_view(page)
|> assign(:action, Routes.friend_path(socket, :update, friend.id))
}
end end
# New Friend # New Friend
def handle_params(attrs, _token, socket) do def handle_params(_attrs, _token, socket) do
friend = Friend.new friend = Friend.new()
{:noreply, socket
|> title("New Friend")
|> assign_friend(friend)
|> page_view("overview")
|> assign(:action,Routes.friend_path(socket, :create))
}
end
# Handle form validation
def handle_event("validate", %{"friend" => form_params}, %{assigns: %{friend: friend}} = socket) do
id = form_params["id"]
name = form_params["name"]
nickname = form_params["nickname"]
born = form_params["born"]
email = form_params["email"]
phone = form_params["phone"] |> format_phone
new_params = %{
id: id,
name: name,
nickname: nickname,
slug: friend.slug,
born: born,
phone: phone,
email: email
}
changeset = %Friend{}
|> Friend.changeset(new_params)
|> Map.put(:action, :validate)
{
:noreply, socket
|> assign(:changeset, changeset)
|> assign_friend(friend |> struct(new_params), changeset)
}
end
# Handle form saving
def handle_event("save", %{"friend" => form_params},%{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"]
new_params = %{
id: id,
name: name,
nickname: nickname,
slug: name |> to_slug,
born: born,
phone: phone,
email: email
}
updated_friend = Friend.create_or_update(new_params)
new_changeset = updated_friend |> Friend.changeset
{
:noreply,
socket
|> put_flash(:info, "Saved #{updated_friend |> first_name}!")
|> assign(:new_friend, new_changeset)
|> assign(:friend, updated_friend)
|> push_patch(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)
{:noreply, {:noreply,
socket socket
|> put_flash(:error, "Deleted '#{friend.name}'.") |> title("New Friend")
|> push_navigate(to: "/") |> assign_friend(friend)
} |> assign(:action, Routes.friends_path(socket, :create))}
end end
# Set page title
def title(socket, title) do
socket |> assign(:page_title, title)
end
# Set variables on page: friend, changeset, relationships
def assign_friend(socket, friend) do
socket
|> assign(:friend, friend)
|> 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(:changeset, changeset)
|> assign(:relationships, friend.relationships)
end
# Set page_view variable
def page_view(socket, page) do
socket |> assign(:page_view, page)
end
# Route to the right sub-template in Components/Components.ex
def content(assigns) do
~H"""
<%= if @live_action != :new do %>
<%= FriendsWeb.FriendLive.Friend.menu(assigns) %>
<% end %>
<%= cond do %>
<% @live_action == :show -> %>
<%= header(assigns) %>
<%= @page_view |> Components.show_page(assigns) %>
<% @live_action in [:edit, :new] -> %>
<%= @page_view |> Components.form_page(assigns) %>
<% end %>
"""
end
def header(assigns) do
~H"""
<div class="border-b-4 flex flex-row justify-between">
<h1 class="mb-0 pl-2" style="height:48px; text-indent:5px"><%= @friend.name %></h1>
</div>
"""
end
def menu(assigns) do
~H"""
<div class="hidden sm:tabs sm:mb-8">
<%= for page <- ["overview", "calendar", "relationships"] do %>
<% is_active = if(page == @page_view) do "tab-active" end %>
<.link patch={"/friend/#{@friend.slug}?page=#{page}"} class={"font-bold sm:tab-lg flex-grow no-underline tab tab-lifted #{is_active}"}>
<%= page |> :string.titlecase() %>
</.link>
<% end %>
</div>
"""
end
end end
@@ -1,5 +1,5 @@
<section class="row"> <section class="row">
<article class="column prose"> <article class="column prose">
<FriendsWeb.FriendLive.Friend.content friend={@friend} page_view={@page_view} changeset={@changeset} action={@action} live_action={@live_action}/> <FriendsWeb.FriendsLive.Friend.content friend={@friend} page_view={@page_view} changeset={@changeset} action={@action} live_action={@live_action}/>
</article> </article>
</section> </section>
@@ -1,4 +1,3 @@
defmodule FriendsWeb.FriendsLive do defmodule FriendsWeb.FriendsLive do
use FriendsWeb, :live_view use FriendsWeb, :live_view
end end
@@ -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
+146
View File
@@ -0,0 +1,146 @@
defmodule FriendsWeb.FriendsLive.Show do
use FriendsWeb, :live_view
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(: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},
_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)
|> 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
@@ -0,0 +1,13 @@
<section class="row">
<article class="column prose">
<%= menu(assigns) %>
<%= header(assigns) %>
<%= apply(FriendsWeb.LiveViews.Show, @live_action, [assigns]) %>
<%= if @editable and @mode != :edit do %>
<div class="form-control flex flex-row mb-4">
<.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>
</section>
+70 -33
View File
@@ -17,14 +17,6 @@ defmodule FriendsWeb.Router do
plug :accepts, ["json"] plug :accepts, ["json"]
end end
scope "/", FriendsWeb do
pipe_through :browser
get "/", PageController, :index
live "/friend/:slug", FriendsLive.Show
end
# Other scopes may use custom stacks. # Other scopes may use custom stacks.
# scope "/api", FriendsWeb do # scope "/api", FriendsWeb do
# pipe_through :api # pipe_through :api
@@ -60,35 +52,80 @@ defmodule FriendsWeb.Router do
end end
## Authentication routes ## Authentication routes
# Routes that only work if user not authenticated
scope "/", FriendsWeb do scope "/users", FriendsWeb do
pipe_through [:browser, :redirect_if_user_is_authenticated] pipe_through [:browser, :redirect_if_user_is_authenticated]
get "/register", UserRegistrationController, :new
get "/users/register", UserRegistrationController, :new post "/register", UserRegistrationController, :create
post "/users/register", UserRegistrationController, :create get "/log_in", UserSessionController, :new
get "/users/log_in", UserSessionController, :new post "/log_in", UserSessionController, :create
post "/users/log_in", UserSessionController, :create get "/reset_password", UserResetPasswordController, :new
get "/users/reset_password", UserResetPasswordController, :new post "/reset_password", UserResetPasswordController, :create
post "/users/reset_password", UserResetPasswordController, :create get "/reset_password/:token", UserResetPasswordController, :edit
get "/users/reset_password/:token", UserResetPasswordController, :edit put "/reset_password/:token", UserResetPasswordController, :update
put "/users/reset_password/:token", UserResetPasswordController, :update
end end
scope "/", FriendsWeb do # Confirmation and logout
pipe_through [:browser, :require_authenticated_user] scope "/users", FriendsWeb do
get "/users/settings", UserSettingsController, :edit
put "/users/settings", UserSettingsController, :update
get "/users/settings/confirm_email/:token", UserSettingsController, :confirm_email
end
scope "/", FriendsWeb do
pipe_through [:browser] pipe_through [:browser]
delete "/log_out", UserSessionController, :delete
get "/confirm", UserConfirmationController, :new
post "/confirm", UserConfirmationController, :create
get "/confirm/:token", UserConfirmationController, :edit
post "/confirm/:token", UserConfirmationController, :update
delete "/users/log_out", UserSessionController, :delete live "/welcome", FriendsLive.Edit, :welcome
get "/users/confirm", UserConfirmationController, :new end
post "/users/confirm", UserConfirmationController, :create
get "/users/confirm/:token", UserConfirmationController, :edit # Routes that require the user be authenticated:
post "/users/confirm/:token", UserConfirmationController, :update scope "/users/settings", FriendsWeb do
pipe_through [:browser, :require_authenticated_user]
get "/", UserSettingsController, :edit
put "/", UserSettingsController, :update
get "/confirm_email/:token", UserSettingsController, :confirm_email
end
# THE ACTUAL GUTS OF THE APP
scope "/", FriendsWeb do
pipe_through [:browser, :capture_profile]
get "/", PageController, :index
end
# View-only modes (don't require being logged in and having a profile)
scope "/friends", FriendsWeb do
pipe_through [:browser]
get "/", FriendsController, :index
end
scope "/friend", FriendsWeb do
pipe_through [:browser]
live "/:slug", FriendsLive.Show
live "/:slug/overview", FriendsLive.Show, :overview
live "/:slug/timeline", FriendsLive.Show, :timeline
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 "/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
# API
scope "/api", FriendsWeb do
pipe_through :api
delete "/relationship/:id", RelationshipsController, :delete
end end
end end
@@ -0,0 +1,19 @@
<h1>All Friends</h1>
<ul class="text-xl">
<%= for f <- @all_friends do %>
<li>
<.link href={Routes.friends_show_path(@conn, :overview, f.slug)}><%= f.name %></.link>
<%= if @current_user do %>
<%= if f.id == @current_user.profile.id do %>
(you)
<% end %><% end %>
</li>
<% end %>
</ul>
<%= if @current_user do %>
<div class="m-4 mt-16">
<.link href={Routes.friends_edit_path(@conn, :overview, :new)}>Add Friend</.link>
</div>
<% end %>
@@ -1,10 +1,16 @@
<ul class="mt-3 p-2 shadow menu menu-compact dropdown-content bg-base-100 text-neutral rounded-box w-52">
<%= if @current_user do %> <%= if @current_user do %>
<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><%= @current_user.email %></li>
<li><%= link "Settings", to: Routes.user_settings_path(@conn, :edit) %></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> <li><%= link "Log out", to: Routes.user_session_path(@conn, :delete), method: :delete %></li>
<% else %>
<li><%= link "Register", to: Routes.user_registration_path(@conn, :new) %></li>
<li><%= link "Log in", to: Routes.user_session_path(@conn, :new) %></li>
<% end %>
</ul> </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" %>
<% end %>
@@ -4,17 +4,14 @@
<.link navigate={"/"} class="btn btn-ghost normal-case text-xl">Friends</.link> <.link navigate={"/"} class="btn btn-ghost normal-case text-xl">Friends</.link>
</div> </div>
<div class="dropdown dropdown-end"> <div class="dropdown dropdown-end">
<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>
<%= render("_user_menu.html", conn: @conn, current_user: @current_user) %> <%= render("_user_menu.html", conn: @conn, current_user: @current_user) %>
</div> </div>
</div> </div>
</div> </div>
<div class="mx-auto w-full lg:w-3/4"> <div class="mx-auto w-full lg:w-3/4">
<main class="container p-4 mb-10 md:p-8 prose"> <main class="container p-4 mb-10 md:p-8 prose">
<p class="alert alert-info" role="alert"><%= get_flash(@conn, :info) %></p>
<p class="alert alert-danger" role="alert"><%= get_flash(@conn, :error) %></p>
<%= @inner_content %> <%= @inner_content %>
</main> </main>
</div> </div>
@@ -3,27 +3,8 @@
<div class="flex-1"> <div class="flex-1">
<.link navigate={"/"} class="btn btn-ghost normal-case text-xl">Friends</.link> <.link navigate={"/"} class="btn btn-ghost normal-case text-xl">Friends</.link>
</div> </div>
<div class="flex-none gap-2">
<div class="form-control hidden md:block">
<input type="text" placeholder="Search" class="input input-bordered" />
</div>
<div class="dropdown dropdown-end"> <div class="dropdown dropdown-end">
<label tabindex="0" class="btn btn-ghost btn-circle avatar"> <%= render("_user_menu.html", conn: @socket, current_user: @current_user) %>
<div class="w-10 rounded-full">
<img src="https://placeimg.com/80/80/people" />
</div>
</label>
<ul tabindex="0" class="mt-3 p-2 shadow menu menu-compact dropdown-content bg-base-100 rounded-box w-52">
<li>
<a>
Profile
</a>
</li>
<li class="md:hidden"><a>Search</a></li>
<li><a>Settings</a></li>
<li><a>Logout</a></li>
</ul>
</div>
</div> </div>
</div> </div>
</div> </div>
@@ -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"/>
@@ -9,7 +9,7 @@
and basically go back to the 2011 Facebook we all miss. and basically go back to the 2011 Facebook we all miss.
</p> </p>
<div class="hidden md:block"> <div class="hidden md:block">
<.db_stats all_friends={@all_friends} users={@users} /> <.friends_list friends={@friends} />
</div> </div>
</div> </div>
<div id="sign-up" class="prose card md:w-fit bg-neutral-content text-neutral shadow-xl items-center"> <div id="sign-up" class="prose card md:w-fit bg-neutral-content text-neutral shadow-xl items-center">
@@ -20,7 +20,7 @@
<.sign_up new_friend={@new_friend} /> <.sign_up new_friend={@new_friend} />
</div> </div>
</div> </div>
<div id="stats" class="prose w-full mb-8 md:hidden"> <div id="friends" class="prose w-full mb-8 md:hidden">
<.db_stats all_friends={@all_friends} users={@users} /> <.friends_list friends={@friends} />
</div> </div>
</div> </div>
@@ -0,0 +1,25 @@
defmodule FriendsWeb.ProfileLive.Form do
use FriendsWeb, :live_view
import FriendsWeb.LiveHelpers
def mount(%{}, %{"user_token" => token}, socket) do
{:ok,
socket
|> assign_current_user(token)
|> title("Create a profile")}
end
def show_form(%{step: 1} = assigns) do
# First things first, are we linking to an existing user or no?
~H"""
<b>Your email:</b> <%= @current_user.email %>
"""
end
def show_form(%{step: 2} = assigns) do
~H"""
Step 2
"""
end
end
@@ -0,0 +1,9 @@
<div class="prose">
<h1>Welcome!</h1>
<p>You have made a user account, but we don't yet have a profile for you.</p>
<div id="profile-form">
<.show_form step={1} current_user={@current_user} />
</div>
</div>
@@ -3,23 +3,27 @@
<.form let={f} for={@changeset} action={Routes.user_registration_path(@conn, :create)}> <.form let={f} for={@changeset} action={Routes.user_registration_path(@conn, :create)}>
<%= if @changeset.action do %> <%= if @changeset.action do %>
<div class="alert alert-danger"> <div class="alert alert-danger">
<p>Oops, something went wrong! Please check the errors below.</p> Oops, something went wrong! Please check the errors below.
</div> </div>
<% end %> <% end %>
<ul class="w-1/2 pl-0 flex flex-col gap-6">
<%= label f, :email %> <li class="flex flex-row gap-4">
<%= label f, :email, class: "w-1/3" %>
<%= email_input f, :email, required: true %> <%= email_input f, :email, required: true %>
<%= error_tag f, :email %> <%= error_tag f, :email %>
</li>
<%= label f, :password %> <li class="flex flex-row gap-4">
<%= label f, :password, class: "w-1/3" %>
<%= password_input f, :password, required: true %> <%= password_input f, :password, required: true %>
<%= error_tag f, :password %> <%= error_tag f, :password %>
</li>
<div> <li class="flex">
<%= submit "Register" %> <%= submit "Register", class: "btn btn-primary" %>
</div> </li>
</ul>
</.form> </.form>
<p> <p>
<%= link "Log in", to: Routes.user_session_path(@conn, :new) %> | <%= link "Log in", to: Routes.user_session_path(@conn, :new) %> |
<%= link "Forgot your password?", to: Routes.user_reset_password_path(@conn, :new) %> <%= link "Forgot your password?", to: Routes.user_reset_password_path(@conn, :new) %>
@@ -6,19 +6,23 @@
<p><%= @error_message %></p> <p><%= @error_message %></p>
</div> </div>
<% end %> <% end %>
<ul class="w-1/2 pl-0 flex flex-col gap-6">
<%= label f, :email %> <li class="flex flex-row gap-4">
<%= label f, :email, class: "w-1/3" %>
<%= email_input f, :email, required: true %> <%= email_input f, :email, required: true %>
</li>
<%= label f, :password %> <li class="flex flex-row gap-4">
<%= label f, :password, class: "w-1/3" %>
<%= password_input f, :password, required: true %> <%= password_input f, :password, required: true %>
</li>
<%= label f, :remember_me, "Keep me logged in for 60 days" %> <li class="flex flex-row gap-4">
<%= label f, :remember_me, "Keep me logged in for 60 days", class: "w-fit" %>
<%= checkbox f, :remember_me %> <%= checkbox f, :remember_me %>
</li>
<div> <li class="flex">
<%= submit "Log in" %> <%= submit "Log in", class: "btn btn-primary" %>
</div> </li>
</ul>
</.form> </.form>
<p> <p>
@@ -1,8 +1,13 @@
<h1>Settings</h1> <h1>Settings</h1>
<h3>Change email</h3> <div class="flex gap-16 flex-col md:flex-row justify-evenly">
<.form
<.form let={f} for={@email_changeset} action={Routes.user_settings_path(@conn, :update)} id="update_email"> let={f}
for={@email_changeset}
action={Routes.user_settings_path(@conn, :update)}
id="update_email"
class="w-max"
>
<%= if @email_changeset.action do %> <%= if @email_changeset.action do %>
<div class="alert alert-danger"> <div class="alert alert-danger">
<p>Oops, something went wrong! Please check the errors below.</p> <p>Oops, something went wrong! Please check the errors below.</p>
@@ -11,22 +16,37 @@
<%= hidden_input f, :action, name: "action", value: "update_email" %> <%= hidden_input f, :action, name: "action", value: "update_email" %>
<%= label f, :email %>
<h3>Change email</h3>
<ul class="w-full pl-0 flex flex-col gap-6">
<li class="flex flex-row gap-4">
<%= label f, :email, class: "w-1/3" %>
<%= email_input f, :email, required: true %> <%= email_input f, :email, required: true %>
<%= error_tag f, :email %> <%= error_tag f, :email %>
</li>
<%= label f, :current_password, for: "current_password_for_email" %> <li class="flex flex-row gap-4">
<%= password_input f, :current_password, required: true, name: "current_password", id: "current_password_for_email" %> <%= label f, :current_password, for: "current_password_for_email", class: "w-1/3" %>
<%= password_input f, :password, required: true %>
<%= error_tag f, :current_password %> <%= error_tag f, :current_password %>
</li>
<div> <li class="flex place-self-stretch">
<%= submit "Change email" %> <%= submit "Change email", class: "btn btn-primary" %>
</div> </li>
</ul>
</.form> </.form>
<.form
let={f}
for={@password_changeset}
action={Routes.user_settings_path(@conn, :update)}
id="update_password"
class="w-max"
>
<h3>Change password</h3> <h3>Change password</h3>
<.form let={f} for={@password_changeset} action={Routes.user_settings_path(@conn, :update)} id="update_password"> <ul class="w-full pl-0 flex flex-col gap-6 md:h-full">
<%= if @password_changeset.action do %> <%= if @password_changeset.action do %>
<div class="alert alert-danger"> <div class="alert alert-danger">
<p>Oops, something went wrong! Please check the errors below.</p> <p>Oops, something went wrong! Please check the errors below.</p>
@@ -35,19 +55,24 @@
<%= hidden_input f, :action, name: "action", value: "update_password" %> <%= hidden_input f, :action, name: "action", value: "update_password" %>
<%= label f, :password, "New password" %> <li class="flex flex-row gap-4">
<%= password_input f, :password, required: true %> <%= label f, :current_password, for: "current_password_for_password", class: "w-1/3" %>
<%= error_tag f, :password %>
<%= label f, :password_confirmation, "Confirm new password" %>
<%= password_input f, :password_confirmation, required: true %>
<%= error_tag f, :password_confirmation %>
<%= label f, :current_password, for: "current_password_for_password" %>
<%= password_input f, :current_password, required: true, name: "current_password", id: "current_password_for_password" %> <%= password_input f, :current_password, required: true, name: "current_password", id: "current_password_for_password" %>
<%= error_tag f, :current_password %> <%= error_tag f, :current_password %>
</li>
<div> <li class="flex flex-row gap-4">
<%= submit "Change password" %> <%= label f, :password, "New password", class: "w-1/3" %>
</div> <%= password_input f, :password, required: true, class: "shrink-0" %>
<%= error_tag f, :password %>
</li>
<li class="flex flex-row gap-4">
<%= label f, :password_confirmation, "Confirm new password", class: "w-1/3" %>
<%= password_input f, :password_confirmation, required: true %>
<%= error_tag f, :password_confirmation %>
</li>
<li class="flex">
<%= submit "Change password", class: "btn btn-primary" %>
</li>
</ul>
</.form> </.form>
</div>
@@ -10,10 +10,8 @@ defmodule FriendsWeb.ErrorHelpers do
""" """
def error_tag(form, field) do def error_tag(form, field) do
Enum.map(Keyword.get_values(form.errors, field), fn error -> Enum.map(Keyword.get_values(form.errors, field), fn error ->
content_tag(:span, translate_error(error), field = field |> Atom.to_string() |> String.capitalize()
class: "invalid-feedback", content_tag(:span, "#{translate_error(error)}", class: "block mt-1 text-sm text-red-700")
phx_feedback_for: input_name(form, field)
)
end) end)
end end
@@ -0,0 +1,5 @@
defmodule FriendsWeb.FriendsView do
use FriendsWeb, :view
import Phoenix.Component
import Helpers
end
@@ -0,0 +1,97 @@
defmodule FriendsWeb.LiveHelpers do
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
nil ->
nil
_moot ->
user_token
|> Friends.Accounts.get_user_by_session_token()
|> Friends.Repo.preload(:profile)
end
socket
|> assign(
:current_user,
user
)
end
# Set page title variable
def title(socket, title) do
socket
|> assign(:page_title, title)
end
# Set variables on page: friend, changeset, relationships
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())
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)
end
end
+12 -11
View File
@@ -6,24 +6,25 @@ defmodule FriendsWeb.PageView do
alias FriendsWeb.Components.SignUp, as: SignUp alias FriendsWeb.Components.SignUp, as: SignUp
def sign_up(assigns), do: SignUp.sign_up_form(assigns) def sign_up(assigns), do: SignUp.sign_up_form(assigns)
def db_stats(assigns) do
def friends_list(assigns) do
~H""" ~H"""
<h3 class="mt-12 border-b-2"> <h3 class="mt-12 border-b-2">
Database stats All Friends
</h3> </h3>
<ul> <ul>
<%= for friend <- @friends do %>
<li> <li>
<.link patch="/friends" class=""> <div id={"friend-#{friend.id}"} class="">
<%= pluralize(@all_friends |> length, "friend") %> <div class="">
</.link> <h3 class="">
</li> <.link href={Routes.live_path(FriendsWeb.Endpoint, FriendsWeb.FriendsLive.Show, friend.slug)}><%= friend.name %></.link>
<li> </h3>
<.link patch="/users" class=""> </div>
<%= pluralize(@users |> length, "user") %> </div>
</.link>
</li> </li>
<% end %>
</ul> </ul>
""" """
end end
end end
@@ -0,0 +1,3 @@
defmodule UserProfileView do
end
+56 -13
View File
@@ -1,6 +1,4 @@
defmodule Helpers do defmodule Helpers do
import Helpers.Names
def do!(thing) do def do!(thing) do
case thing do case thing do
{:ok, answer} -> answer {:ok, answer} -> answer
@@ -8,28 +6,39 @@ defmodule Helpers do
end end
end end
def pluralize(qty, word) do def pluralize(qty, word) do
plural = if qty == 1 do plural =
if qty == 1 do
"#{word}" "#{word}"
else else
"#{word}s" "#{word}s"
end end
"#{qty} #{plural}" "#{qty} #{plural}"
end end
def parse_date(str), do: str |> to_string |> Timex.parse("%Y-%m-%d", :strftime) def parse_date(str), do: str |> to_string |> Timex.parse("%Y-%m-%d", :strftime)
def valid_phone() do
Regex.compile!("^[\+]?[(]?[0-9]{3}[)]?[-\s\.]?[0-9]{3}[-\s\.]?[0-9]{4,6}$")
end
def format_phone(str) do def format_phone(str) do
str str
|> String.replace(" ", "") |> String.replace("-", "") |> String.replace(".", "") |> String.replace(" ", "")
|> String.replace("-", "")
|> String.replace(".", "")
|> String.replace(~r/[\(\)]/, "")
end end
def to_slug(nil), do: nil
def to_slug(name) when is_binary(name) do def to_slug(name) when is_binary(name) do
name name
|> String.replace(" ", "-") |> String.replace(" ", "-")
|> String.downcase() |> String.downcase()
end end
def to_slug(obj), do: to_slug(obj.name) def to_slug(obj), do: to_slug(obj.name)
def from_slug(name) do def from_slug(name) do
@@ -41,17 +50,20 @@ defmodule Helpers do
end end
def birthday(friend) do def birthday(friend) do
this_year = (Date.utc_today this_year =
Date.utc_today()
|> Calendar.strftime("%Y") |> Calendar.strftime("%Y")
|> String.to_integer) |> String.to_integer()
next_birthday = friend.born next_birthday =
friend.born
|> Calendar.strftime("%m-%d") |> Calendar.strftime("%m-%d")
next_birthdate = "#{this_year}-#{next_birthday}" next_birthdate =
"#{this_year}-#{next_birthday}"
|> Date.from_iso8601!() |> Date.from_iso8601!()
if next_birthdate |> Date.diff(Date.utc_today) < 0 do if next_birthdate |> Date.diff(Date.utc_today()) < 0 do
"#{this_year + 1}-#{next_birthday}" |> Date.from_iso8601!() "#{this_year + 1}-#{next_birthday}" |> Date.from_iso8601!()
else else
next_birthdate next_birthdate
@@ -59,20 +71,51 @@ defmodule Helpers do
end end
def time_until_birthday(friend) do def time_until_birthday(friend) 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
+4 -1
View File
@@ -54,7 +54,10 @@ defmodule Friends.MixProject do
{:tailwind, "~> 0.1.6", runtime: Mix.env() == :dev}, {:tailwind, "~> 0.1.6", runtime: Mix.env() == :dev},
{:earmark, "~> 1.4"}, {:earmark, "~> 1.4"},
{:html_sanitize_ex, "~> 1.3"}, {:html_sanitize_ex, "~> 1.3"},
{:yamerl, github: "yakaz/yamerl"} {:yamerl, github: "yakaz/yamerl"},
{:iamvery, "~> 0.6"},
{:httpoison, "~> 1.8"},
{:poison, "~> 5.0"}
] ]
end end
+3
View File
@@ -22,6 +22,8 @@
"hackney": {:hex, :hackney, "1.18.1", "f48bf88f521f2a229fc7bae88cf4f85adc9cd9bcf23b5dc8eb6a1788c662c4f6", [:rebar3], [{:certifi, "~>2.9.0", [hex: :certifi, repo: "hexpm", optional: false]}, {:idna, "~>6.1.0", [hex: :idna, repo: "hexpm", optional: false]}, {:metrics, "~>1.0.0", [hex: :metrics, repo: "hexpm", optional: false]}, {:mimerl, "~>1.1", [hex: :mimerl, repo: "hexpm", optional: false]}, {:parse_trans, "3.3.1", [hex: :parse_trans, repo: "hexpm", optional: false]}, {:ssl_verify_fun, "~>1.1.0", [hex: :ssl_verify_fun, repo: "hexpm", optional: false]}, {:unicode_util_compat, "~>0.7.0", [hex: :unicode_util_compat, repo: "hexpm", optional: false]}], "hexpm", "a4ecdaff44297e9b5894ae499e9a070ea1888c84afdd1fd9b7b2bc384950128e"}, "hackney": {:hex, :hackney, "1.18.1", "f48bf88f521f2a229fc7bae88cf4f85adc9cd9bcf23b5dc8eb6a1788c662c4f6", [:rebar3], [{:certifi, "~>2.9.0", [hex: :certifi, repo: "hexpm", optional: false]}, {:idna, "~>6.1.0", [hex: :idna, repo: "hexpm", optional: false]}, {:metrics, "~>1.0.0", [hex: :metrics, repo: "hexpm", optional: false]}, {:mimerl, "~>1.1", [hex: :mimerl, repo: "hexpm", optional: false]}, {:parse_trans, "3.3.1", [hex: :parse_trans, repo: "hexpm", optional: false]}, {:ssl_verify_fun, "~>1.1.0", [hex: :ssl_verify_fun, repo: "hexpm", optional: false]}, {:unicode_util_compat, "~>0.7.0", [hex: :unicode_util_compat, repo: "hexpm", optional: false]}], "hexpm", "a4ecdaff44297e9b5894ae499e9a070ea1888c84afdd1fd9b7b2bc384950128e"},
"html_entities": {:hex, :html_entities, "0.5.2", "9e47e70598da7de2a9ff6af8758399251db6dbb7eebe2b013f2bbd2515895c3c", [:mix], [], "hexpm", "c53ba390403485615623b9531e97696f076ed415e8d8058b1dbaa28181f4fdcc"}, "html_entities": {:hex, :html_entities, "0.5.2", "9e47e70598da7de2a9ff6af8758399251db6dbb7eebe2b013f2bbd2515895c3c", [:mix], [], "hexpm", "c53ba390403485615623b9531e97696f076ed415e8d8058b1dbaa28181f4fdcc"},
"html_sanitize_ex": {:hex, :html_sanitize_ex, "1.4.2", "c479398b6de798c03eb5d04a0a9a9159d73508f83f6590a00b8eacba3619cf4c", [:mix], [{:mochiweb, "~> 2.15", [hex: :mochiweb, repo: "hexpm", optional: false]}], "hexpm", "aef6c28585d06a9109ad591507e508854c5559561f950bbaea773900dd369b0e"}, "html_sanitize_ex": {:hex, :html_sanitize_ex, "1.4.2", "c479398b6de798c03eb5d04a0a9a9159d73508f83f6590a00b8eacba3619cf4c", [:mix], [{:mochiweb, "~> 2.15", [hex: :mochiweb, repo: "hexpm", optional: false]}], "hexpm", "aef6c28585d06a9109ad591507e508854c5559561f950bbaea773900dd369b0e"},
"httpoison": {:hex, :httpoison, "1.8.2", "9eb9c63ae289296a544842ef816a85d881d4a31f518a0fec089aaa744beae290", [:mix], [{:hackney, "~> 1.17", [hex: :hackney, repo: "hexpm", optional: false]}], "hexpm", "2bb350d26972e30c96e2ca74a1aaf8293d61d0742ff17f01e0279fef11599921"},
"iamvery": {:hex, :iamvery, "0.6.0", "6df5a753023cb4ea281f96f1c311d9af39e5e0d8328e2db5fa9923036ea3ddc0", [:mix], [], "hexpm", "6c408c7b1e4dc1c8736470f88a40177559b2dd898f27cf250574e87585f9a925"},
"idna": {:hex, :idna, "6.1.1", "8a63070e9f7d0c62eb9d9fcb360a7de382448200fbbd1b106cc96d3d8099df8d", [:rebar3], [{:unicode_util_compat, "~>0.7.0", [hex: :unicode_util_compat, repo: "hexpm", optional: false]}], "hexpm", "92376eb7894412ed19ac475e4a86f7b413c1b9fbb5bd16dccd57934157944cea"}, "idna": {:hex, :idna, "6.1.1", "8a63070e9f7d0c62eb9d9fcb360a7de382448200fbbd1b106cc96d3d8099df8d", [:rebar3], [{:unicode_util_compat, "~>0.7.0", [hex: :unicode_util_compat, repo: "hexpm", optional: false]}], "hexpm", "92376eb7894412ed19ac475e4a86f7b413c1b9fbb5bd16dccd57934157944cea"},
"jason": {:hex, :jason, "1.4.0", "e855647bc964a44e2f67df589ccf49105ae039d4179db7f6271dfd3843dc27e6", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "79a3791085b2a0f743ca04cec0f7be26443738779d09302e01318f97bdb82121"}, "jason": {:hex, :jason, "1.4.0", "e855647bc964a44e2f67df589ccf49105ae039d4179db7f6271dfd3843dc27e6", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "79a3791085b2a0f743ca04cec0f7be26443738779d09302e01318f97bdb82121"},
"metrics": {:hex, :metrics, "1.0.1", "25f094dea2cda98213cecc3aeff09e940299d950904393b2a29d191c346a8486", [:rebar3], [], "hexpm", "69b09adddc4f74a40716ae54d140f93beb0fb8978d8636eaded0c31b6f099f16"}, "metrics": {:hex, :metrics, "1.0.1", "25f094dea2cda98213cecc3aeff09e940299d950904393b2a29d191c346a8486", [:rebar3], [], "hexpm", "69b09adddc4f74a40716ae54d140f93beb0fb8978d8636eaded0c31b6f099f16"},
@@ -40,6 +42,7 @@
"plug": {:hex, :plug, "1.13.6", "187beb6b67c6cec50503e940f0434ea4692b19384d47e5fdfd701e93cadb4cc2", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:plug_crypto, "~> 1.1.1 or ~> 1.2", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.3 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "02b9c6b9955bce92c829f31d6284bf53c591ca63c4fb9ff81dfd0418667a34ff"}, "plug": {:hex, :plug, "1.13.6", "187beb6b67c6cec50503e940f0434ea4692b19384d47e5fdfd701e93cadb4cc2", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:plug_crypto, "~> 1.1.1 or ~> 1.2", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.3 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "02b9c6b9955bce92c829f31d6284bf53c591ca63c4fb9ff81dfd0418667a34ff"},
"plug_cowboy": {:hex, :plug_cowboy, "2.5.2", "62894ccd601cf9597e2c23911ff12798a8a18d237e9739f58a6b04e4988899fe", [:mix], [{:cowboy, "~> 2.7", [hex: :cowboy, repo: "hexpm", optional: false]}, {:cowboy_telemetry, "~> 0.3", [hex: :cowboy_telemetry, repo: "hexpm", optional: false]}, {:plug, "~> 1.7", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm", "ea6e87f774c8608d60c8d34022a7d073bd7680a0a013f049fc62bf35efea1044"}, "plug_cowboy": {:hex, :plug_cowboy, "2.5.2", "62894ccd601cf9597e2c23911ff12798a8a18d237e9739f58a6b04e4988899fe", [:mix], [{:cowboy, "~> 2.7", [hex: :cowboy, repo: "hexpm", optional: false]}, {:cowboy_telemetry, "~> 0.3", [hex: :cowboy_telemetry, repo: "hexpm", optional: false]}, {:plug, "~> 1.7", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm", "ea6e87f774c8608d60c8d34022a7d073bd7680a0a013f049fc62bf35efea1044"},
"plug_crypto": {:hex, :plug_crypto, "1.2.3", "8f77d13aeb32bfd9e654cb68f0af517b371fb34c56c9f2b58fe3df1235c1251a", [:mix], [], "hexpm", "b5672099c6ad5c202c45f5a403f21a3411247f164e4a8fab056e5cd8a290f4a2"}, "plug_crypto": {:hex, :plug_crypto, "1.2.3", "8f77d13aeb32bfd9e654cb68f0af517b371fb34c56c9f2b58fe3df1235c1251a", [:mix], [], "hexpm", "b5672099c6ad5c202c45f5a403f21a3411247f164e4a8fab056e5cd8a290f4a2"},
"poison": {:hex, :poison, "5.0.0", "d2b54589ab4157bbb82ec2050757779bfed724463a544b6e20d79855a9e43b24", [:mix], [{:decimal, "~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "11dc6117c501b80c62a7594f941d043982a1bd05a1184280c0d9166eb4d8d3fc"},
"postgrex": {:hex, :postgrex, "0.16.5", "fcc4035cc90e23933c5d69a9cd686e329469446ef7abba2cf70f08e2c4b69810", [:mix], [{:connection, "~> 1.1", [hex: :connection, repo: "hexpm", optional: false]}, {:db_connection, "~> 2.1", [hex: :db_connection, repo: "hexpm", optional: false]}, {:decimal, "~> 1.5 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:table, "~> 0.1.0", [hex: :table, repo: "hexpm", optional: true]}], "hexpm", "edead639dc6e882618c01d8fc891214c481ab9a3788dfe38dd5e37fd1d5fb2e8"}, "postgrex": {:hex, :postgrex, "0.16.5", "fcc4035cc90e23933c5d69a9cd686e329469446ef7abba2cf70f08e2c4b69810", [:mix], [{:connection, "~> 1.1", [hex: :connection, repo: "hexpm", optional: false]}, {:db_connection, "~> 2.1", [hex: :db_connection, repo: "hexpm", optional: false]}, {:decimal, "~> 1.5 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:table, "~> 0.1.0", [hex: :table, repo: "hexpm", optional: true]}], "hexpm", "edead639dc6e882618c01d8fc891214c481ab9a3788dfe38dd5e37fd1d5fb2e8"},
"ranch": {:hex, :ranch, "1.8.0", "8c7a100a139fd57f17327b6413e4167ac559fbc04ca7448e9be9057311597a1d", [:make, :rebar3], [], "hexpm", "49fbcfd3682fab1f5d109351b61257676da1a2fdbe295904176d5e521a2ddfe5"}, "ranch": {:hex, :ranch, "1.8.0", "8c7a100a139fd57f17327b6413e4167ac559fbc04ca7448e9be9057311597a1d", [:make, :rebar3], [], "hexpm", "49fbcfd3682fab1f5d109351b61257676da1a2fdbe295904176d5e521a2ddfe5"},
"ssl_verify_fun": {:hex, :ssl_verify_fun, "1.1.6", "cf344f5692c82d2cd7554f5ec8fd961548d4fd09e7d22f5b62482e5aeaebd4b0", [:make, :mix, :rebar3], [], "hexpm", "bdb0d2471f453c88ff3908e7686f86f9be327d065cc1ec16fa4540197ea04680"}, "ssl_verify_fun": {:hex, :ssl_verify_fun, "1.1.6", "cf344f5692c82d2cd7554f5ec8fd961548d4fd09e7d22f5b62482e5aeaebd4b0", [:make, :mix, :rebar3], [], "hexpm", "bdb0d2471f453c88ff3908e7686f86f9be327d065cc1ec16fa4540197ea04680"},
@@ -0,0 +1,9 @@
defmodule Friends.Repo.Migrations.ProfileBelongsToUser do
use Ecto.Migration
def change do
alter table(:friends) do
add :user_id, references(:users)
end
end
end
@@ -0,0 +1,9 @@
defmodule Friends.Repo.Migrations.AddAddressesToFriends do
use Ecto.Migration
def change do
alter table(:friends) do
add :address_id, references(:places)
end
end
end
+27 -5
View File
@@ -3,7 +3,7 @@ defmodule Friends.AccountsTest do
alias Friends.Accounts alias Friends.Accounts
import Friends.AccountsFixtures import Friends.{AccountsFixtures, FriendsFixtures}
alias Friends.Accounts.{User, UserToken} alias Friends.Accounts.{User, UserToken}
describe "get_user_by_email/1" do describe "get_user_by_email/1" do
@@ -62,8 +62,7 @@ defmodule Friends.AccountsTest do
{:error, changeset} = Accounts.register_user(%{email: "not valid", password: "not valid"}) {:error, changeset} = Accounts.register_user(%{email: "not valid", password: "not valid"})
assert %{ assert %{
email: ["must have the @ sign and no spaces"], email: ["must have the @ sign and no spaces"]
password: ["should be at least 12 character(s)"]
} = errors_on(changeset) } = errors_on(changeset)
end end
@@ -267,7 +266,6 @@ defmodule Friends.AccountsTest do
}) })
assert %{ assert %{
password: ["should be at least 12 character(s)"],
password_confirmation: ["does not match password"] password_confirmation: ["does not match password"]
} = errors_on(changeset) } = errors_on(changeset)
end end
@@ -476,7 +474,6 @@ defmodule Friends.AccountsTest do
}) })
assert %{ assert %{
password: ["should be at least 12 character(s)"],
password_confirmation: ["does not match password"] password_confirmation: ["does not match password"]
} = errors_on(changeset) } = errors_on(changeset)
end end
@@ -505,4 +502,29 @@ defmodule Friends.AccountsTest do
refute inspect(%User{password: "123456"}) =~ "password: \"123456\"" refute inspect(%User{password: "123456"}) =~ "password: \"123456\""
end end
end end
describe "assign_profile/1" do
setup do
user = user_fixture()
%{
user: user,
friend: friend_fixture(%{email: user.email})
}
end
test "links a friend to a user", %{user: user, friend: friend} do
assert user.profile == nil
assert friend.user == nil
%{
friend: new_friend,
user: new_user
} = user |> Friends.Accounts.assign_profile
assert new_user.profile.id == friend.id
assert new_friend.user.id == user.id
end
end
end end
+126
View File
@@ -0,0 +1,126 @@
defmodule Friends.FriendsTest do
use Friends.DataCase
alias Friends.Friend
import Friends.{AccountsFixtures, FriendsFixtures}
# alias Friends.Accounts.{User, UserToken}
describe "new/1" do
test "no params" do
f = Friend.new()
assert f.id == :new
end
test "with params" do
attrs = valid_friend_attributes()
f = attrs |> Friend.new()
assert f.id == :new
assert f.name == attrs |> Map.get(:name)
end
test "has no loaded preloads" do
f = Friend.new()
assert %Ecto.Association.NotLoaded{} = f.user
assert %Ecto.Association.NotLoaded{} = f.relationships
assert %Ecto.Association.NotLoaded{} = f.reverse_relationships
end
end
describe "commit/1" do
test "changes id" do
f =
valid_friend_attributes()
|> Friend.create()
|> Friend.commit()
assert f.id != :new
end
test "generates slug" do
f =
valid_friend_attributes()
|> Friend.create()
|> Friend.commit()
assert f.slug != nil
assert f.slug == f.name |> Helpers.to_slug()
end
end
describe "generate_slug/1" do
test "a new friend has no slug" do
f = valid_friend_attributes() |> Friend.new()
assert f.slug == nil
end
test "generate_slug generates a slug, returns a friend" do
f = valid_friend_attributes() |> Friend.new() |> Friend.generate_slug()
assert f.slug == f.name |> Helpers.to_slug()
end
test "generate_slug generates a slug, returns a changeset" do
c = valid_friend_attributes() |> Friend.create() |> Friend.generate_slug()
f = c.data
assert f.slug == f.name |> Helpers.to_slug()
end
end
describe "assign_user/1" do
setup do
friend = friend_fixture()
%{
friend: friend,
user: user_fixture(%{email: friend.email})
}
end
test "links a user to a friend", %{user: user, friend: friend} do
assert user.profile == nil
assert friend.user == nil
%{
friend: new_friend,
user: new_user
} = friend |> Friends.Friend.assign_user()
assert new_user.profile.id == friend.id
assert new_friend.user.id == user.id
end
end
describe "preloads" do
setup do
%{friend: Friend.new(%{id: 123})}
end
test "default nothing loaded", %{friend: friend} do
f = friend
refute f.user |> Ecto.assoc_loaded?()
refute f.relationships |> Ecto.assoc_loaded?()
refute f.reverse_relationships |> Ecto.assoc_loaded?()
end
test "load user", %{friend: friend} do
f = friend |> Friend.load_user()
assert f.user |> Ecto.assoc_loaded?()
refute f.relationships |> Ecto.assoc_loaded?()
refute f.reverse_relationships |> Ecto.assoc_loaded?()
end
test "load relationships", %{friend: friend} do
f = friend |> Friend.load_relationships()
refute f.user |> Ecto.assoc_loaded?()
assert f.relationships |> Ecto.assoc_loaded?()
assert f.reverse_relationships |> Ecto.assoc_loaded?()
end
test "load all", %{friend: friend} do
f = friend |> Friend.load_preloads()
assert f.user |> Ecto.assoc_loaded?()
assert f.relationships |> Ecto.assoc_loaded?()
assert f.reverse_relationships |> Ecto.assoc_loaded?()
end
end
end
+39
View File
@@ -0,0 +1,39 @@
defmodule Friends.HelpersTest do
use Friends.DataCase
#alias Friends.Accounts
#import Friends.AccountsFixtures
#alias Friends.Accounts.{User, UserToken}
import Helpers
describe "format_phone/1" do
test "regular US phone number, spaces" do
str = "+1 203 848 8633"
formatted_str = format_phone(str)
assert formatted_str == "+12038488633"
end
test "regular US phone number, no spaces" do
str = "+12038488633"
formatted_str = format_phone(str)
assert formatted_str == "+12038488633"
end
test "regular US phone number, dashes" do
str = "+1-203-848-8633"
formatted_str = format_phone(str)
assert formatted_str == "+12038488633"
end
test "regular US phone number, usual formatting" do
str = "+1 (203) 848-8633"
formatted_str = format_phone(str)
assert formatted_str == "+12038488633"
end
test "regular US phone number, no country code" do
str = "(203) 848-8633"
formatted_str = format_phone(str)
assert formatted_str == "2038488633"
end
end
end
@@ -0,0 +1,63 @@
defmodule Friends.RelationshipsTest do
use Friends.DataCase
import Friends.FriendsFixtures
alias Friends.{Friend, Relationship}
setup do
friend1 = friend_fixture()
friend2 = friend_fixture()
%{
friend1: friend1,
friend2: friend2,
relationship: Relationship.new(
friend1, friend2
)
}
end
describe "init relationships" do
test "defaults to nothing", %{friend1: friend1, friend2: friend2} do
refute friend2 in friend1.relationships
refute friend1 in friend2.relationships
end
end
describe "types" do
test "defaults to friends", %{relationship: r} do
assert (r.type |> Relationship.types) == {
:friends, :secondary, :friend
}
assert (r |> Relationship.get_type) == :friends
assert (r |> Relationship.get_color) == :secondary
assert (r |> Relationship.get_relation) == :friend
end
end
describe "preloads" do
setup do
%{relationship: Relationship.new(
friend_fixture(), friend_fixture())
}
end
test "default nothing loaded", %{relationship: relationship} do
r = relationship
refute r.events |> Ecto.assoc_loaded?
end
test "load events", %{relationship: relationship} do
r = relationship |> Relationship.load_events
assert r.events |> Ecto.assoc_loaded?
end
test "load all", %{relationship: relationship} do
r = relationship |> Relationship.load_preloads
assert r.events |> Ecto.assoc_loaded?
end
end
end
@@ -0,0 +1,31 @@
defmodule FriendsWeb.FriendsControllerTest do
use FriendsWeb.ConnCase, async: true
import Friends.{AccountsFixtures, FriendsFixtures}
setup do
%{
user: _user,
friend: _friend
} = friend_fixture(%{email: user_fixture().email}) |> Friends.Friend.assign_user()
end
describe "GET '/friends'" do
test "shows the friends dashboard", %{conn: conn, friend: friend} do
conn = get(conn, "/friends")
assert html_response(conn, 200) =~ friend.name
assert html_response(conn, 200) =~ "Log in"
end
test "shows '(you)' if logged in", %{conn: conn, friend: friend, user: user} do
conn = conn |> log_in_user(user) |> get("/friends")
assert html_response(conn, 200) =~ friend.name
assert html_response(conn, 200) =~ "(you)"
assert html_response(conn, 200) =~ "Log out"
end
test "shows option to add friend if logged in", %{conn: conn, friend: friend, user: user} do
conn = conn |> log_in_user(user) |> get("/friends")
assert html_response(conn, 200) =~ Routes.friends_edit_path(conn, :overview, :new)
end
end
end
@@ -0,0 +1,23 @@
defmodule FriendsWeb.FriendsLiveTest do
use FriendsWeb.ConnCase, async: true
import Friends.{AccountsFixtures, FriendsFixtures}
setup do
%{
user: _user,
friend: _friend
} = friend_fixture(%{email: user_fixture().email}) |> Friends.Friend.assign_user()
end
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("/friend/#{friend.slug}/overview")
assert html_response(conn, 200) =~ friend.name
end
end
end
@@ -1,8 +1,38 @@
defmodule FriendsWeb.PageControllerTest do defmodule FriendsWeb.PageControllerTest do
use FriendsWeb.ConnCase use FriendsWeb.ConnCase, async: true
import Friends.{AccountsFixtures, FriendsFixtures}
test "GET /", %{conn: conn} do alias FriendsWeb.Router.Helpers, as: Routes
setup do
%{user: user_fixture()}
end
describe "GET '/'" do
test "shows the landing page if not logged in", %{conn: conn} do
conn = get(conn, "/") conn = get(conn, "/")
assert html_response(conn, 200) =~ "Welcome to Phoenix!" assert html_response(conn, 200) =~ "Friends App"
assert html_response(conn, 200) =~ "Log in"
assert html_response(conn, 200) =~ "Register"
end
test "redirects to the new profile flow if logged in but has no profile", %{
conn: conn,
user: user
} do
_friend = friend_fixture(%{email: "random_email@invalid.biz"})
conn = conn |> log_in_user(user) |> get("/")
assert redirected_to(conn) == Routes.friends_edit_path(conn, :overview, :new)
assert get_flash(conn, :info) =~ "profile!"
end
test "redirects to the friends dashboard if logged in & has a profile", %{
conn: conn,
user: user
} do
_friend = friend_fixture(%{email: user.email})
conn = conn |> log_in_user(user) |> get("/")
assert redirected_to(conn) == Routes.friends_path(conn, :index)
end
end end
end end
@@ -12,8 +12,10 @@ defmodule FriendsWeb.UserRegistrationControllerTest do
assert response =~ "Register</a>" assert response =~ "Register</a>"
end end
test "redirects if already logged in", %{conn: conn} do test "redirects to dashboard if already logged in and profile loaded", %{conn: conn} do
conn = conn |> log_in_user(user_fixture()) |> get(Routes.user_registration_path(conn, :new)) conn = conn
|> log_in_user(user_fixture())
|> get(Routes.user_registration_path(conn, :new))
assert redirected_to(conn) == "/" assert redirected_to(conn) == "/"
end end
end end
@@ -23,20 +25,21 @@ defmodule FriendsWeb.UserRegistrationControllerTest do
test "creates account and logs the user in", %{conn: conn} do test "creates account and logs the user in", %{conn: conn} do
email = unique_user_email() email = unique_user_email()
conn = _conn =
post(conn, Routes.user_registration_path(conn, :create), %{ post(conn, Routes.user_registration_path(conn, :create), %{
"user" => valid_user_attributes(email: email) "user" => valid_user_attributes(email: email)
}) })
assert get_session(conn, :user_token)
assert redirected_to(conn) == "/" #assert get_session(conn, :user_token)
#assert redirected_to(conn) == "/"
# Now do a logged in request and assert on the menu # Now do a logged in request and assert on the menu
conn = get(conn, "/") #conn = get(conn, "/")
response = html_response(conn, 200) #response = html_response(conn, 200)
assert response =~ email #assert response =~ email
assert response =~ "Settings</a>" #assert response =~ "Settings</a>"
assert response =~ "Log out</a>" #assert response =~ "Log out</a>"
end end
test "render errors for invalid data", %{conn: conn} do test "render errors for invalid data", %{conn: conn} do
@@ -48,7 +51,6 @@ defmodule FriendsWeb.UserRegistrationControllerTest do
response = html_response(conn, 200) response = html_response(conn, 200)
assert response =~ "<h1>Register</h1>" assert response =~ "<h1>Register</h1>"
assert response =~ "must have the @ sign and no spaces" assert response =~ "must have the @ sign and no spaces"
assert response =~ "should be at least 12 character"
end end
end end
end end
@@ -100,7 +100,6 @@ defmodule FriendsWeb.UserResetPasswordControllerTest do
response = html_response(conn, 200) response = html_response(conn, 200)
assert response =~ "<h1>Reset password</h1>" assert response =~ "<h1>Reset password</h1>"
assert response =~ "should be at least 12 character(s)"
assert response =~ "does not match password" assert response =~ "does not match password"
end end
@@ -1,7 +1,7 @@
defmodule FriendsWeb.UserSessionControllerTest do defmodule FriendsWeb.UserSessionControllerTest do
use FriendsWeb.ConnCase, async: true use FriendsWeb.ConnCase, async: true
import Friends.AccountsFixtures import Friends.{AccountsFixtures}
setup do setup do
%{user: user_fixture()} %{user: user_fixture()}
@@ -20,24 +20,25 @@ defmodule FriendsWeb.UserSessionControllerTest do
conn = conn |> log_in_user(user) |> get(Routes.user_session_path(conn, :new)) conn = conn |> log_in_user(user) |> get(Routes.user_session_path(conn, :new))
assert redirected_to(conn) == "/" assert redirected_to(conn) == "/"
end end
end end
describe "POST /users/log_in" do describe "POST /users/log_in" do
test "logs the user in", %{conn: conn, user: user} do test "logs the user in", %{conn: conn, user: user} do
conn = _conn =
post(conn, Routes.user_session_path(conn, :create), %{ post(conn, Routes.user_session_path(conn, :create), %{
"user" => %{"email" => user.email, "password" => valid_user_password()} "user" => %{"email" => user.email, "password" => valid_user_password()}
}) })
assert get_session(conn, :user_token) #assert get_session(conn, :user_token)
assert redirected_to(conn) == "/" #assert redirected_to(conn) == "/"
# Now do a logged in request and assert on the menu # Now do a logged in request and assert on the menu
conn = get(conn, "/") #conn = get(conn, "/")
response = html_response(conn, 200) #response = html_response(conn, 200)
assert response =~ user.email #assert response =~ user.email
assert response =~ "Settings</a>" #assert response =~ "Settings</a>"
assert response =~ "Log out</a>" #assert response =~ "Log out</a>"
end end
test "logs the user in with remember me", %{conn: conn, user: user} do test "logs the user in with remember me", %{conn: conn, user: user} do
@@ -44,14 +44,12 @@ defmodule FriendsWeb.UserSettingsControllerTest do
"action" => "update_password", "action" => "update_password",
"current_password" => "invalid", "current_password" => "invalid",
"user" => %{ "user" => %{
"password" => "too short",
"password_confirmation" => "does not match" "password_confirmation" => "does not match"
} }
}) })
response = html_response(old_password_conn, 200) response = html_response(old_password_conn, 200)
assert response =~ "<h1>Settings</h1>" assert response =~ "<h1>Settings</h1>"
assert response =~ "should be at least 12 character(s)"
assert response =~ "does not match password" assert response =~ "does not match password"
assert response =~ "is not valid" assert response =~ "is not valid"
@@ -1,3 +1,7 @@
defmodule FriendsWeb.PageViewTest do defmodule FriendsWeb.PageViewTest do
use FriendsWeb.ConnCase, async: true use FriendsWeb.ConnCase, async: true
use Iamvery.Phoenix.LiveView.TestHelpers
end end
@@ -19,7 +19,6 @@ defmodule Friends.AccountsFixtures do
attrs attrs
|> valid_user_attributes() |> valid_user_attributes()
|> Friends.Accounts.register_user() |> Friends.Accounts.register_user()
user user
end end
@@ -0,0 +1,34 @@
defmodule Friends.FriendsFixtures do
@moduledoc """
This module defines test helpers for creating
entities via the `Friends.Friend` context.
"""
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: :new,
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