├── elixir_buildpack.config
├── Procfile
├── web
├── views
│ ├── page_view.ex
│ ├── layout_view.ex
│ ├── response_view.ex
│ ├── session_view.ex
│ ├── forum_view.ex
│ ├── error_view.ex
│ ├── feedback_view.ex
│ └── error_helpers.ex
├── static
│ ├── assets
│ │ ├── favicon.ico
│ │ ├── images
│ │ │ ├── phoenix.png
│ │ │ ├── dwyl-heart-only-logo.png
│ │ │ ├── dwyl-heart-only-logo-white.png
│ │ │ ├── tick.svg
│ │ │ ├── locked.svg
│ │ │ ├── edit.svg
│ │ │ ├── back-arrow-button.svg
│ │ │ ├── neutral.svg
│ │ │ ├── confused.svg
│ │ │ ├── happy.svg
│ │ │ ├── angry.svg
│ │ │ ├── sad.svg
│ │ │ └── delighted.svg
│ │ └── robots.txt
│ ├── js
│ │ ├── app.js
│ │ └── socket.js
│ └── css
│ │ └── app.css
├── templates
│ ├── feedback
│ │ ├── sad.html.eex
│ │ ├── angry.html.eex
│ │ ├── happy.html.eex
│ │ ├── confused.html.eex
│ │ ├── delighted.html.eex
│ │ ├── neutral.html.eex
│ │ ├── index.html.eex
│ │ ├── dashboard.html.eex
│ │ ├── new.html.eex
│ │ └── show.html.eex
│ ├── session
│ │ └── new.html.eex
│ ├── forum
│ │ ├── forum.html.eex
│ │ └── forum_show.html.eex
│ ├── layout
│ │ ├── app.html.eex
│ │ ├── index.html.eex
│ │ ├── nav.html.eex
│ │ └── forum.html.eex
│ └── page
│ │ └── index.html.eex
├── models
│ ├── forum.ex
│ ├── response.ex
│ ├── feedback.ex
│ └── user.ex
├── controllers
│ ├── page_controller.ex
│ ├── forum_controller.ex
│ ├── session_controller.ex
│ ├── auth.ex
│ ├── response_controller.ex
│ ├── helpers.ex
│ └── feedback_controller.ex
├── channels
│ ├── room_channel.ex
│ └── user_socket.ex
├── gettext.ex
├── router.ex
└── web.ex
├── lib
├── feedback
│ ├── repo.ex
│ ├── mailer.ex
│ └── endpoint.ex
├── email.ex
└── feedback.ex
├── test
├── test_helper.exs
├── views
│ ├── page_view_test.exs
│ ├── layout_view_test.exs
│ ├── error_view_test.exs
│ └── feedback_view_test.exs
├── controllers
│ ├── page_controller_test.exs
│ ├── forum_controller_test.exs
│ ├── session_controller_test.exs
│ ├── auth_test.exs
│ ├── response_controller_test.exs
│ └── feedback_controller_test.exs
├── channels
│ └── room_channel_test.exs
├── models
│ ├── response_test.exs
│ ├── feedback_test.exs
│ └── user_test.exs
└── support
│ ├── channel_case.ex
│ ├── conn_case.ex
│ ├── test_helpers.ex
│ └── model_case.ex
├── .buildpacks
├── priv
├── repo
│ ├── migrations
│ │ ├── 20170412151545_add_mood_to_feedback.exs
│ │ ├── 20170413200800_add_privacy_to_feedback.exs
│ │ ├── 20170413213659_add_public_to_feedback.exs
│ │ ├── 20170413214226_remove_private_from_feedback.exs
│ │ ├── 20170418191425_remove_responded_from_feedback.exs
│ │ ├── 20170425155958_remove_response_from_feedback.exs
│ │ ├── 20170418184335_add_responded_at_to_feedback.exs
│ │ ├── 20170425160206_remove_responded_at_from_feedback.exs
│ │ ├── 20170419174138_add_edit_to_feedback.exs
│ │ ├── 20170406160040_create_feedback.exs
│ │ ├── 20170406135758_create_user.exs
│ │ └── 20170425145025_create_response.exs
│ └── seeds.exs
└── gettext
│ ├── en
│ └── LC_MESSAGES
│ │ └── errors.po
│ └── errors.pot
├── coveralls.json
├── .travis.yml
├── codecov.yml
├── package.json
├── .gitignore
├── config
├── test.exs
├── config.exs
├── dev.exs
└── prod.exs
├── brunch-config.js
├── mix.exs
├── README.md
├── research.md
└── mix.lock
/elixir_buildpack.config:
--------------------------------------------------------------------------------
1 | always_rebuild=true
2 |
--------------------------------------------------------------------------------
/Procfile:
--------------------------------------------------------------------------------
1 | web: MIX_ENV=prod mix run priv/repo/seeds.exs && mix phoenix.server
2 |
--------------------------------------------------------------------------------
/web/views/page_view.ex:
--------------------------------------------------------------------------------
1 | defmodule Feedback.PageView do
2 | use Feedback.Web, :view
3 | end
4 |
--------------------------------------------------------------------------------
/lib/feedback/repo.ex:
--------------------------------------------------------------------------------
1 | defmodule Feedback.Repo do
2 | use Ecto.Repo, otp_app: :feedback
3 | end
4 |
--------------------------------------------------------------------------------
/web/views/layout_view.ex:
--------------------------------------------------------------------------------
1 | defmodule Feedback.LayoutView do
2 | use Feedback.Web, :view
3 | end
4 |
--------------------------------------------------------------------------------
/web/views/response_view.ex:
--------------------------------------------------------------------------------
1 | defmodule Feedback.ResponseView do
2 | use Feedback.Web, :view
3 | end
4 |
--------------------------------------------------------------------------------
/web/views/session_view.ex:
--------------------------------------------------------------------------------
1 | defmodule Feedback.SessionView do
2 | use Feedback.Web, :view
3 | end
4 |
--------------------------------------------------------------------------------
/lib/feedback/mailer.ex:
--------------------------------------------------------------------------------
1 | defmodule Feedback.Mailer do
2 | use Bamboo.Mailer, otp_app: :feedback
3 | end
4 |
--------------------------------------------------------------------------------
/test/test_helper.exs:
--------------------------------------------------------------------------------
1 | ExUnit.start
2 |
3 | Ecto.Adapters.SQL.Sandbox.mode(Feedback.Repo, :manual)
4 |
5 |
--------------------------------------------------------------------------------
/web/static/assets/favicon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/dwyl/feedback/HEAD/web/static/assets/favicon.ico
--------------------------------------------------------------------------------
/test/views/page_view_test.exs:
--------------------------------------------------------------------------------
1 | defmodule Feedback.PageViewTest do
2 | use Feedback.ConnCase, async: true
3 | end
4 |
--------------------------------------------------------------------------------
/test/views/layout_view_test.exs:
--------------------------------------------------------------------------------
1 | defmodule Feedback.LayoutViewTest do
2 | use Feedback.ConnCase, async: true
3 | end
4 |
--------------------------------------------------------------------------------
/web/static/assets/images/phoenix.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/dwyl/feedback/HEAD/web/static/assets/images/phoenix.png
--------------------------------------------------------------------------------
/.buildpacks:
--------------------------------------------------------------------------------
1 | https://github.com/HashNuke/heroku-buildpack-elixir.git
2 | https://github.com/gjaldon/heroku-buildpack-phoenix-static.git
3 |
--------------------------------------------------------------------------------
/web/static/assets/images/dwyl-heart-only-logo.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/dwyl/feedback/HEAD/web/static/assets/images/dwyl-heart-only-logo.png
--------------------------------------------------------------------------------
/web/static/assets/images/dwyl-heart-only-logo-white.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/dwyl/feedback/HEAD/web/static/assets/images/dwyl-heart-only-logo-white.png
--------------------------------------------------------------------------------
/web/views/forum_view.ex:
--------------------------------------------------------------------------------
1 | defmodule Feedback.ForumView do
2 | use Feedback.Web, :view
3 | import Feedback.FeedbackView, only: [truncate_text: 2, format_date: 1]
4 | end
5 |
--------------------------------------------------------------------------------
/web/templates/feedback/sad.html.eex:
--------------------------------------------------------------------------------
1 | <%= render "dashboard.html",
2 | feedback: @feedback,
3 | responded_feedback: @responded_feedback,
4 | conn: @conn, current_user: @current_user,
5 | emotion: "sad" %>
6 |
--------------------------------------------------------------------------------
/web/templates/feedback/angry.html.eex:
--------------------------------------------------------------------------------
1 | <%= render "dashboard.html",
2 | feedback: @feedback,
3 | responded_feedback: @responded_feedback,
4 | conn: @conn, current_user: @current_user,
5 | emotion: "angry" %>
6 |
--------------------------------------------------------------------------------
/web/templates/feedback/happy.html.eex:
--------------------------------------------------------------------------------
1 | <%= render "dashboard.html",
2 | feedback: @feedback,
3 | responded_feedback: @responded_feedback,
4 | conn: @conn, current_user: @current_user,
5 | emotion: "happy" %>
6 |
--------------------------------------------------------------------------------
/web/templates/feedback/confused.html.eex:
--------------------------------------------------------------------------------
1 | <%= render "dashboard.html",
2 | feedback: @feedback,
3 | responded_feedback: @responded_feedback,
4 | conn: @conn, current_user: @current_user,
5 | emotion: "confused" %>
6 |
--------------------------------------------------------------------------------
/web/templates/feedback/delighted.html.eex:
--------------------------------------------------------------------------------
1 | <%= render "dashboard.html",
2 | feedback: @feedback,
3 | responded_feedback: @responded_feedback,
4 | conn: @conn, current_user: @current_user,
5 | emotion: "delighted" %>
6 |
--------------------------------------------------------------------------------
/web/templates/feedback/neutral.html.eex:
--------------------------------------------------------------------------------
1 | <%= render "dashboard.html",
2 | feedback: @feedback,
3 | responded_feedback: @responded_feedback,
4 | conn: @conn, current_user: @current_user,
5 | emotion: "neutral" %>
6 |
--------------------------------------------------------------------------------
/web/models/forum.ex:
--------------------------------------------------------------------------------
1 | defmodule Feedback.Forum do
2 | use Feedback.Web, :model
3 |
4 | defimpl Phoenix.Param, for: Feedback.Forum do
5 | def to_param(%{id: id}) do
6 | "#{id}"
7 | end
8 | end
9 | end
10 |
--------------------------------------------------------------------------------
/web/static/assets/robots.txt:
--------------------------------------------------------------------------------
1 | # See http://www.robotstxt.org/robotstxt.html for documentation on how to use the robots.txt file
2 | #
3 | # To ban all spiders from the entire site uncomment the next two lines:
4 | # User-agent: *
5 | # Disallow: /
6 |
--------------------------------------------------------------------------------
/priv/repo/migrations/20170412151545_add_mood_to_feedback.exs:
--------------------------------------------------------------------------------
1 | defmodule Feedback.Repo.Migrations.AddMoodToFeedback do
2 | use Ecto.Migration
3 |
4 | def change do
5 | alter table(:feedback) do
6 | add :mood, :string
7 | end
8 | end
9 | end
10 |
--------------------------------------------------------------------------------
/priv/repo/migrations/20170413200800_add_privacy_to_feedback.exs:
--------------------------------------------------------------------------------
1 | defmodule Feedback.Repo.Migrations.AddPrivacyToFeedback do
2 | use Ecto.Migration
3 |
4 | def change do
5 | alter table(:feedback) do
6 | add :private, :boolean
7 | end
8 | end
9 | end
10 |
--------------------------------------------------------------------------------
/priv/repo/migrations/20170413213659_add_public_to_feedback.exs:
--------------------------------------------------------------------------------
1 | defmodule Feedback.Repo.Migrations.AddPublicToFeedback do
2 | use Ecto.Migration
3 |
4 | def change do
5 | alter table(:feedback) do
6 | add :public, :boolean
7 | end
8 | end
9 | end
10 |
--------------------------------------------------------------------------------
/priv/repo/migrations/20170413214226_remove_private_from_feedback.exs:
--------------------------------------------------------------------------------
1 | defmodule Feedback.Repo.Migrations.RemovePrivateFromFeedback do
2 | use Ecto.Migration
3 |
4 | def change do
5 | alter table(:feedback) do
6 | remove :private
7 | end
8 | end
9 | end
10 |
--------------------------------------------------------------------------------
/priv/repo/migrations/20170418191425_remove_responded_from_feedback.exs:
--------------------------------------------------------------------------------
1 | defmodule Feedback.Repo.Migrations.RemoveRespondedFromFeedback do
2 | use Ecto.Migration
3 |
4 | def change do
5 | alter table(:feedback) do
6 | remove :responded
7 | end
8 | end
9 | end
10 |
--------------------------------------------------------------------------------
/priv/repo/migrations/20170425155958_remove_response_from_feedback.exs:
--------------------------------------------------------------------------------
1 | defmodule Feedback.Repo.Migrations.RemoveResponseFromFeedback do
2 | use Ecto.Migration
3 |
4 | def change do
5 | alter table(:feedback) do
6 | remove :response
7 | end
8 | end
9 | end
10 |
--------------------------------------------------------------------------------
/priv/repo/migrations/20170418184335_add_responded_at_to_feedback.exs:
--------------------------------------------------------------------------------
1 | defmodule Feedback.Repo.Migrations.AddRespondedAtToFeedback do
2 | use Ecto.Migration
3 |
4 | def change do
5 | alter table(:feedback) do
6 | add :responded_at, :date
7 | end
8 | end
9 | end
10 |
--------------------------------------------------------------------------------
/priv/repo/migrations/20170425160206_remove_responded_at_from_feedback.exs:
--------------------------------------------------------------------------------
1 | defmodule Feedback.Repo.Migrations.RemoveRespondedAtFromFeedback do
2 | use Ecto.Migration
3 |
4 | def change do
5 | alter table(:feedback) do
6 | remove :responded_at
7 | end
8 | end
9 | end
10 |
--------------------------------------------------------------------------------
/priv/repo/migrations/20170419174138_add_edit_to_feedback.exs:
--------------------------------------------------------------------------------
1 | defmodule Feedback.Repo.Migrations.AddEditToFeedback do
2 | use Ecto.Migration
3 |
4 | def change do
5 | alter table(:feedback) do
6 | add :edit, :boolean
7 | add :edited, :boolean
8 | end
9 | end
10 | end
11 |
--------------------------------------------------------------------------------
/coveralls.json:
--------------------------------------------------------------------------------
1 | {
2 | "coverage_options": {
3 | "minimum_coverage": 100
4 | },
5 | "skip_files": [
6 | "lib/feedback.ex",
7 | "test/support/model_case.ex",
8 | "test/support/channel_case.ex",
9 | "web/web.ex",
10 | "web/views/error_helpers.ex",
11 | "web/models/forum.ex"
12 | ]
13 | }
14 |
--------------------------------------------------------------------------------
/lib/email.ex:
--------------------------------------------------------------------------------
1 | defmodule Feedback.Email do
2 | use Bamboo.Phoenix, view: Feedback.FeedbackView
3 |
4 | def send_email(to_email_address, subject, message) do
5 | new_email()
6 | |> to(to_email_address)
7 | |> from(System.get_env("ADMIN_EMAIL")) # also needs to be a validated email
8 | |> subject(subject)
9 | |> text_body(message)
10 | end
11 | end
12 |
--------------------------------------------------------------------------------
/web/controllers/page_controller.ex:
--------------------------------------------------------------------------------
1 | defmodule Feedback.PageController do
2 | use Feedback.Web, :controller
3 |
4 | def index(conn, _params) do
5 | case conn.assigns.current_user do
6 | nil ->
7 | conn
8 | |> redirect(to: feedback_path(conn, :new))
9 | _user ->
10 | conn
11 | |> redirect(to: feedback_path(conn, :index))
12 | end
13 | end
14 | end
15 |
--------------------------------------------------------------------------------
/.travis.yml:
--------------------------------------------------------------------------------
1 | language: elixir
2 | elixir:
3 | - 1.4.1
4 | addons:
5 | postgresql: '9.4'
6 | apt:
7 | packages:
8 | - wkhtmltopdf
9 | env:
10 | - MIX_ENV=test
11 | before_script:
12 | - mix do ecto.create, ecto.migrate
13 | script:
14 | - mix do deps.get, compile --warnings-as-errors, coveralls.json
15 | after_success:
16 | - bash <(curl -s https://codecov.io/bash)
17 | notifications:
18 | email: false
19 |
--------------------------------------------------------------------------------
/priv/repo/migrations/20170406160040_create_feedback.exs:
--------------------------------------------------------------------------------
1 | defmodule Feedback.Repo.Migrations.CreateFeedback do
2 | use Ecto.Migration
3 |
4 | def change do
5 | create table(:feedback) do
6 | add :item, :text
7 | add :response, :text
8 | add :responded, :boolean
9 | add :submitter_email, :string
10 | add :permalink_string, :string
11 |
12 | timestamps()
13 | end
14 | end
15 | end
16 |
--------------------------------------------------------------------------------
/priv/repo/migrations/20170406135758_create_user.exs:
--------------------------------------------------------------------------------
1 | defmodule Feedback.Repo.Migrations.CreateUser do
2 | use Ecto.Migration
3 |
4 | def change do
5 | create table(:users) do
6 | add :first_name, :string
7 | add :last_name, :string
8 | add :email, :string
9 | add :password_hash, :string
10 |
11 | timestamps()
12 | end
13 |
14 | create unique_index(:users, [:email])
15 | end
16 | end
17 |
--------------------------------------------------------------------------------
/priv/repo/seeds.exs:
--------------------------------------------------------------------------------
1 | alias Feedback.{Repo, User}
2 |
3 | case Repo.get_by(User, first_name: "Admin") do
4 | nil ->
5 | Repo.insert! %User{
6 | first_name: "Admin",
7 | last_name: "Account",
8 | email: System.get_env("ADMIN_EMAIL"),
9 | password: System.get_env("ADMIN_PASSWORD"),
10 | password_hash: Comeonin.Bcrypt.hashpwsalt(System.get_env("ADMIN_PASSWORD"))
11 | }
12 | _user -> IO.puts "Admin already in database"
13 | end
14 |
--------------------------------------------------------------------------------
/web/views/error_view.ex:
--------------------------------------------------------------------------------
1 | defmodule Feedback.ErrorView do
2 | use Feedback.Web, :view
3 |
4 | def render("404.html", _assigns) do
5 | "Page not found"
6 | end
7 |
8 | def render("500.html", _assigns) do
9 | "Internal server error"
10 | end
11 |
12 | # In case no render clause matches or no
13 | # template is found, let's render it as 500
14 | def template_not_found(_template, assigns) do
15 | render "500.html", assigns
16 | end
17 | end
18 |
--------------------------------------------------------------------------------
/priv/repo/migrations/20170425145025_create_response.exs:
--------------------------------------------------------------------------------
1 | defmodule Feedback.Repo.Migrations.CreateResponse do
2 | use Ecto.Migration
3 |
4 | def change do
5 | create table(:response) do
6 | add :response, :text
7 | add :edit, :boolean
8 | add :edited, :boolean
9 | add :feedback_id, references(:feedback, on_delete: :delete_all)
10 |
11 | timestamps()
12 | end
13 |
14 | create index(:response, [:feedback_id])
15 | end
16 | end
17 |
--------------------------------------------------------------------------------
/test/controllers/page_controller_test.exs:
--------------------------------------------------------------------------------
1 | defmodule Feedback.PageControllerTest do
2 | use Feedback.ConnCase
3 |
4 | test "GET / not logged in", %{conn: conn} do
5 | conn = get conn, "/"
6 | assert redirected_to(conn, 302) =~ "/feedback/new"
7 | end
8 |
9 | test "GET / logged in", %{conn: conn} do
10 | user = insert_validated_user()
11 | conn =
12 | conn
13 | |> assign(:current_user, user)
14 | conn = get conn, "/"
15 | assert redirected_to(conn, 302) =~ "/feedback"
16 | end
17 | end
18 |
--------------------------------------------------------------------------------
/web/models/response.ex:
--------------------------------------------------------------------------------
1 | defmodule Feedback.Response do
2 | use Feedback.Web, :model
3 |
4 | schema "response" do
5 | field :response, :string
6 | field :edit, :boolean
7 | field :edited, :boolean
8 | belongs_to :feedback, Feedback.Feedback
9 |
10 | timestamps()
11 | end
12 |
13 | def changeset(struct, params \\ :invalid) do
14 | struct
15 | |> cast(params, [:response, :edit, :edited, :feedback_id])
16 | |> validate_required([:response, :feedback_id])
17 | |> assoc_constraint(:feedback)
18 | end
19 | end
20 |
--------------------------------------------------------------------------------
/codecov.yml:
--------------------------------------------------------------------------------
1 | codecov:
2 | codecov:
3 | token: d9616fef-dc91-4fb3-a919-a75b7f0ba079
4 | notify:
5 | require_ci_to_pass: yes
6 |
7 | coverage:
8 | precision: 2
9 | round: down
10 | range: "70...100"
11 |
12 | status:
13 | project: yes
14 | patch: yes
15 | changes: no
16 |
17 | parsers:
18 | gcov:
19 | branch_detection:
20 | conditional: yes
21 | loop: yes
22 | method: no
23 | macro: no
24 |
25 | comment:
26 | layout: "header, diff, sunburst"
27 | behavior: default
28 | require_changes: no
29 |
--------------------------------------------------------------------------------
/test/channels/room_channel_test.exs:
--------------------------------------------------------------------------------
1 | defmodule Feedback.RoomChannelTest do
2 | use Feedback.ChannelCase
3 | alias Feedback.RoomChannel
4 |
5 | setup do
6 | {:ok, _, socket} =
7 | socket("responded", %{body: "body"})
8 | |> subscribe_and_join(RoomChannel, "room:lobby")
9 |
10 | {:ok, socket: socket}
11 | end
12 |
13 | test "responded replies with status ok", %{socket: socket} do
14 | push socket, "responded", %{body: "body"}
15 | assert_broadcast "responded", %{body: "body"}
16 | leave socket
17 | end
18 | end
19 |
--------------------------------------------------------------------------------
/web/templates/feedback/index.html.eex:
--------------------------------------------------------------------------------
1 |
Feedback Categories
2 |
14 |
--------------------------------------------------------------------------------
/web/templates/session/new.html.eex:
--------------------------------------------------------------------------------
1 | Login
2 |
3 | <%= form_for @conn, session_path(@conn, :create), [as: :session], fn f -> %>
4 |
5 | <%= label f, :email, class: "control-label" %>
6 | <%= text_input f, :email, placeholder: "email@example.com", class: "form-control" %>
7 |
8 |
9 | <%= label f, :password, class: "control-label" %>
10 | <%= text_input f, :password, placeholder: "Password", class: "form-control", type: :password %>
11 |
12 | <%= submit "Login", class: "button" %>
13 | <% end %>
14 |
--------------------------------------------------------------------------------
/web/static/assets/images/tick.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
5 |
6 |
8 |
9 |
--------------------------------------------------------------------------------
/web/channels/room_channel.ex:
--------------------------------------------------------------------------------
1 | defmodule Feedback.RoomChannel do
2 | use Phoenix.Channel
3 |
4 | def join("room:lobby", _message, socket) do
5 | {:ok, socket}
6 | end
7 | def join("room:" <> _private_room_id, _params, _socket) do
8 | {:error, %{reason: "unauthorized"}}
9 | end
10 |
11 | def handle_in("responded", %{"body" => body}, socket) do
12 | broadcast! socket, "responded", %{body: body}
13 | {:noreply, socket}
14 | end
15 |
16 | intercept ["responded"]
17 |
18 | def handle_out("responded", payload, socket) do
19 | push socket, "responded", payload
20 | {:noreply, socket}
21 | end
22 | end
23 |
--------------------------------------------------------------------------------
/test/views/error_view_test.exs:
--------------------------------------------------------------------------------
1 | defmodule Feedback.ErrorViewTest do
2 | use Feedback.ConnCase, async: true
3 |
4 | # Bring render/3 and render_to_string/3 for testing custom views
5 | import Phoenix.View
6 |
7 | test "renders 404.html" do
8 | assert render_to_string(Feedback.ErrorView, "404.html", []) ==
9 | "Page not found"
10 | end
11 |
12 | test "render 500.html" do
13 | assert render_to_string(Feedback.ErrorView, "500.html", []) ==
14 | "Internal server error"
15 | end
16 |
17 | test "render any other" do
18 | assert render_to_string(Feedback.ErrorView, "505.html", []) ==
19 | "Internal server error"
20 | end
21 | end
22 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "repository": {},
3 | "license": "MIT",
4 | "scripts": {
5 | "deploy": "brunch build --production",
6 | "watch": "brunch watch --stdin",
7 | "test": "mix test",
8 | "cover": "mix coveralls"
9 | },
10 | "dependencies": {
11 | "phoenix": "file:deps/phoenix",
12 | "phoenix_html": "file:deps/phoenix_html"
13 | },
14 | "devDependencies": {
15 | "babel-brunch": "~6.0.0",
16 | "brunch": "2.7.4",
17 | "clean-css-brunch": "~2.0.0",
18 | "css-brunch": "~2.0.0",
19 | "javascript-brunch": "~2.0.0",
20 | "uglify-js-brunch": "~2.0.1",
21 | "pre-commit": "^1.2.2"
22 | },
23 | "pre-commit": [
24 | "cover"
25 | ]
26 | }
27 |
--------------------------------------------------------------------------------
/test/controllers/forum_controller_test.exs:
--------------------------------------------------------------------------------
1 | defmodule Feedback.ForumControllerTest do
2 | use Feedback.ConnCase, async: false
3 |
4 | test "/forum", %{conn: conn} do
5 | insert_feedback(%{public: true})
6 | conn = get conn, forum_path(conn, :forum)
7 | assert html_response(conn, 200) =~ "Forum"
8 | end
9 |
10 | test "/forum/:id", %{conn: conn} do
11 | feedback = insert_feedback(%{public: true})
12 | conn = get conn, forum_path(conn, :forum_show, feedback.id)
13 | assert html_response(conn, 200) =~ "Feedback"
14 | end
15 |
16 | test "/forum/:id invalid", %{conn: conn} do
17 | conn = get conn, forum_path(conn, :forum_show, 1)
18 | assert redirected_to(conn, 302) =~ "/"
19 | end
20 | end
21 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # App artifacts
2 | /_build
3 | /db
4 | /deps
5 | /*.ez
6 |
7 | # Generated on crash by the VM
8 | erl_crash.dump
9 |
10 | # Static artifacts
11 | /node_modules
12 |
13 | # Since we are building assets from web/static,
14 | # we ignore priv/static. You may want to comment
15 | # this depending on your deployment strategy.
16 | /priv/static/
17 |
18 | # The config/prod.secret.exs file by default contains sensitive
19 | # data and you should not commit it into version control.
20 | #
21 | # Alternatively, you may comment the line below and commit the
22 | # secrets file as long as you replace its contents by environment
23 | # variables.
24 | /config/prod.secret.exs
25 |
26 | # Coverage
27 | cover
28 |
29 | # Environment Variables
30 | .env
31 |
--------------------------------------------------------------------------------
/web/gettext.ex:
--------------------------------------------------------------------------------
1 | defmodule Feedback.Gettext do
2 | @moduledoc """
3 | A module providing Internationalization with a gettext-based API.
4 |
5 | By using [Gettext](https://hexdocs.pm/gettext),
6 | your module gains a set of macros for translations, for example:
7 |
8 | import Feedback.Gettext
9 |
10 | # Simple translation
11 | gettext "Here is the string to translate"
12 |
13 | # Plural translation
14 | ngettext "Here is the string to translate",
15 | "Here are the strings to translate",
16 | 3
17 |
18 | # Domain-based translation
19 | dgettext "errors", "Here is the error message to translate"
20 |
21 | See the [Gettext Docs](https://hexdocs.pm/gettext) for detailed usage.
22 | """
23 | use Gettext, otp_app: :feedback
24 | end
25 |
--------------------------------------------------------------------------------
/config/test.exs:
--------------------------------------------------------------------------------
1 | use Mix.Config
2 |
3 | # We don't run a server during test. If one is required,
4 | # you can enable the server option below.
5 | config :feedback, Feedback.Endpoint,
6 | http: [port: 4001],
7 | server: false
8 |
9 | # Print only warnings and errors during test
10 | config :logger, level: :warn
11 |
12 | # Configure your database
13 | config :feedback, Feedback.Repo,
14 | adapter: Ecto.Adapters.Postgres,
15 | username: "postgres",
16 | password: "postgres",
17 | database: "feedback_test",
18 | hostname: "localhost",
19 | pool: Ecto.Adapters.SQL.Sandbox
20 |
21 | # Configure password hashing
22 |
23 | config :comeonin, :bcrypt_log_rounds, 4
24 | config :comeonin, :pbkdf2_rounds, 1
25 |
26 | # Configure Bamboo email
27 | config :feedback, Feedback.Mailer,
28 | adapter: Bamboo.TestAdapter
29 |
--------------------------------------------------------------------------------
/web/templates/forum/forum.html.eex:
--------------------------------------------------------------------------------
1 | Feedback Forum
2 | <%= if length(@public_feedback) == 0 do %>
3 | Looks like there isn't any public feedback yet! Please check back again later
4 | <% end %>
5 | <%= for feedback <- @public_feedback do %>
6 |
7 |
8 |
9 | <%= if feedback.response != nil do %>
10 |
11 | <% end %>
12 | <%= truncate_text(feedback.item, 32) %>
13 |
14 |
<%= format_date(feedback.inserted_at) %>
15 |
16 |
17 | <% end %>
18 |
--------------------------------------------------------------------------------
/test/models/response_test.exs:
--------------------------------------------------------------------------------
1 | defmodule Feedback.ResponseTest do
2 | use Feedback.ModelCase
3 |
4 | alias Feedback.Response
5 |
6 | @valid_attrs %{
7 | response: "This is my response",
8 | edit: false,
9 | edited: false,
10 | feedback_id: 1
11 | }
12 | @invalid_attrs %{}
13 |
14 | test "changeset with valid attributes" do
15 | changeset = Response.changeset(%Response{}, @valid_attrs)
16 | assert changeset.valid?
17 | end
18 |
19 | test "changeset with invalid attributes" do
20 | changeset = Response.changeset(%Response{}, @invalid_attrs)
21 | refute changeset.valid?
22 | end
23 |
24 | test "response schema" do
25 | actual = Response.__schema__(:fields)
26 | expected = [
27 | :id,
28 | :response,
29 | :edit,
30 | :edited,
31 | :feedback_id,
32 | :inserted_at,
33 | :updated_at
34 | ]
35 | assert actual == expected
36 | end
37 |
38 | end
39 |
--------------------------------------------------------------------------------
/web/templates/layout/app.html.eex:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 | dwyl feedback
11 | ">
12 |
13 |
14 |
15 |
16 |
<%= get_flash(@conn, :info) %>
17 |
<%= get_flash(@conn, :error) %>
18 |
19 |
20 | <%= render @view_module, @view_template, assigns %>
21 |
22 |
23 |
24 |
25 |
26 |
27 |
28 |
--------------------------------------------------------------------------------
/web/models/feedback.ex:
--------------------------------------------------------------------------------
1 | defmodule Feedback.Feedback do
2 | use Feedback.Web, :model
3 |
4 | schema "feedback" do
5 | field :item, :string
6 | field :submitter_email, :string
7 | field :permalink_string, :string
8 | field :mood, :string
9 | field :public, :boolean
10 | field :edit, :boolean
11 | field :edited, :boolean
12 | has_one :response, Feedback.Response
13 |
14 | timestamps()
15 | end
16 |
17 | def changeset(struct, params \\ :invalid) do
18 | struct
19 | |> cast(params, [
20 | :item,
21 | :submitter_email,
22 | :permalink_string,
23 | :mood,
24 | :public,
25 | :edit,
26 | :edited])
27 | |> validate_format(:submitter_email, ~r/@/)
28 | |> validate_length(:response, min: 2)
29 | |> validate_required([:item, :permalink_string, :mood])
30 | end
31 |
32 | defimpl Phoenix.Param, for: Feedback.Feedback do
33 | def to_param(%{permalink_string: permalink_string}) do
34 | "#{permalink_string}"
35 | end
36 | end
37 | end
38 |
--------------------------------------------------------------------------------
/test/views/feedback_view_test.exs:
--------------------------------------------------------------------------------
1 | defmodule Feedback.FeedbackViewTest do
2 | use Feedback.ConnCase, async: true
3 |
4 |
5 | test "truncate_text" do
6 | truncated_text = Feedback.FeedbackView.truncate_text("Test", 2)
7 | assert truncated_text == "Te..."
8 | end
9 |
10 | test "truncate_text doesn't apply to text shorter than given length" do
11 | truncated_text = Feedback.FeedbackView.truncate_text("Test", 5)
12 | assert truncated_text == "Test"
13 | end
14 |
15 | test "truncate_text removes white space" do
16 | truncated_text = Feedback.FeedbackView.truncate_text("Test whitespace ", 16)
17 | assert truncated_text == "Test whitespace..."
18 | end
19 |
20 | test "format_date today" do
21 | format_date = Feedback.FeedbackView.format_date(DateTime.utc_now())
22 | assert format_date == "Today"
23 | end
24 |
25 | test "format_date not today" do
26 | date = ~D[2017-01-01]
27 | format_date = Feedback.FeedbackView.format_date(date)
28 | assert format_date == "1/1/2017"
29 | end
30 | end
31 |
--------------------------------------------------------------------------------
/web/static/assets/images/locked.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
--------------------------------------------------------------------------------
/web/static/assets/images/edit.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
--------------------------------------------------------------------------------
/test/models/feedback_test.exs:
--------------------------------------------------------------------------------
1 | defmodule Feedback.FeedbackTest do
2 | use Feedback.ModelCase
3 |
4 | alias Feedback.Feedback
5 |
6 | @valid_attrs %{
7 | item: "This is my feedback",
8 | submitter_email: "test@email.com",
9 | permalink_string: "long-and-un-guessable",
10 | mood: "happy",
11 | public: false,
12 | edit: false,
13 | edited: false
14 | }
15 | @invalid_attrs %{}
16 |
17 | test "changeset with valid attributes" do
18 | changeset = Feedback.changeset(%Feedback{}, @valid_attrs)
19 | assert changeset.valid?
20 | end
21 |
22 | test "changeset with invalid attributes" do
23 | changeset = Feedback.changeset(%Feedback{}, @invalid_attrs)
24 | refute changeset.valid?
25 | end
26 |
27 | test "feedback schema" do
28 | actual = Feedback.__schema__(:fields)
29 | expected = [
30 | :id,
31 | :item,
32 | :submitter_email,
33 | :permalink_string,
34 | :mood,
35 | :public,
36 | :edit,
37 | :edited,
38 | :inserted_at,
39 | :updated_at
40 | ]
41 | assert actual == expected
42 | end
43 | end
44 |
--------------------------------------------------------------------------------
/web/templates/page/index.html.eex:
--------------------------------------------------------------------------------
1 |
2 |
<%= gettext "Welcome to %{name}", name: "Phoenix!" %>
3 |
A productive web framework that does not compromise speed and maintainability.
4 |
5 |
6 |
37 |
--------------------------------------------------------------------------------
/lib/feedback.ex:
--------------------------------------------------------------------------------
1 | defmodule Feedback do
2 | use Application
3 |
4 | # See http://elixir-lang.org/docs/stable/elixir/Application.html
5 | # for more information on OTP Applications
6 | def start(_type, _args) do
7 | import Supervisor.Spec
8 |
9 | # Define workers and child supervisors to be supervised
10 | children = [
11 | # Start the Ecto repository
12 | supervisor(Feedback.Repo, []),
13 | # Start the endpoint when the application starts
14 | supervisor(Feedback.Endpoint, []),
15 | # Start your own worker by calling: Feedback.Worker.start_link(arg1, arg2, arg3)
16 | # worker(Feedback.Worker, [arg1, arg2, arg3]),
17 | ]
18 |
19 | # See http://elixir-lang.org/docs/stable/elixir/Supervisor.html
20 | # for other strategies and supported options
21 | opts = [strategy: :one_for_one, name: Feedback.Supervisor]
22 | Supervisor.start_link(children, opts)
23 | end
24 |
25 | # Tell Phoenix to update the endpoint configuration
26 | # whenever the application is updated.
27 | def config_change(changed, _new, removed) do
28 | Feedback.Endpoint.config_change(changed, removed)
29 | :ok
30 | end
31 | end
32 |
--------------------------------------------------------------------------------
/web/controllers/forum_controller.ex:
--------------------------------------------------------------------------------
1 | defmodule Feedback.ForumController do
2 | use Feedback.Web, :controller
3 | alias Feedback.{Feedback, Response, LayoutView}
4 |
5 | def forum(conn, _params) do
6 | raw_feedback = Repo.all(Feedback)
7 | public_feedback =
8 | raw_feedback
9 | |> Enum.filter(fn item -> item.public end)
10 | |> sort_by_ascending_date()
11 | |> Repo.preload(:response)
12 | render conn, "forum.html", layout: {LayoutView, "forum.html"}, public_feedback: public_feedback
13 | end
14 |
15 | def forum_show(conn, %{"id" => id}) do
16 | case Repo.get_by(Feedback, id: id) do
17 | nil ->
18 | conn
19 | |> put_flash(:error, "That piece of feedback doesn't exist")
20 | |> redirect(to: page_path(conn, :index))
21 | feedback ->
22 | feedback = Repo.preload(feedback, :response)
23 | response_changeset = Response.changeset(%Response{})
24 | changeset = Response.changeset(%Response{})
25 | render conn, "forum_show.html", layout: {LayoutView, "forum.html"}, feedback: feedback, changeset: changeset, response_changeset: response_changeset
26 | end
27 | end
28 | end
29 |
--------------------------------------------------------------------------------
/web/router.ex:
--------------------------------------------------------------------------------
1 | defmodule Feedback.Router do
2 | use Feedback.Web, :router
3 |
4 | pipeline :browser do
5 | plug :accepts, ["html"]
6 | plug :fetch_session
7 | plug :fetch_flash
8 | plug :protect_from_forgery
9 | plug :put_secure_browser_headers
10 | plug Feedback.Auth, repo: Feedback.Repo
11 | end
12 |
13 | scope "/", Feedback do
14 | pipe_through :browser # Use the default browser stack
15 |
16 | get "/", PageController, :index
17 | resources "/feedback", FeedbackController, only: [:index, :new, :create, :show, :update]
18 | resources "/session", SessionController, only: [:new, :create, :delete]
19 | resources "/response", ResponseController, only: [:create]
20 | post "/response/:id", ResponseController, :update
21 | get "/delighted", FeedbackController, :delighted
22 | get "/happy", FeedbackController, :happy
23 | get "/neutral", FeedbackController, :neutral
24 | get "/confused", FeedbackController, :confused
25 | get "/sad", FeedbackController, :sad
26 | get "/angry", FeedbackController, :angry
27 | get "/forum", ForumController, :forum
28 | get "/forum/:id", ForumController, :forum_show
29 | end
30 |
31 | end
32 |
--------------------------------------------------------------------------------
/web/controllers/session_controller.ex:
--------------------------------------------------------------------------------
1 | defmodule Feedback.SessionController do
2 | use Feedback.Web, :controller
3 | alias Feedback.User
4 |
5 | def new(conn, _params) do
6 | case conn.assigns.current_user do
7 | nil ->
8 | changeset = User.changeset(%User{})
9 | render conn, "new.html", changeset: changeset
10 | _user ->
11 | conn
12 | |> put_flash(:info, "You are already logged in!")
13 | |> redirect(to: feedback_path(conn, :index))
14 | end
15 |
16 | end
17 |
18 | def create(conn, %{"session" => %{"email" => email, "password" => password}}) do
19 | case Feedback.Auth.login_by_email_and_pass(conn, email, password, repo: Repo) do
20 | {:ok, conn} ->
21 | conn
22 | |> put_flash(:info, "Welcome Back!")
23 | |> redirect(to: feedback_path(conn, :index))
24 | {:error, _reason, conn} ->
25 | conn
26 | |> put_flash(:error, "Invalid email/password combination")
27 | |> redirect(to: session_path(conn, :new))
28 | end
29 | end
30 |
31 | def delete(conn, _) do
32 | conn
33 | |> Feedback.Auth.logout()
34 | |> redirect(to: page_path(conn, :index))
35 | end
36 | end
37 |
--------------------------------------------------------------------------------
/web/static/assets/images/back-arrow-button.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
--------------------------------------------------------------------------------
/test/support/channel_case.ex:
--------------------------------------------------------------------------------
1 | defmodule Feedback.ChannelCase do
2 | @moduledoc """
3 | This module defines the test case to be used by
4 | channel tests.
5 |
6 | Such tests rely on `Phoenix.ChannelTest` and also
7 | import other functionality to make it easier
8 | to build and query models.
9 |
10 | Finally, if the test case interacts with the database,
11 | it cannot be async. For this reason, every test runs
12 | inside a transaction which is reset at the beginning
13 | of the test unless the test case is marked as async.
14 | """
15 |
16 | use ExUnit.CaseTemplate
17 |
18 | using do
19 | quote do
20 | # Import conveniences for testing with channels
21 | use Phoenix.ChannelTest
22 |
23 | alias Feedback.Repo
24 | import Ecto
25 | import Ecto.Changeset
26 | import Ecto.Query
27 | import Feedback.TestHelpers
28 |
29 |
30 | # The default endpoint for testing
31 | @endpoint Feedback.Endpoint
32 | end
33 | end
34 |
35 | setup tags do
36 | :ok = Ecto.Adapters.SQL.Sandbox.checkout(Feedback.Repo)
37 |
38 | unless tags[:async] do
39 | Ecto.Adapters.SQL.Sandbox.mode(Feedback.Repo, {:shared, self()})
40 | end
41 |
42 | :ok
43 | end
44 | end
45 |
--------------------------------------------------------------------------------
/web/static/js/app.js:
--------------------------------------------------------------------------------
1 | // Brunch automatically concatenates all files in your
2 | // watched paths. Those paths can be configured at
3 | // config.paths.watched in "brunch-config.js".
4 | //
5 | // However, those files will only be executed if
6 | // explicitly imported. The only exception are files
7 | // in vendor, which are never wrapped in imports and
8 | // therefore are always executed.
9 |
10 | // Import dependencies
11 | //
12 | // If you no longer want to use a dependency, remember
13 | // to also remove its path from "config.paths.watched".
14 | import "phoenix_html"
15 |
16 | // Import local files
17 | //
18 | // Local files can be imported directly using relative
19 | // paths "./socket" or full ones "web/static/js/socket".
20 |
21 | import socket from "./socket"
22 |
23 | function addLabelListeners() {
24 | [].forEach.call(
25 | document.querySelectorAll('.emotion-container label'),
26 | function (label_elem) {
27 | label_elem.addEventListener('click', function () {
28 | var id = label_elem.getAttribute('for');
29 | var input_elem = document.querySelector('#' + id);
30 | input_elem.checked = !input_elem.checked;
31 | })
32 | }
33 | )
34 | }
35 |
36 | export var App = {
37 | addLabelListeners: addLabelListeners
38 | }
39 |
--------------------------------------------------------------------------------
/web/views/feedback_view.ex:
--------------------------------------------------------------------------------
1 | defmodule Feedback.FeedbackView do
2 | use Feedback.Web, :view
3 |
4 | def truncate_text(text, text_length) do
5 | case String.length(text) > text_length do
6 | true ->
7 | truncated_text = String.slice(text, 0, text_length)
8 | clean_text = remove_whitespace(truncated_text)
9 | clean_text <> "..."
10 | false ->
11 | text
12 | end
13 | end
14 |
15 | defp remove_whitespace(text) do
16 | case String.last(text) == " " do
17 | true ->
18 | cleaner_string = String.slice(text, 0, String.length(text) - 1)
19 | remove_whitespace(cleaner_string)
20 | false ->
21 | text
22 | end
23 | end
24 |
25 | def format_date(date) do
26 | date_today = DateTime.utc_now()
27 | same_day = date.day == date_today.day
28 | same_month = date.month == date_today.month
29 | same_year = date.year == date_today.year
30 | same_date = same_day && same_month && same_year
31 | case same_date do
32 | true -> "Today"
33 | false ->
34 | month = Integer.to_string(date.month)
35 | year = Integer.to_string(date.year)
36 | day = Integer.to_string(date.day)
37 |
38 | day <> "/" <> month <> "/" <> year
39 | end
40 | end
41 |
42 | end
43 |
--------------------------------------------------------------------------------
/web/models/user.ex:
--------------------------------------------------------------------------------
1 | defmodule Feedback.User do
2 | use Feedback.Web, :model
3 | alias Ecto.Changeset
4 | alias Comeonin.Bcrypt
5 |
6 | schema "users" do
7 | field :first_name, :string
8 | field :last_name, :string
9 | field :email, :string
10 | field :password, :string, virtual: true
11 | field :password_hash, :string
12 |
13 | timestamps()
14 | end
15 |
16 | def changeset(struct, params \\ :invalid) do
17 | struct
18 | |> cast(params, [:email, :first_name, :last_name, :password])
19 | |> validate_format(:email, ~r/@/)
20 | |> validate_required([:email, :password, :first_name, :last_name])
21 | |> unique_constraint(:email)
22 | end
23 |
24 | def registration_changeset(struct, params) do
25 | struct
26 | |> changeset(params)
27 | |> validate_password(params)
28 | |> put_pass_hash()
29 | end
30 |
31 | def validate_password(changeset, params) do
32 | changeset
33 | |> cast(params, [:password, :email])
34 | |> validate_length(:password, min: 6, max: 100)
35 | end
36 |
37 | def put_pass_hash(changeset) do
38 | case changeset do
39 | %Changeset{valid?: true, changes: %{password: pass}} ->
40 | put_change(changeset, :password_hash, Bcrypt.hashpwsalt(pass))
41 | _ ->
42 | changeset
43 | end
44 | end
45 | end
46 |
--------------------------------------------------------------------------------
/web/channels/user_socket.ex:
--------------------------------------------------------------------------------
1 | defmodule Feedback.UserSocket do
2 | use Phoenix.Socket
3 |
4 | ## Channels
5 | channel "room:*", Feedback.RoomChannel
6 |
7 | ## Transports
8 | transport :websocket, Phoenix.Transports.WebSocket
9 | # transport :longpoll, Phoenix.Transports.LongPoll
10 |
11 | # Socket params are passed from the client and can
12 | # be used to verify and authenticate a user. After
13 | # verification, you can put default assigns into
14 | # the socket that will be set for all channels, ie
15 | #
16 | # {:ok, assign(socket, :user_id, verified_user_id)}
17 | #
18 | # To deny connection, return `:error`.
19 | #
20 | # See `Phoenix.Token` documentation for examples in
21 | # performing token verification on connect.
22 | def connect(_params, socket) do
23 | {:ok, socket}
24 | end
25 |
26 | # Socket id's are topics that allow you to identify all sockets for a given user:
27 | #
28 | # def id(socket), do: "users_socket:#{socket.assigns.user_id}"
29 | #
30 | # Would allow you to broadcast a "disconnect" event and terminate
31 | # all active sockets and channels for a given user:
32 | #
33 | # Feedback.Endpoint.broadcast("users_socket:#{user.id}", "disconnect", %{})
34 | #
35 | # Returning `nil` makes this socket anonymous.
36 | def id(_socket), do: nil
37 | end
38 |
--------------------------------------------------------------------------------
/test/support/conn_case.ex:
--------------------------------------------------------------------------------
1 | defmodule Feedback.ConnCase do
2 | @moduledoc """
3 | This module defines the test case to be used by
4 | tests that require setting up a connection.
5 |
6 | Such tests rely on `Phoenix.ConnTest` and also
7 | import other functionality to make it easier
8 | to build and query models.
9 |
10 | Finally, if the test case interacts with the database,
11 | it cannot be async. For this reason, every test runs
12 | inside a transaction which is reset at the beginning
13 | of the test unless the test case is marked as async.
14 | """
15 |
16 | use ExUnit.CaseTemplate
17 |
18 | using do
19 | quote do
20 | # Import conveniences for testing with connections
21 | use Phoenix.ConnTest
22 |
23 | alias Feedback.Repo
24 | import Ecto
25 | import Ecto.Changeset
26 | import Ecto.Query
27 |
28 | import Feedback.Router.Helpers
29 | import Feedback.TestHelpers
30 |
31 | # The default endpoint for testing
32 | @endpoint Feedback.Endpoint
33 | end
34 | end
35 |
36 | setup tags do
37 | :ok = Ecto.Adapters.SQL.Sandbox.checkout(Feedback.Repo)
38 |
39 | unless tags[:async] do
40 | Ecto.Adapters.SQL.Sandbox.mode(Feedback.Repo, {:shared, self()})
41 | end
42 |
43 | {:ok, conn: Phoenix.ConnTest.build_conn()}
44 | end
45 | end
46 |
--------------------------------------------------------------------------------
/test/support/test_helpers.ex:
--------------------------------------------------------------------------------
1 | defmodule Feedback.TestHelpers do
2 | alias Feedback.{Repo, User, Feedback, Response}
3 |
4 | @user_id 1
5 | @feedback_id 1
6 | @response_id 1
7 |
8 | def id() do
9 | %{
10 | user: @user_id,
11 | feedback: @feedback_id,
12 | response: @response_id
13 | }
14 | end
15 |
16 | def insert_validated_user(attrs \\ %{}) do
17 | changes = Map.merge(
18 | %{first_name: "First",
19 | last_name: "Last",
20 | email: "email@test.com",
21 | password: "supersecret",
22 | id: @user_id},
23 | attrs)
24 |
25 | %User{}
26 | |> User.registration_changeset(changes)
27 | |> Repo.insert!
28 | end
29 |
30 | def insert_feedback(attrs \\ %{}) do
31 | changes = Map.merge(
32 | %{item: "Feedback",
33 | permalink_string: "thisisapermalink",
34 | mood: "happy",
35 | id: @feedback_id},
36 | attrs)
37 |
38 | %Feedback{}
39 | |> Feedback.changeset(changes)
40 | |> Repo.insert!
41 | end
42 |
43 | def insert_response(attrs \\ %{}) do
44 | changes = Map.merge(
45 | %{response: "Response",
46 | feedback_id: @feedback_id,
47 | id: @response_id},
48 | attrs)
49 |
50 | %Response{}
51 | |> Response.changeset(changes)
52 | |> Repo.insert!
53 | end
54 | end
55 |
--------------------------------------------------------------------------------
/lib/feedback/endpoint.ex:
--------------------------------------------------------------------------------
1 | defmodule Feedback.Endpoint do
2 | use Phoenix.Endpoint, otp_app: :feedback
3 |
4 | socket "/socket", Feedback.UserSocket
5 |
6 | # Serve at "/" the static files from "priv/static" directory.
7 | #
8 | # You should set gzip to true if you are running phoenix.digest
9 | # when deploying your static files in production.
10 | plug Plug.Static,
11 | at: "/", from: :feedback, gzip: false,
12 | only: ~w(css fonts images js favicon.ico robots.txt)
13 |
14 | # Code reloading can be explicitly enabled under the
15 | # :code_reloader configuration of your endpoint.
16 | if code_reloading? do
17 | socket "/phoenix/live_reload/socket", Phoenix.LiveReloader.Socket
18 | plug Phoenix.LiveReloader
19 | plug Phoenix.CodeReloader
20 | end
21 |
22 | plug Plug.RequestId
23 | plug Plug.Logger
24 |
25 | plug Plug.Parsers,
26 | parsers: [:urlencoded, :multipart, :json],
27 | pass: ["*/*"],
28 | json_decoder: Poison
29 |
30 | plug Plug.MethodOverride
31 | plug Plug.Head
32 |
33 | # The session will be stored in the cookie and signed,
34 | # this means its contents can be read but not tampered with.
35 | # Set :encryption_salt if you would also like to encrypt it.
36 | plug Plug.Session,
37 | store: :cookie,
38 | key: "_feedback_key",
39 | signing_salt: "GPBWklIH"
40 |
41 | plug Feedback.Router
42 | end
43 |
--------------------------------------------------------------------------------
/test/models/user_test.exs:
--------------------------------------------------------------------------------
1 | defmodule Feedback.UserTest do
2 | use Feedback.ModelCase
3 | alias Feedback.User
4 | alias Comeonin.Bcrypt
5 |
6 | @valid_attrs %{
7 | email: "email@test.com",
8 | password: "secretshhh",
9 | password_hash: Bcrypt.hashpwsalt("secretshhh"),
10 | first_name: "First",
11 | last_name: "Last"
12 | }
13 | @invalid_attrs %{email: "test@test.com"}
14 |
15 | test "changeset with valid attributes" do
16 | changeset = User.changeset(%User{}, @valid_attrs)
17 | assert changeset.valid?
18 | end
19 |
20 | test "changeset with invalid attributes" do
21 | changeset = User.changeset(%User{}, @invalid_attrs)
22 | refute changeset.valid?
23 | end
24 |
25 | test "registration_changeset with valid attributes" do
26 | changeset = User.registration_changeset(%User{}, @valid_attrs)
27 | assert changeset.valid?
28 | end
29 |
30 | test "registration_changeset with invalid attributes" do
31 | changeset = User.registration_changeset(%User{}, @invalid_attrs)
32 | refute changeset.valid?
33 | end
34 |
35 | test "user schema" do
36 | actual = User.__schema__(:fields)
37 | expected = [
38 | :id,
39 | :first_name,
40 | :last_name,
41 | :email,
42 | :password_hash,
43 | :inserted_at,
44 | :updated_at
45 | ]
46 | assert actual == expected
47 | end
48 | end
49 |
--------------------------------------------------------------------------------
/config/config.exs:
--------------------------------------------------------------------------------
1 | # This file is responsible for configuring your application
2 | # and its dependencies with the aid of the Mix.Config module.
3 | #
4 | # This configuration file is loaded before any dependency and
5 | # is restricted to this project.
6 | use Mix.Config
7 |
8 | # General application configuration
9 | config :feedback,
10 | ecto_repos: [Feedback.Repo]
11 |
12 | # Configures the endpoint
13 | config :feedback, Feedback.Endpoint,
14 | url: [host: "localhost"],
15 | secret_key_base: System.get_env("SECRET_KEY_BASE"),
16 | render_errors: [view: Feedback.ErrorView, accepts: ~w(html json)],
17 | pubsub: [name: Feedback.PubSub,
18 | adapter: Phoenix.PubSub.PG2]
19 |
20 | # Configures Elixir's Logger
21 | config :logger, :console,
22 | format: "$time $metadata[$level] $message\n",
23 | metadata: [:request_id]
24 |
25 | # Configures mailing
26 | config :feedback, Feedback.Mailer,
27 | adapter: Bamboo.SMTPAdapter,
28 | server: System.get_env("SES_SERVER"),
29 | port: System.get_env("SES_PORT"),
30 | username: System.get_env("SMTP_USERNAME"),
31 | password: System.get_env("SMTP_PASSWORD"),
32 | tls: :always, # can be `:always` or `:never`
33 | ssl: false, # can be `true`
34 | retries: 1
35 |
36 | # Import environment specific config. This must remain at the bottom
37 | # of this file so it overrides the configuration defined above.
38 | import_config "#{Mix.env}.exs"
39 |
--------------------------------------------------------------------------------
/web/views/error_helpers.ex:
--------------------------------------------------------------------------------
1 | defmodule Feedback.ErrorHelpers do
2 | @moduledoc """
3 | Conveniences for translating and building error messages.
4 | """
5 |
6 | use Phoenix.HTML
7 |
8 | @doc """
9 | Generates tag for inlined form input errors.
10 | """
11 | def error_tag(form, field) do
12 | if error = form.errors[field] do
13 | content_tag :span, translate_error(error), class: "help-block"
14 | end
15 | end
16 |
17 | @doc """
18 | Translates an error message using gettext.
19 | """
20 | def translate_error({msg, opts}) do
21 | # Because error messages were defined within Ecto, we must
22 | # call the Gettext module passing our Gettext backend. We
23 | # also use the "errors" domain as translations are placed
24 | # in the errors.po file.
25 | # Ecto will pass the :count keyword if the error message is
26 | # meant to be pluralized.
27 | # On your own code and templates, depending on whether you
28 | # need the message to be pluralized or not, this could be
29 | # written simply as:
30 | #
31 | # dngettext "errors", "1 file", "%{count} files", count
32 | # dgettext "errors", "is invalid"
33 | #
34 | if count = opts[:count] do
35 | Gettext.dngettext(Feedback.Gettext, "errors", msg, msg, count, opts)
36 | else
37 | Gettext.dgettext(Feedback.Gettext, "errors", msg, opts)
38 | end
39 | end
40 | end
41 |
--------------------------------------------------------------------------------
/config/dev.exs:
--------------------------------------------------------------------------------
1 | use Mix.Config
2 |
3 | # For development, we disable any cache and enable
4 | # debugging and code reloading.
5 | #
6 | # The watchers configuration can be used to run external
7 | # watchers to your application. For example, we use it
8 | # with brunch.io to recompile .js and .css sources.
9 | config :feedback, Feedback.Endpoint,
10 | http: [port: 4000],
11 | debug_errors: true,
12 | code_reloader: true,
13 | check_origin: false,
14 | watchers: [node: ["node_modules/brunch/bin/brunch", "watch", "--stdin",
15 | cd: Path.expand("../", __DIR__)]]
16 |
17 |
18 | # Watch static and templates for browser reloading.
19 | config :feedback, Feedback.Endpoint,
20 | live_reload: [
21 | patterns: [
22 | ~r{priv/static/.*(js|css|png|jpeg|jpg|gif|svg)$},
23 | ~r{priv/gettext/.*(po)$},
24 | ~r{web/views/.*(ex)$},
25 | ~r{web/templates/.*(eex)$}
26 | ]
27 | ]
28 |
29 | # Do not include metadata nor timestamps in development logs
30 | config :logger, :console, format: "[$level] $message\n"
31 |
32 | # Set a higher stacktrace during development. Avoid configuring such
33 | # in production as building large stacktraces may be expensive.
34 | config :phoenix, :stacktrace_depth, 20
35 |
36 | # Configure your database
37 | config :feedback, Feedback.Repo,
38 | adapter: Ecto.Adapters.Postgres,
39 | username: "postgres",
40 | password: "postgres",
41 | database: "feedback_dev",
42 | hostname: "localhost",
43 | pool_size: 10
44 |
--------------------------------------------------------------------------------
/web/templates/layout/index.html.eex:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 | dwyl feedback
11 | ">
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 | <%= if @current_user do %>
20 | <%= link "Log out", to: session_path(@conn, :delete, @current_user), method: "delete", class: "back-button link" %>
21 | <% end %>
22 |
23 | Forum
24 |
25 |
26 |
27 |
<%= get_flash(@conn, :info) %>
28 |
<%= get_flash(@conn, :error) %>
29 |
30 |
31 | <%= render @view_module, @view_template, assigns %>
32 |
33 |
34 |
35 |
36 |
37 |
38 |
39 |
--------------------------------------------------------------------------------
/web/static/assets/images/neutral.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
5 |
6 |
7 |
9 |
11 |
13 |
15 |
18 |
19 |
20 |
21 |
--------------------------------------------------------------------------------
/web/templates/feedback/dashboard.html.eex:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 | <%= for feedback <- @feedback do %>
9 |
10 |
11 | <%= truncate_text(feedback.item, 32) %>
12 |
13 |
<%= format_date(feedback.inserted_at) %>
14 |
15 |
16 | <% end %>
17 | <%= if length(@feedback) == 0 do %>
18 |
19 | Looks like you don't have any new <%= @emotion %> feedback yet! You will be notified through email
20 | once there is!
21 |
22 | <% end %>
23 | <%= if length(@responded_feedback) > 0 do %>
24 |
25 |
26 |
RESPONDED
27 |
28 |
29 | <% end %>
30 |
31 | <%= for responded_feedback <- @responded_feedback do %>
32 |
33 |
34 | <%= truncate_text(responded_feedback.item, 32) %>
35 |
36 |
<%= format_date(responded_feedback.inserted_at) %>
37 | <%= if responded_feedback.response != nil do %>
38 |
39 | <% end %>
40 |
41 |
42 | <% end %>
43 |
--------------------------------------------------------------------------------
/web/controllers/auth.ex:
--------------------------------------------------------------------------------
1 | defmodule Feedback.Auth do
2 | import Plug.Conn
3 | import Comeonin.Bcrypt, only: [checkpw: 2, dummy_checkpw: 0]
4 | import Phoenix.Controller
5 |
6 | alias Feedback.{User, Router.Helpers}
7 |
8 | def init(opts) do
9 | Keyword.fetch!(opts, :repo)
10 | end
11 |
12 | def call(conn, repo) do
13 | user_id = get_session(conn, :user_id)
14 |
15 | cond do
16 | conn.assigns[:current_user] ->
17 | conn
18 | user = user_id && repo.get(User, user_id) ->
19 | assign(conn, :current_user, user)
20 | true ->
21 | assign(conn, :current_user, nil)
22 | end
23 | end
24 |
25 | def login(conn, user) do
26 | conn
27 | |> assign(:current_user, user)
28 | |> put_session(:user_id, user.id)
29 | |> configure_session(renew: true)
30 | end
31 |
32 | def login_by_email_and_pass(conn, email, given_pass, opts) do
33 | repo = Keyword.fetch!(opts, :repo)
34 | user = repo.get_by(User, email: email)
35 |
36 | cond do
37 | user && checkpw(given_pass, user.password_hash) ->
38 | {:ok, login(conn, user)}
39 | user ->
40 | {:error, :unauthorized, conn}
41 | true ->
42 | dummy_checkpw()
43 | {:error, :not_found, conn}
44 | end
45 | end
46 |
47 | def logout(conn) do
48 | configure_session(conn, drop: true)
49 | end
50 |
51 | def authenticate(conn, _opts) do
52 | if conn.assigns.current_user do
53 | conn
54 | else
55 | conn
56 | |> put_flash(:error, "You must be logged in to view that page")
57 | |> redirect(to: Helpers.page_path(conn, :index))
58 | |> halt()
59 | end
60 | end
61 |
62 | end
63 |
--------------------------------------------------------------------------------
/web/templates/layout/nav.html.eex:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 | dwyl feedback
11 | ">
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 | <%= if !@conn.assigns.current_user do %>
20 |
21 | Leave more feedback
22 |
23 | <% end %>
24 | <%= if @current_user do %>
25 | <%= link "Log out", to: session_path(@conn, :delete, @current_user), method: "delete", class: "back-button link" %>
26 | <% end %>
27 |
28 | Forum
29 |
30 |
31 |
32 |
<%= get_flash(@conn, :info) %>
33 |
<%= get_flash(@conn, :error) %>
34 |
35 |
36 | <%= render @view_module, @view_template, assigns %>
37 |
38 |
39 |
40 |
41 |
42 |
43 |
44 |
--------------------------------------------------------------------------------
/web/templates/layout/forum.html.eex:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 | dwyl feedback
11 | ">
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 | <%= if !@conn.assigns.current_user do %>
20 |
21 | Leave more feedback
22 |
23 | <% end %>
24 | <%= if @current_user do %>
25 | <%= link "Log out", to: session_path(@conn, :delete, @current_user), method: "delete", class: "back-button link" %>
26 |
27 | Dashboard
28 |
29 | <% end %>
30 |
31 |
32 |
<%= get_flash(@conn, :info) %>
33 |
<%= get_flash(@conn, :error) %>
34 |
35 |
36 | <%= render @view_module, @view_template, assigns %>
37 |
38 |
39 |
40 |
41 |
42 |
43 |
44 |
--------------------------------------------------------------------------------
/web/templates/feedback/new.html.eex:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 | Help us be better
5 | Submit your anonymous feedback below. Don't worry, we can take it!
6 |
7 | <%= form_for @changeset, feedback_path(@conn, :create), fn f -> %>
8 | How are you feeling?
9 |
10 |
11 |
12 | <%= for emotion <- @emotions do %>
13 | <%=radio_button(
14 | f,
15 | :mood,
16 | emotion,
17 | [class: emotion, id: "feedback_" <> emotion]) %>
18 | <%= label f, emotion, "" %>
19 | <% end %>
20 | <%= if @changeset.action do %>
21 | <%= error_tag f, :mood %>
22 | <% end %>
23 |
24 | Tell us your thoughts.
25 |
26 | (To help retain your anonymity , use
27 | short and concise
28 | sentences so it is more difficult to
29 | identify you by your writing style!)
30 |
31 |
32 | <%= textarea f, :item, placeholder: "What's on your mind?", class: "text_area top-buffer" %>
33 | <%= error_tag f, :item %>
34 |
35 |
36 |
Private
37 |
Public
38 |
39 | <%= checkbox f, :public %>
40 |
41 |
42 |
43 | <%= submit "Share", [class: "button", id: "feedback-button"] %>
44 | <% end %>
45 |
--------------------------------------------------------------------------------
/web/static/assets/images/confused.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
5 |
6 |
7 |
9 |
14 |
16 |
18 |
21 |
22 |
23 |
24 |
--------------------------------------------------------------------------------
/brunch-config.js:
--------------------------------------------------------------------------------
1 | exports.config = {
2 | // See http://brunch.io/#documentation for docs.
3 | files: {
4 | javascripts: {
5 | joinTo: "js/app.js"
6 |
7 | // To use a separate vendor.js bundle, specify two files path
8 | // http://brunch.io/docs/config#-files-
9 | // joinTo: {
10 | // "js/app.js": /^(web\/static\/js)/,
11 | // "js/vendor.js": /^(web\/static\/vendor)|(deps)/
12 | // }
13 | //
14 | // To change the order of concatenation of files, explicitly mention here
15 | // order: {
16 | // before: [
17 | // "web/static/vendor/js/jquery-2.1.1.js",
18 | // "web/static/vendor/js/bootstrap.min.js"
19 | // ]
20 | // }
21 | },
22 | stylesheets: {
23 | joinTo: "css/app.css",
24 | order: {
25 | after: ["web/static/css/app.css"] // concat app.css last
26 | }
27 | },
28 | templates: {
29 | joinTo: "js/app.js"
30 | }
31 | },
32 |
33 | conventions: {
34 | // This option sets where we should place non-css and non-js assets in.
35 | // By default, we set this to "/web/static/assets". Files in this directory
36 | // will be copied to `paths.public`, which is "priv/static" by default.
37 | assets: /^(web\/static\/assets)/
38 | },
39 |
40 | // Phoenix paths configuration
41 | paths: {
42 | // Dependencies and current project directories to watch
43 | watched: [
44 | "web/static",
45 | "test/static"
46 | ],
47 |
48 | // Where to compile files to
49 | public: "priv/static"
50 | },
51 |
52 | // Configure your plugins
53 | plugins: {
54 | babel: {
55 | // Do not use ES6 compiler in vendor code
56 | ignore: [/web\/static\/vendor/]
57 | }
58 | },
59 |
60 | modules: {
61 | autoRequire: {
62 | "js/app.js": ["web/static/js/app"]
63 | }
64 | },
65 |
66 | npm: {
67 | enabled: true
68 | }
69 | };
70 |
--------------------------------------------------------------------------------
/web/static/assets/images/happy.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
5 |
6 |
7 |
9 |
12 |
15 |
18 |
21 |
22 |
23 |
24 |
--------------------------------------------------------------------------------
/test/support/model_case.ex:
--------------------------------------------------------------------------------
1 | defmodule Feedback.ModelCase do
2 | @moduledoc """
3 | This module defines the test case to be used by
4 | model tests.
5 |
6 | You may define functions here to be used as helpers in
7 | your model tests. See `errors_on/2`'s definition as reference.
8 |
9 | Finally, if the test case interacts with the database,
10 | it cannot be async. For this reason, every test runs
11 | inside a transaction which is reset at the beginning
12 | of the test unless the test case is marked as async.
13 | """
14 |
15 | use ExUnit.CaseTemplate
16 |
17 | using do
18 | quote do
19 | alias Feedback.Repo
20 |
21 | import Ecto
22 | import Ecto.Changeset
23 | import Ecto.Query
24 | import Feedback.ModelCase
25 | end
26 | end
27 |
28 | setup tags do
29 | :ok = Ecto.Adapters.SQL.Sandbox.checkout(Feedback.Repo)
30 |
31 | unless tags[:async] do
32 | Ecto.Adapters.SQL.Sandbox.mode(Feedback.Repo, {:shared, self()})
33 | end
34 |
35 | :ok
36 | end
37 |
38 | @doc """
39 | Helper for returning list of errors in a struct when given certain data.
40 |
41 | ## Examples
42 |
43 | Given a User schema that lists `:name` as a required field and validates
44 | `:password` to be safe, it would return:
45 |
46 | iex> errors_on(%User{}, %{password: "password"})
47 | [password: "is unsafe", name: "is blank"]
48 |
49 | You could then write your assertion like:
50 |
51 | assert {:password, "is unsafe"} in errors_on(%User{}, %{password: "password"})
52 |
53 | You can also create the changeset manually and retrieve the errors
54 | field directly:
55 |
56 | iex> changeset = User.changeset(%User{}, password: "password")
57 | iex> {:password, "is unsafe"} in changeset.errors
58 | true
59 | """
60 | def errors_on(struct, data) do
61 | struct.__struct__.changeset(struct, data)
62 | |> Ecto.Changeset.traverse_errors(&Feedback.ErrorHelpers.translate_error/1)
63 | |> Enum.flat_map(fn {key, errors} -> for msg <- errors, do: {key, msg} end)
64 | end
65 | end
66 |
--------------------------------------------------------------------------------
/web/web.ex:
--------------------------------------------------------------------------------
1 | defmodule Feedback.Web do
2 | @moduledoc """
3 | A module that keeps using definitions for controllers,
4 | views and so on.
5 |
6 | This can be used in your application as:
7 |
8 | use Feedback.Web, :controller
9 | use Feedback.Web, :view
10 |
11 | The definitions below will be executed for every view,
12 | controller, etc, so keep them short and clean, focused
13 | on imports, uses and aliases.
14 |
15 | Do NOT define functions inside the quoted expressions
16 | below.
17 | """
18 |
19 | def model do
20 | quote do
21 | use Ecto.Schema
22 |
23 | import Ecto
24 | import Ecto.Changeset
25 | import Ecto.Query
26 | end
27 | end
28 |
29 | def controller do
30 | quote do
31 | use Phoenix.Controller
32 |
33 | alias Feedback.Repo
34 | import Ecto
35 | import Ecto.Query
36 |
37 | import Feedback.Router.Helpers
38 | import Feedback.Gettext
39 | import Feedback.Controllers.Helpers
40 | import Feedback.Auth, only: [authenticate: 2]
41 | end
42 | end
43 |
44 | def view do
45 | quote do
46 | use Phoenix.View, root: "web/templates"
47 |
48 | # Import convenience functions from controllers
49 | import Phoenix.Controller, only: [get_csrf_token: 0, get_flash: 2, view_module: 1]
50 |
51 | # Use all HTML functionality (forms, tags, etc)
52 | use Phoenix.HTML
53 |
54 | import Feedback.Router.Helpers
55 | import Feedback.ErrorHelpers
56 | import Feedback.Gettext
57 | end
58 | end
59 |
60 | def router do
61 | quote do
62 | use Phoenix.Router
63 | end
64 | end
65 |
66 | def channel do
67 | quote do
68 | use Phoenix.Channel
69 |
70 | alias Feedback.Repo
71 | import Ecto
72 | import Ecto.Query
73 | import Feedback.Gettext
74 | end
75 | end
76 |
77 | @doc """
78 | When used, dispatch to the appropriate controller/view/etc.
79 | """
80 | defmacro __using__(which) when is_atom(which) do
81 | apply(__MODULE__, which, [])
82 | end
83 | end
84 |
--------------------------------------------------------------------------------
/web/static/assets/images/angry.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
5 |
6 |
7 |
9 |
12 |
16 |
20 |
23 |
24 |
25 |
26 |
--------------------------------------------------------------------------------
/mix.exs:
--------------------------------------------------------------------------------
1 | defmodule Feedback.Mixfile do
2 | use Mix.Project
3 |
4 | def project do
5 | [app: :feedback,
6 | version: "0.0.1",
7 | elixir: "~> 1.2",
8 | elixirc_paths: elixirc_paths(Mix.env),
9 | compilers: [:phoenix, :gettext] ++ Mix.compilers,
10 | build_embedded: Mix.env == :prod,
11 | start_permanent: Mix.env == :prod,
12 | test_coverage: [tool: ExCoveralls],
13 | preferred_cli_env: ["coveralls": :test,
14 | "coveralls.detail": :test,
15 | "coveralls.post": :test,
16 | "coveralls.html": :test],
17 | aliases: aliases(),
18 | deps: deps()]
19 | end
20 |
21 | # Configuration for the OTP application.
22 | #
23 | # Type `mix help compile.app` for more information.
24 | def application do
25 | [mod: {Feedback, []},
26 | applications: [:phoenix, :phoenix_pubsub, :phoenix_html, :cowboy, :logger, :gettext,
27 | :phoenix_ecto, :postgrex, :comeonin, :bamboo]]
28 | end
29 |
30 | # Specifies which paths to compile per environment.
31 | defp elixirc_paths(:test), do: ["lib", "web", "test/support"]
32 | defp elixirc_paths(_), do: ["lib", "web"]
33 |
34 | # Specifies your project dependencies.
35 | #
36 | # Type `mix help deps` for examples and options.
37 | defp deps do
38 | [{:phoenix, "~> 1.2.1"},
39 | {:phoenix_pubsub, "~> 1.0"},
40 | {:phoenix_ecto, "~> 3.0"},
41 | {:postgrex, ">= 0.0.0"},
42 | {:phoenix_html, "~> 2.6"},
43 | {:phoenix_live_reload, "~> 1.0", only: :dev},
44 | {:gettext, "~> 0.11"},
45 | {:cowboy, "~> 1.0"},
46 | {:comeonin, "~> 2.0"},
47 | {:excoveralls, "~> 0.6.2"},
48 | {:bamboo, "~> 0.7"},
49 | {:bamboo_smtp, "~> 1.2.1"},
50 | {:mock, "~> 0.2.0", only: :test}]
51 | end
52 |
53 | # Aliases are shortcuts or tasks specific to the current project.
54 | # For example, to create, migrate and run the seeds file at once:
55 | #
56 | # $ mix ecto.setup
57 | #
58 | # See the documentation for `Mix` for more info on aliases.
59 | defp aliases do
60 | ["ecto.setup": ["ecto.create", "ecto.migrate", "run priv/repo/seeds.exs"],
61 | "ecto.reset": ["ecto.drop", "ecto.setup"],
62 | "test": ["ecto.create --quiet", "ecto.migrate", "test"]]
63 | end
64 | end
65 |
--------------------------------------------------------------------------------
/web/controllers/response_controller.ex:
--------------------------------------------------------------------------------
1 | defmodule Feedback.ResponseController do
2 | use Feedback.Web, :controller
3 | alias Feedback.{Feedback, Response}
4 |
5 | plug :authenticate when action in [:create, :update]
6 |
7 | def create(conn, %{"response" => response_params}) do
8 | feedback_id = response_params["feedback_id"]
9 | attrs
10 | = response_params
11 | |> Map.new(fn {key, val} -> {String.to_atom(key), val} end)
12 | |> Map.delete(:feedback_id)
13 |
14 | feedback = Repo.get!(Feedback, feedback_id)
15 | changeset = Ecto.build_assoc(feedback, :response) |> Response.changeset(attrs)
16 | case Repo.insert(changeset) do
17 | {:ok, response} ->
18 | response = Repo.preload(response, :feedback)
19 | case get_referer(conn.req_headers) do
20 | "forum" ->
21 | send_response_email_if_exists(response.feedback)
22 | conn
23 | |> put_flash(:info, "Response sent successfully!")
24 | |> redirect(to: forum_path(conn, :forum_show, feedback.id))
25 | _other ->
26 | send_response_email_if_exists(response.feedback)
27 | conn
28 | |> put_flash(:info, "Response sent successfully!")
29 | |> redirect(to: feedback_path(conn, :show, feedback.permalink_string))
30 | end
31 | {:error, _changeset} ->
32 | conn
33 | |> put_flash(:error, "Oops! Something went wrong. Please try again")
34 | |> redirect(to: feedback_path(conn, :index))
35 | end
36 | end
37 |
38 | def update(conn, %{"id" => id, "response" => response_params}) do
39 | response = Repo.get!(Response, id) |> Repo.preload(:feedback)
40 | changeset = Response.changeset(response, response_params)
41 | case Repo.update(changeset) do
42 | {:ok, response} ->
43 | response = Repo.preload(response, :feedback)
44 | case get_referer(conn.req_headers) do
45 | "forum" ->
46 | conn
47 | |> redirect(to: forum_path(conn, :forum_show, response.feedback.id))
48 | _other ->
49 | conn
50 | |> redirect(to: feedback_path(conn, :show, response.feedback.permalink_string))
51 | end
52 | {:error, _changeset} ->
53 | conn
54 | |> put_flash(:error, "Oops! Something went wrong. Make sure the response isn't empty")
55 | |> redirect(to: feedback_path(conn, :show, response.feedback.permalink_string))
56 | end
57 | end
58 | end
59 |
--------------------------------------------------------------------------------
/priv/gettext/en/LC_MESSAGES/errors.po:
--------------------------------------------------------------------------------
1 | ## `msgid`s in this file come from POT (.pot) files.
2 | ##
3 | ## Do not add, change, or remove `msgid`s manually here as
4 | ## they're tied to the ones in the corresponding POT file
5 | ## (with the same domain).
6 | ##
7 | ## Use `mix gettext.extract --merge` or `mix gettext.merge`
8 | ## to merge POT files into PO files.
9 | msgid ""
10 | msgstr ""
11 | "Language: en\n"
12 |
13 | ## From Ecto.Changeset.cast/4
14 | msgid "can't be blank"
15 | msgstr ""
16 |
17 | ## From Ecto.Changeset.unique_constraint/3
18 | msgid "has already been taken"
19 | msgstr ""
20 |
21 | ## From Ecto.Changeset.put_change/3
22 | msgid "is invalid"
23 | msgstr ""
24 |
25 | ## From Ecto.Changeset.validate_format/3
26 | msgid "has invalid format"
27 | msgstr ""
28 |
29 | ## From Ecto.Changeset.validate_subset/3
30 | msgid "has an invalid entry"
31 | msgstr ""
32 |
33 | ## From Ecto.Changeset.validate_exclusion/3
34 | msgid "is reserved"
35 | msgstr ""
36 |
37 | ## From Ecto.Changeset.validate_confirmation/3
38 | msgid "does not match confirmation"
39 | msgstr ""
40 |
41 | ## From Ecto.Changeset.no_assoc_constraint/3
42 | msgid "is still associated to this entry"
43 | msgstr ""
44 |
45 | msgid "are still associated to this entry"
46 | msgstr ""
47 |
48 | ## From Ecto.Changeset.validate_length/3
49 | msgid "should be %{count} character(s)"
50 | msgid_plural "should be %{count} character(s)"
51 | msgstr[0] ""
52 | msgstr[1] ""
53 |
54 | msgid "should have %{count} item(s)"
55 | msgid_plural "should have %{count} item(s)"
56 | msgstr[0] ""
57 | msgstr[1] ""
58 |
59 | msgid "should be at least %{count} character(s)"
60 | msgid_plural "should be at least %{count} character(s)"
61 | msgstr[0] ""
62 | msgstr[1] ""
63 |
64 | msgid "should have at least %{count} item(s)"
65 | msgid_plural "should have at least %{count} item(s)"
66 | msgstr[0] ""
67 | msgstr[1] ""
68 |
69 | msgid "should be at most %{count} character(s)"
70 | msgid_plural "should be at most %{count} character(s)"
71 | msgstr[0] ""
72 | msgstr[1] ""
73 |
74 | msgid "should have at most %{count} item(s)"
75 | msgid_plural "should have at most %{count} item(s)"
76 | msgstr[0] ""
77 | msgstr[1] ""
78 |
79 | ## From Ecto.Changeset.validate_number/3
80 | msgid "must be less than %{number}"
81 | msgstr ""
82 |
83 | msgid "must be greater than %{number}"
84 | msgstr ""
85 |
86 | msgid "must be less than or equal to %{number}"
87 | msgstr ""
88 |
89 | msgid "must be greater than or equal to %{number}"
90 | msgstr ""
91 |
92 | msgid "must be equal to %{number}"
93 | msgstr ""
94 |
--------------------------------------------------------------------------------
/priv/gettext/errors.pot:
--------------------------------------------------------------------------------
1 | ## This file is a PO Template file.
2 | ##
3 | ## `msgid`s here are often extracted from source code.
4 | ## Add new translations manually only if they're dynamic
5 | ## translations that can't be statically extracted.
6 | ##
7 | ## Run `mix gettext.extract` to bring this file up to
8 | ## date. Leave `msgstr`s empty as changing them here as no
9 | ## effect: edit them in PO (`.po`) files instead.
10 |
11 | ## From Ecto.Changeset.cast/4
12 | msgid "can't be blank"
13 | msgstr ""
14 |
15 | ## From Ecto.Changeset.unique_constraint/3
16 | msgid "has already been taken"
17 | msgstr ""
18 |
19 | ## From Ecto.Changeset.put_change/3
20 | msgid "is invalid"
21 | msgstr ""
22 |
23 | ## From Ecto.Changeset.validate_format/3
24 | msgid "has invalid format"
25 | msgstr ""
26 |
27 | ## From Ecto.Changeset.validate_subset/3
28 | msgid "has an invalid entry"
29 | msgstr ""
30 |
31 | ## From Ecto.Changeset.validate_exclusion/3
32 | msgid "is reserved"
33 | msgstr ""
34 |
35 | ## From Ecto.Changeset.validate_confirmation/3
36 | msgid "does not match confirmation"
37 | msgstr ""
38 |
39 | ## From Ecto.Changeset.no_assoc_constraint/3
40 | msgid "is still associated to this entry"
41 | msgstr ""
42 |
43 | msgid "are still associated to this entry"
44 | msgstr ""
45 |
46 | ## From Ecto.Changeset.validate_length/3
47 | msgid "should be %{count} character(s)"
48 | msgid_plural "should be %{count} character(s)"
49 | msgstr[0] ""
50 | msgstr[1] ""
51 |
52 | msgid "should have %{count} item(s)"
53 | msgid_plural "should have %{count} item(s)"
54 | msgstr[0] ""
55 | msgstr[1] ""
56 |
57 | msgid "should be at least %{count} character(s)"
58 | msgid_plural "should be at least %{count} character(s)"
59 | msgstr[0] ""
60 | msgstr[1] ""
61 |
62 | msgid "should have at least %{count} item(s)"
63 | msgid_plural "should have at least %{count} item(s)"
64 | msgstr[0] ""
65 | msgstr[1] ""
66 |
67 | msgid "should be at most %{count} character(s)"
68 | msgid_plural "should be at most %{count} character(s)"
69 | msgstr[0] ""
70 | msgstr[1] ""
71 |
72 | msgid "should have at most %{count} item(s)"
73 | msgid_plural "should have at most %{count} item(s)"
74 | msgstr[0] ""
75 | msgstr[1] ""
76 |
77 | ## From Ecto.Changeset.validate_number/3
78 | msgid "must be less than %{number}"
79 | msgstr ""
80 |
81 | msgid "must be greater than %{number}"
82 | msgstr ""
83 |
84 | msgid "must be less than or equal to %{number}"
85 | msgstr ""
86 |
87 | msgid "must be greater than or equal to %{number}"
88 | msgstr ""
89 |
90 | msgid "must be equal to %{number}"
91 | msgstr ""
92 |
--------------------------------------------------------------------------------
/config/prod.exs:
--------------------------------------------------------------------------------
1 | use Mix.Config
2 |
3 | # For production, we configure the host to read the PORT
4 | # from the system environment. Therefore, you will need
5 | # to set PORT=80 before running your server.
6 | #
7 | # You should also configure the url host to something
8 | # meaningful, we use this information when generating URLs.
9 | #
10 | # Finally, we also include the path to a manifest
11 | # containing the digested version of static files. This
12 | # manifest is generated by the mix phoenix.digest task
13 | # which you typically run after static files are built.
14 | config :feedback, Feedback.Endpoint,
15 | http: [port: {:system, "PORT"}],
16 | url: [scheme: "https", host: "dwyl-feedback.herokuapp.com", port: 443],
17 | force_ssl: [rewrite_on: [:x_forwarded_proto]],
18 | secret_key_base: System.get_env("SECRET_KEY_BASE"),
19 | cache_static_manifest: "priv/static/manifest.json"
20 |
21 | # Configure the database
22 |
23 | config :feedback, Feedback.Repo,
24 | adapter: Ecto.Adapters.Postgres,
25 | url: System.get_env("DATABASE_URL"),
26 | pool_size: "10",
27 | ssl: true
28 |
29 | # Do not print debug messages in production
30 | config :logger, level: :info
31 |
32 | # ## SSL Support
33 | #
34 | # To get SSL working, you will need to add the `https` key
35 | # to the previous section and set your `:url` port to 443:
36 | #
37 | # config :feedback, Feedback.Endpoint,
38 | # ...
39 | # url: [host: "example.com", port: 443],
40 | # https: [port: 443,
41 | # keyfile: System.get_env("SOME_APP_SSL_KEY_PATH"),
42 | # certfile: System.get_env("SOME_APP_SSL_CERT_PATH")]
43 | #
44 | # Where those two env variables return an absolute path to
45 | # the key and cert in disk or a relative path inside priv,
46 | # for example "priv/ssl/server.key".
47 | #
48 | # We also recommend setting `force_ssl`, ensuring no data is
49 | # ever sent via http, always redirecting to https:
50 | #
51 | # config :feedback, Feedback.Endpoint,
52 | # force_ssl: [hsts: true]
53 | #
54 | # Check `Plug.SSL` for all available options in `force_ssl`.
55 |
56 | # ## Using releases
57 | #
58 | # If you are doing OTP releases, you need to instruct Phoenix
59 | # to start the server for all endpoints:
60 | #
61 | # config :phoenix, :serve_endpoints, true
62 | #
63 | # Alternatively, you can configure exactly which server to
64 | # start per endpoint:
65 | #
66 | # config :feedback, Feedback.Endpoint, server: true
67 | #
68 |
69 | # Finally import the config/prod.secret.exs
70 | # which should be versioned separately.
71 | # import_config "prod.secret.exs"
72 |
--------------------------------------------------------------------------------
/test/controllers/session_controller_test.exs:
--------------------------------------------------------------------------------
1 | defmodule Feedback.SessionControllerTest do
2 | use Feedback.ConnCase, async: false
3 |
4 | alias Feedback.User
5 |
6 | describe "session routes that don't need authentication" do
7 | test "Get /session/new", %{conn: conn} do
8 | conn = get conn, session_path(conn, :new)
9 | assert html_response(conn, 200) =~ "Login"
10 | end
11 | end
12 |
13 | describe "session routes that need authentication" do
14 | setup do
15 | insert_validated_user()
16 |
17 | conn = assign(build_conn(), :current_user, Repo.get(User, id().user))
18 | {:ok, conn: conn}
19 | end
20 |
21 | test "Get /session/new logged in", %{conn: conn} do
22 | changes = %{first_name: "First",
23 | last_name: "Last",
24 | email: "email@test2.com",
25 | password: "supersecret",
26 | id: 2}
27 |
28 | user =
29 | %User{}
30 | |> User.registration_changeset(changes)
31 | |> Repo.insert!
32 | conn =
33 | conn
34 | |> assign(:current_user, user)
35 | conn = get conn, session_path(conn, :new)
36 | assert redirected_to(conn, 302) =~ "/feedback"
37 | end
38 |
39 | test "Login: Valid session /session/new", %{conn: conn} do
40 | conn = post conn, session_path(conn, :create,
41 | %{"session" => %{"email" => "email@test.com", "password" => "supersecret"}})
42 | assert redirected_to(conn, 302) =~ "/feedback"
43 | end
44 |
45 | test "Login: Invalid session /sessions/new", %{conn: conn} do
46 | conn = post conn, session_path(conn, :create,
47 | %{"session" => %{"email" => "invalid@test.com", "password" => "invalid"}})
48 | assert html_response(conn, 302) =~ "/session/new"
49 | end
50 |
51 | test "Login: Invalid password", %{conn: conn} do
52 | conn = post conn, session_path(conn, :create,
53 | %{"session" => %{"email" => "email@test.com", "password" => "invalid"}})
54 | assert html_response(conn, 302) =~ "/session/new"
55 | end
56 |
57 | test "Logout", %{conn: conn} do
58 | changes = %{first_name: "First",
59 | last_name: "Last",
60 | email: "email@test1.com",
61 | password: "supersecret",
62 | id: 1}
63 |
64 | user =
65 | %User{}
66 | |> User.registration_changeset(changes)
67 | |> Repo.insert!
68 | conn =
69 | conn
70 | |> assign(:current_user, user)
71 | conn = delete conn, session_path(conn, :delete, conn.assigns.current_user)
72 | assert redirected_to(conn, 302) =~ "/"
73 | end
74 | end
75 | end
76 |
--------------------------------------------------------------------------------
/web/static/assets/images/sad.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
5 |
6 |
7 |
9 |
11 |
14 |
19 |
24 |
26 |
28 |
29 |
30 |
31 |
--------------------------------------------------------------------------------
/web/controllers/helpers.ex:
--------------------------------------------------------------------------------
1 | defmodule Feedback.Controllers.Helpers do
2 | alias Feedback.{Repo, Feedback, Email, Mailer}
3 |
4 | def generate_permalink_string(length) do
5 | :crypto.strong_rand_bytes(length) |> Base.url_encode64 |> binary_part(0, length)
6 | end
7 |
8 | def sort_by_ascending_date(enum) do
9 | enum |> Enum.sort(&(&1.inserted_at >= &2.inserted_at))
10 | end
11 |
12 | def format_error(changeset) do
13 | errors = changeset.errors
14 | case length(errors) do
15 | 1 ->
16 | {key, _} = Enum.at(errors, 0)
17 | case key do
18 | :item ->
19 | "Make sure you write something in the feedback textbox"
20 | :mood ->
21 | "Make sure you select your mood"
22 | end
23 | 2 ->
24 | "To leave feedback, select your mood and write your thoughts in the
25 | textbox"
26 | end
27 | end
28 |
29 | def get_feedback(emotion) do
30 | raw_feedback = Repo.all(Feedback) |> Repo.preload(:response)
31 | feedback =
32 | raw_feedback
33 | |> Enum.filter(fn item -> item.response == nil end)
34 | |> Enum.filter(fn item -> item.mood == emotion end)
35 | |> sort_by_ascending_date()
36 |
37 | feedback
38 | end
39 |
40 | def get_responded_feedback(emotion) do
41 | raw_feedback = Repo.all(Feedback) |> Repo.preload(:response)
42 | responded_feedback =
43 | raw_feedback
44 | |> Enum.filter(fn item -> item.response != nil end)
45 | |> Enum.filter(fn item -> item.mood == emotion end)
46 | |> sort_by_ascending_date()
47 |
48 | responded_feedback
49 | end
50 |
51 | def get_referer(headers) do
52 | [{_header, value}] = Enum.filter(headers, fn {header, _value} -> header == "referer" end)
53 | path = Enum.at(String.split(value, "/"), 3)
54 | path
55 | end
56 |
57 | def get_base_url() do
58 | dev_env? = Mix.env == :dev
59 | case dev_env? do
60 | true -> System.get_env("DEV_URL")
61 | false -> System.get_env("PROD_URL")
62 | end
63 | end
64 |
65 | def send_response_email_if_exists(feedback) do
66 | link = "#{get_base_url()}/feedback/#{feedback.permalink_string}"
67 | subject = "Feedback Response"
68 | message = "Hi there! There has been a response to your feedback. Follow this link #{link} to view it."
69 | if feedback.submitter_email != nil do
70 | Email.send_email(feedback.submitter_email, subject, message)
71 | |> Mailer.deliver_later()
72 | end
73 | end
74 |
75 | def send_feedback_email(feedback) do
76 | link = "#{get_base_url()}/feedback/#{feedback.permalink_string}"
77 | subject = "New Feedback"
78 | message = "You have a new piece of #{feedback.mood} feedback: \"#{feedback.item}\". Follow this link #{link} to respond."
79 | Email.send_email(System.get_env("ADMIN_EMAIL"), subject, message)
80 | |> Mailer.deliver_later()
81 | end
82 |
83 | end
84 |
--------------------------------------------------------------------------------
/web/static/js/socket.js:
--------------------------------------------------------------------------------
1 | // NOTE: The contents of this file will only be executed if
2 | // you uncomment its entry in "web/static/js/app.js".
3 |
4 | // To use Phoenix channels, the first step is to import Socket
5 | // and connect at the socket path in "lib/my_app/endpoint.ex":
6 | import {Socket} from "phoenix"
7 |
8 | let socket = new Socket("/socket", {params: {token: window.userToken}})
9 |
10 | // When you connect, you'll often need to authenticate the client.
11 | // For example, imagine you have an authentication plug, `MyAuth`,
12 | // which authenticates the session and assigns a `:current_user`.
13 | // If the current user exists you can assign the user's token in
14 | // the connection for use in the layout.
15 | //
16 | // In your "web/router.ex":
17 | //
18 | // pipeline :browser do
19 | // ...
20 | // plug MyAuth
21 | // plug :put_user_token
22 | // end
23 | //
24 | // defp put_user_token(conn, _) do
25 | // if current_user = conn.assigns[:current_user] do
26 | // token = Phoenix.Token.sign(conn, "user socket", current_user.id)
27 | // assign(conn, :user_token, token)
28 | // else
29 | // conn
30 | // end
31 | // end
32 | //
33 | // Now you need to pass this token to JavaScript. You can do so
34 | // inside a script tag in "web/templates/layout/app.html.eex":
35 | //
36 | //
37 | //
38 | // You will need to verify the user token in the "connect/2" function
39 | // in "web/channels/user_socket.ex":
40 | //
41 | // def connect(%{"token" => token}, socket) do
42 | // # max_age: 1209600 is equivalent to two weeks in seconds
43 | // case Phoenix.Token.verify(socket, "user socket", token, max_age: 1209600) do
44 | // {:ok, user_id} ->
45 | // {:ok, assign(socket, :user, user_id)}
46 | // {:error, reason} ->
47 | // :error
48 | // end
49 | // end
50 | //
51 | // Finally, pass the token on connect as below. Or remove it
52 | // from connect if you don't care about authentication.
53 |
54 | socket.connect()
55 |
56 | // Now that you are connected, you can join channels with a topic:
57 | let channel = socket.channel("room:lobby", {});
58 | let responseButton = document.getElementById("response-button");
59 | // let feedbackButton = document.getElementById("feedback-button");
60 |
61 | channel.join()
62 | .receive("ok", resp => { console.log("Sockets initialised", resp) })
63 | .receive("error", resp => { console.log("Unable to join", resp) })
64 |
65 | if (responseButton !== null) {
66 | responseButton.addEventListener("click", event => {
67 | channel.push("responded", {body: "responded"})
68 | })
69 | }
70 |
71 | // if (feedbackButton !== null) {
72 | // feedbackButton.addEventListener("click", event => {
73 | // channel.push("responded", {body: "responded"})
74 | // })
75 | // }
76 |
77 | channel.on("responded", payload => {
78 | setTimeout(function () {
79 | window.location.reload()
80 | }, 100)
81 | })
82 |
83 | export default socket
84 |
--------------------------------------------------------------------------------
/test/controllers/auth_test.exs:
--------------------------------------------------------------------------------
1 | defmodule Feedback.AuthTest do
2 | use Feedback.ConnCase, async: false
3 |
4 | alias Feedback.{Auth, Router, User}
5 |
6 | describe "auth controller" do
7 | setup %{conn: conn} do
8 | conn =
9 | conn
10 | |> bypass_through(Router, :browser)
11 | |> get("/")
12 |
13 | {:ok, %{conn: conn}}
14 | end
15 |
16 | test "testing init function", do: assert Auth.init([repo: 1])
17 |
18 | test "authenticate_user halts when no current_user exists", %{conn: conn} do
19 | conn = Auth.authenticate(conn, [])
20 |
21 | assert conn.halted
22 | end
23 |
24 | test "authenticate continues when the current_user exists", %{conn: conn} do
25 | conn =
26 | conn
27 | |> assign(:current_user, %User{})
28 | |> Auth.authenticate([])
29 | refute conn.halted
30 | end
31 |
32 | test "login puts the user in the session", %{conn: conn} do
33 | login_conn =
34 | conn
35 | |> Auth.login(%User{id: id().user})
36 | |> send_resp(:ok, "")
37 | next_conn = get(login_conn, "/")
38 | assert get_session(next_conn, :user_id) == id().user
39 | end
40 |
41 | test "logout drops the session", %{conn: conn} do
42 | logout_conn =
43 | conn
44 | |> put_session(:user_id, id().user)
45 | |> Auth.logout()
46 | |> send_resp(:ok, "")
47 |
48 | next_conn = get(logout_conn, "/")
49 | refute get_session(next_conn, :user_id)
50 | end
51 |
52 | test "call places user from session into assigns", %{conn: conn} do
53 | user = insert_validated_user()
54 | conn =
55 | conn
56 | |> put_session(:user_id, user.id)
57 | |> Auth.call(Repo)
58 |
59 | assert conn.assigns.current_user.id == user.id
60 | end
61 |
62 | test "call returns conn when current_user is assigned", %{conn: conn} do
63 | user = insert_validated_user()
64 | conn =
65 | conn
66 | |> assign(:current_user, Repo.get_by(User, email: "email@test.com"))
67 | |> put_session(:user_id, user.id)
68 | |> Auth.call(Repo)
69 |
70 | assert conn.assigns.current_user.id == user.id
71 | end
72 |
73 | test "call with no session sets current_user assign to nil", %{conn: conn} do
74 | conn = Auth.call(conn, Repo)
75 | assert conn.assigns.current_user == nil
76 | end
77 |
78 | test "login with a valid username and pass", %{conn: conn} do
79 | user = insert_validated_user()
80 | {:ok, conn} =
81 | Auth.login_by_email_and_pass(conn, "email@test.com", "supersecret", repo: Repo)
82 |
83 | assert conn.assigns.current_user.id == user.id
84 | end
85 |
86 | test "login with a not found user", %{conn: conn} do
87 | assert {:error, :not_found, _conn} =
88 | Auth.login_by_email_and_pass(conn, "notemail@nottest.com", "supersecret", repo: Repo)
89 | end
90 |
91 | test "login with password mismatch", %{conn: conn} do
92 | insert_validated_user()
93 | assert {:error, :unauthorized, _conn} =
94 | Auth.login_by_email_and_pass(conn, "email@test.com", "wrong", repo: Repo)
95 | end
96 | end
97 | end
98 |
--------------------------------------------------------------------------------
/web/templates/forum/forum_show.html.eex:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 | <%= if !@feedback.public do %>
8 |
9 | <% end %>
10 |
Feedback -
11 | <%= format_date(@feedback.inserted_at) %>
12 |
13 |
<%= @feedback.item %>
14 |
15 |
16 | <%= if @feedback.response && !@conn.assigns.current_user do %>
17 |
18 |
Response -
19 | <%= format_date(@feedback.response.inserted_at) %>
20 |
21 |
<%= @feedback.response.response %>
22 |
23 | <% end %>
24 |
25 | <%= if @feedback.response && @conn.assigns.current_user && !@feedback.response.edit do %>
26 |
27 |
28 | <%= form_for @response_changeset, response_path(@conn, :update, @feedback.response), fn f -> %>
29 | <%= hidden_input f, :edit, value: "true" %>
30 | <%= submit "Edit", class: "edit-button" %>
31 | <% end %>
32 |
33 |
Response -
34 | <%= format_date(@feedback.response.inserted_at) %>
35 | <%= if @feedback.response.edited do %>
36 | (edited)
37 | <% end %>
38 |
39 |
<%= @feedback.response.response %>
40 |
41 | <% end %>
42 |
43 | <%= if @feedback.response && @feedback.response.edit && @conn.assigns.current_user do %>
44 |
45 |
Response -
46 | <%= format_date(@feedback.response.inserted_at) %>
47 | <%= if @feedback.response.edited do %>
48 | (edited)
49 | <% end %>
50 |
51 | <%= form_for @response_changeset, response_path(@conn, :update, @feedback.response), fn f -> %>
52 | <%= textarea f, :response, value: @feedback.response.response, class: "text_area text_area-edit" %>
53 | <%= error_tag f, :response %>
54 | <%= hidden_input f, :edit, value: "false" %>
55 | <%= hidden_input f, :edited, value: "true" %>
56 | <%= submit "Update", class: "button response-button" %>
57 | <% end %>
58 |
59 | <% end %>
60 |
61 | <%= if !@feedback.response && !@conn.assigns.current_user do %>
62 |
63 | There hasn't been a response to this feedback yet. Please check again later!
64 |
65 | <% end %>
66 |
67 | <%= if !@feedback.response && @conn.assigns.current_user do %>
68 | <%= form_for @changeset, response_path(@conn, :create), fn f -> %>
69 | <%= textarea f, :response, placeholder: "Enter your feedback response", class: "text_area" %>
70 | <%= error_tag f, :response %>
71 | <%= hidden_input f, :feedback_id, value: @feedback.id %>
72 | <%= submit "Respond", [class: "button response-button", id: "response-button"] %>
73 | <% end %>
74 | <% end %>
75 |
--------------------------------------------------------------------------------
/web/static/assets/images/delighted.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
5 |
6 |
7 |
9 |
12 |
15 |
18 |
25 |
32 |
35 |
36 |
37 |
38 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # feedback
2 |
3 | An anonymous feedback mechanism guaranteeing anonymity to those providing it.
4 |
5 | Try it: https://dwyl-feedback.herokuapp.com
6 |
7 | 
8 |
9 | ## _Why_?
10 |
11 | We _all_ have things we would _like_ to improve in our lives and work.
12 | But _often_ we do not know how to approach communicating the "issue"
13 | without _offending_ someone.
14 | If we just _ignore_ the issue or
15 | ["_bottle it up_"](https://youtu.be/tf92q6Vrj2o)
16 | without saying anything,
17 | the issue _rarely_ just "_goes away_".
18 |
19 | We need a _systematic_ way of sharing feedback on _anything_.
20 | So that _anyone_ can describe an issue, capture and address it.
21 |
22 |
23 | ## _What_?
24 |
25 | + Collect ***anonymous*** feedback from _anyone_.
26 | + Store that feedback in a simple database
27 | + Act on the feedback and show progress towards _solving_ any issues raised.
28 |
29 | > Initially the feedback will only be _internal_ but we need
30 | to _discuss_ the potential for how to make it `public`
31 | please share your thoughts on this: https://github.com/dwyl/feedback/issues/2
32 |
33 |
34 |
35 |
36 | ## _Who_?
37 |
38 | ### _Who_ is the feedback app made for?
39 |
40 | Initially this app is for our _internal_ purposes.
41 | We @dwyl are doing a _terrible_ job of collecting feedback from
42 | all the team members, clients,
43 | "users" of the apps we build and other stakeholders.
44 |
45 | The `feedback` app addresses this challenge.
46 |
47 | ### _Who_ _might_ the feedback app be useful to in the future?
48 |
49 | _Any_ organsiation or individual who needs a _systematic_ way of collecting
50 | both specific/targeted ***and*** _anonymous_ feedback.
51 |
52 |
53 | ## _How_?
54 |
55 | To run this app locally, you will need to have some _basic_ Phoenix Knowledge.
56 | We recommend reading: https://github.com/dwyl/learn-phoenix-framework
57 |
58 | ### Get Started in _2 Minutes_
59 |
60 |
61 | + Clone the Git repository: `git clone git@github.com:dwyl/feedback.git && cd feedback`
62 | + Install dependencies with `mix deps.get && npm install`
63 | + Create and migrate your database with `mix ecto.create && mix ecto.migrate`
64 | + Set environment variables:
65 | + ADMIN_EMAIL - the email that you want to log in with (must also be verified by AWS)
66 | + ADMIN_PASSWORD - the password you want to log in with
67 | + SECRET_KEY_BASE - taken from `config.exs`
68 | + TARGET_EMAIL - verified SES email for testing
69 | + SES_SERVER - your SES server
70 | + SES_PORT - your SES port
71 | + SMTP_USERNAME - your SMTP username
72 | + SMTP_PASSWORD - your SMTP password
73 | + DEV_URL - http://localhost:4000
74 | + PROD_URL - url of the live site
75 | + Run `priv/repo/seeds.exs`
76 | + Run `source .env` to load your environment variables
77 | + Start Phoenix endpoint with `mix phoenix.server`
78 |
79 | Now visit [`localhost:4000`](http://localhost:4000) from your web browser.
80 |
81 |
82 | # Research
83 |
84 | See the [`research.md`](https://github.com/dwyl/feedback/blob/master/research.md)
85 | file.
86 |
87 | # Conclusion
88 |
89 | We have [surveyed the market](https://github.com/dwyl/feedback/blob/master/research.md)
90 | and can conclude that there isn't an existing Open-Source, easy-to-run application
91 | or Service with an API we can use in April 2017 so we decided to *make* one.
92 |
--------------------------------------------------------------------------------
/test/controllers/response_controller_test.exs:
--------------------------------------------------------------------------------
1 | defmodule Feedback.ResponseControllerTest do
2 | use Feedback.ConnCase, async: false
3 | alias Feedback.{Mailer}
4 |
5 | import Mock
6 |
7 | test "response/create valid from forum", %{conn: conn} do
8 | with_mock Mailer, [deliver_later: fn(_) -> nil end] do
9 | Mix.env(:test)
10 | user = insert_validated_user()
11 | conn =
12 | conn
13 | |> assign(:current_user, user)
14 | |> put_req_header("referer", "http://localhost:4000/forum")
15 | feedback = insert_feedback()
16 | conn = post conn, response_path(conn, :create, %{"response" => %{response: "response", feedback_id: feedback.id}})
17 | [{_header, location}] = Enum.filter(conn.resp_headers, fn {header, _value} -> header == "location" end)
18 | assert redirected_to(conn, 302) =~ location
19 | end
20 | end
21 |
22 | test "response/create valid from feedback", %{conn: conn} do
23 | with_mock Mailer, [deliver_later: fn(_) -> nil end] do
24 | Mix.env(:dev)
25 | user = insert_validated_user()
26 | conn =
27 | conn
28 | |> assign(:current_user, user)
29 | |> put_req_header("referer", "http://localhost:4000/feedback")
30 | feedback = insert_feedback(%{submitter_email: "test@email.com"})
31 | conn = post conn, response_path(conn, :create, %{"response" => %{response: "response", feedback_id: feedback.id}})
32 | [{_header, location}] = Enum.filter(conn.resp_headers, fn {header, _value} -> header == "location" end)
33 | assert redirected_to(conn, 302) =~ location
34 | end
35 | end
36 |
37 | test "response/create invalid", %{conn: conn} do
38 | user = insert_validated_user()
39 | conn =
40 | conn
41 | |> assign(:current_user, user)
42 | |> put_req_header("referer", "http://localhost:4000/feedback")
43 | feedback = insert_feedback()
44 | conn = post conn, response_path(conn, :create, %{"response" => %{response: "", feedback_id: feedback.id}})
45 | [{_header, location}] = Enum.filter(conn.resp_headers, fn {header, _value} -> header == "location" end)
46 | assert redirected_to(conn, 302) =~ location
47 | end
48 |
49 | test "response/:id update feedback", %{conn: conn} do
50 | user = insert_validated_user()
51 | conn =
52 | conn
53 | |> assign(:current_user, user)
54 | |> put_req_header("referer", "http://localhost:4000/feedback")
55 | feedback = insert_feedback()
56 | response = insert_response(%{feedback_id: feedback.id})
57 | conn = post conn, response_path(conn, :update, response, %{"response" => %{"response" => "changed my mind"}})
58 | assert redirected_to(conn, 302) =~ "/feedback/#{feedback.permalink_string}"
59 | end
60 |
61 | test "response/:id update forum", %{conn: conn} do
62 | user = insert_validated_user()
63 | conn =
64 | conn
65 | |> assign(:current_user, user)
66 | |> put_req_header("referer", "http://localhost:4000/forum")
67 | feedback = insert_feedback()
68 | response = insert_response(%{feedback_id: feedback.id})
69 | conn = post conn, response_path(conn, :update, response, %{"response" => %{"response" => "changed my mind"}})
70 | assert redirected_to(conn, 302) =~ "/forum/#{feedback.id}"
71 | end
72 |
73 | test "response/:id update error", %{conn: conn} do
74 | user = insert_validated_user()
75 | conn =
76 | conn
77 | |> assign(:current_user, user)
78 | |> put_req_header("referer", "http://localhost:4000/forum")
79 | feedback = insert_feedback()
80 | response = insert_response(%{feedback_id: feedback.id})
81 | conn = post conn, response_path(conn, :update, response, %{"response" => %{"response" => ""}})
82 | assert redirected_to(conn, 302) =~ "/feedback/#{feedback.permalink_string}"
83 | end
84 | end
85 |
--------------------------------------------------------------------------------
/web/controllers/feedback_controller.ex:
--------------------------------------------------------------------------------
1 | defmodule Feedback.FeedbackController do
2 | use Feedback.Web, :controller
3 | alias Feedback.{Feedback, LayoutView, Response}
4 |
5 | plug :authenticate when action in [:index, :angry, :upset, :neutral, :happy, :delighted]
6 |
7 | def index(conn, _params) do
8 |
9 | happy_feedback = get_feedback("happy")
10 | delighted_feedback = get_feedback("delighted")
11 | neutral_feedback = get_feedback("neutral")
12 | confused_feedback = get_feedback("confused")
13 | angry_feedback = get_feedback("angry")
14 | sad_feedback = get_feedback("sad")
15 |
16 | emotions = [
17 | {"delighted", delighted_feedback},
18 | {"happy", happy_feedback},
19 | {"neutral", neutral_feedback},
20 | {"confused", confused_feedback},
21 | {"sad", sad_feedback},
22 | {"angry", angry_feedback}]
23 |
24 | render conn, "index.html", layout: {LayoutView, "nav.html"}, emotions: emotions
25 |
26 | end
27 |
28 | def new(conn, _params) do
29 | changeset = Feedback.changeset(%Feedback{})
30 | emotions = ["angry", "sad", "confused", "neutral", "happy", "delighted"]
31 | render conn, "new.html", layout: {LayoutView, "index.html"}, changeset: changeset, emotions: emotions
32 | end
33 |
34 | def show(conn, %{"id" => permalink}) do
35 | case Repo.get_by(Feedback, permalink_string: permalink) do
36 | nil ->
37 | conn
38 | |> put_flash(:error, "That piece of feedback doesn't exist")
39 | |> redirect(to: page_path(conn, :index))
40 | feedback ->
41 | loaded_feedback = Repo.preload(feedback, :response)
42 | response_changeset = Response.changeset(%Response{})
43 | changeset = Feedback.changeset(feedback)
44 | render conn, "show.html", layout: {LayoutView, "nav.html"}, feedback: loaded_feedback, changeset: changeset, response_changeset: response_changeset
45 | end
46 | end
47 |
48 | def update(conn, %{"id" => id, "feedback" => feedback_params}) do
49 | feedback = Repo.get!(Feedback, id) |> Repo.preload(:response)
50 | case Map.has_key?(feedback_params, "submitter_email") do
51 | true ->
52 | changeset = Feedback.changeset(feedback, feedback_params)
53 | case Repo.update(changeset) do
54 | {:ok, _email} ->
55 | conn
56 | |> put_flash(:info, "Email submitted successfully!")
57 | |> redirect(to: feedback_path(conn, :show, feedback.permalink_string))
58 | {:error, changeset} ->
59 | render conn, "show.html", feedback: feedback, changeset: changeset
60 | end
61 | false ->
62 | changeset = Feedback.changeset(feedback, feedback_params)
63 | case Repo.update(changeset) do
64 | {:ok, feedback} ->
65 | conn
66 | |> redirect(to: feedback_path(conn, :show, feedback.permalink_string))
67 | {:error, changeset} ->
68 | render conn, "show.html", feedback: feedback, changeset: changeset
69 | end
70 | end
71 | end
72 |
73 | def create(conn, %{"feedback" => params}) do
74 | permalink_string = generate_permalink_string(24)
75 | feedback_params = Map.put(params, "permalink_string", permalink_string)
76 | changeset = Feedback.changeset(%Feedback{}, feedback_params)
77 | case Repo.insert(changeset) do
78 | {:ok, feedback} ->
79 | send_feedback_email(feedback)
80 | conn
81 | |> put_flash(:info, "Thank you so much for your feedback!")
82 | |> redirect(to: feedback_path(conn, :show, feedback))
83 | |> halt()
84 | {:error, changeset} ->
85 | error = format_error(changeset)
86 | conn
87 | |> put_flash(:error, "Oops! Something went wrong. #{error}")
88 | |> redirect(to: feedback_path(conn, :new))
89 | |> halt()
90 | end
91 | end
92 |
93 | def happy(conn, _params) do
94 | feedback = get_feedback("happy")
95 | responded_feedback = get_responded_feedback("happy")
96 |
97 | render conn, "happy.html", layout: {LayoutView, "nav.html"}, feedback: feedback, responded_feedback: responded_feedback
98 | end
99 |
100 |
101 | def delighted(conn, _params) do
102 | feedback = get_feedback("delighted")
103 | responded_feedback = get_responded_feedback("delighted")
104 |
105 | render conn, "delighted.html", layout: {LayoutView, "nav.html"}, feedback: feedback, responded_feedback: responded_feedback
106 | end
107 |
108 | def neutral(conn, _params) do
109 | feedback = get_feedback("neutral")
110 | responded_feedback = get_responded_feedback("neutral")
111 |
112 | render conn, "neutral.html", layout: {LayoutView, "nav.html"}, feedback: feedback, responded_feedback: responded_feedback
113 | end
114 |
115 | def confused(conn, _params) do
116 | feedback = get_feedback("confused")
117 | responded_feedback = get_responded_feedback("confused")
118 |
119 | render conn, "confused.html", layout: {LayoutView, "nav.html"}, feedback: feedback, responded_feedback: responded_feedback
120 | end
121 |
122 | def sad(conn, _params) do
123 | feedback = get_feedback("sad")
124 | responded_feedback = get_responded_feedback("sad")
125 |
126 | render conn, "sad.html", layout: {LayoutView, "nav.html"}, feedback: feedback, responded_feedback: responded_feedback
127 | end
128 |
129 | def angry(conn, _params) do
130 | feedback = get_feedback("angry")
131 | responded_feedback = get_responded_feedback("angry")
132 |
133 | render conn, "angry.html", layout: {LayoutView, "nav.html"}, feedback: feedback, responded_feedback: responded_feedback
134 | end
135 |
136 | end
137 |
--------------------------------------------------------------------------------
/test/controllers/feedback_controller_test.exs:
--------------------------------------------------------------------------------
1 | defmodule Feedback.FeedbackControllerTest do
2 | use Feedback.ConnCase, async: false
3 | alias Feedback.{Email, Mailer}
4 |
5 | import Mock
6 |
7 | test "/feedback/new", %{conn: conn} do
8 | conn = get conn, "/feedback/new"
9 | assert html_response(conn, 200) =~ "Help us be better"
10 | end
11 |
12 | test "/feedback/create valid", %{conn: conn} do
13 | with_mock Mailer, [deliver_later: fn(_) -> nil end] do
14 | conn = post conn, feedback_path(conn, :create, %{"feedback" => %{item: "test", mood: "happy"}})
15 | [{_header, location}] = Enum.filter(conn.resp_headers, fn {header, _value} -> header == "location" end)
16 | assert redirected_to(conn, 302) =~ location
17 | end
18 |
19 | end
20 |
21 | test "/feedback/create", %{conn: conn} do
22 | conn = post conn, feedback_path(conn, :create, %{"feedback" => %{item: "test"}})
23 | assert redirected_to(conn, 302) =~ "/feedback/new"
24 | end
25 |
26 | test "/feedback/create invalid", %{conn: conn} do
27 | conn = post conn, feedback_path(conn, :create, %{"feedback" => %{item: "", permalink_string: ""}})
28 | assert redirected_to(conn, 302) =~ "/feedback/new"
29 | end
30 |
31 | test "/feedback", %{conn: conn} do
32 | insert_feedback()
33 | insert_feedback(%{id: 2})
34 | user = insert_validated_user()
35 | conn =
36 | conn
37 | |> assign(:current_user, user)
38 | conn = get conn, feedback_path(conn, :index)
39 | assert html_response(conn, 200) =~ "Categories"
40 | end
41 |
42 | test "/feedback/:id", %{conn: conn} do
43 | feedback = insert_feedback()
44 | conn = get conn, feedback_path(conn, :show, feedback.permalink_string)
45 | assert html_response(conn, 200) =~ "Feedback"
46 | end
47 |
48 | test "/feedback/:id invalid", %{conn: conn} do
49 | conn = get conn, feedback_path(conn, :show, 1)
50 | assert redirected_to(conn, 302) =~ "/"
51 | end
52 |
53 | test "/feedback/:id update error email", %{conn: conn} do
54 | feedback = insert_feedback()
55 | insert_response(%{feedback_id: feedback.id})
56 | conn = put conn, feedback_path(conn, :update, feedback.id, %{"feedback" => %{"submitter_email" => "invalid_email_format"}})
57 | assert html_response(conn, 200) =~ "feedback"
58 | end
59 |
60 | test "/feedback/:id update email submit", %{conn: conn} do
61 | feedback = insert_feedback()
62 | conn = put conn, feedback_path(conn, :update, feedback.id, %{"feedback" => %{"submitter_email" => "test@email.com"}})
63 | assert redirected_to(conn, 302) =~ "/feedback/#{feedback.permalink_string}"
64 | end
65 |
66 | test "/feedback/:id update feedback item", %{conn: conn} do
67 | feedback = insert_feedback()
68 | conn = put conn, feedback_path(conn, :update, feedback.id, %{"feedback" => %{"item" => "changed my mind"}})
69 | assert redirected_to(conn, 302) =~ "/feedback/#{feedback.permalink_string}"
70 | end
71 |
72 | test "/feedback/:id update feedback item invalid", %{conn: conn} do
73 | feedback = insert_feedback()
74 | insert_response(%{feedback_id: feedback.id})
75 | conn = put conn, feedback_path(conn, :update, feedback.id, %{"feedback" => %{"item" => ""}})
76 | assert html_response(conn, 200) =~ "feedback"
77 | end
78 |
79 | test "/happy", %{conn: conn} do
80 | feedback = insert_feedback()
81 | insert_response(%{feedback_id: feedback.id})
82 | user = insert_validated_user()
83 | conn =
84 | conn
85 | |> assign(:current_user, user)
86 | conn = get conn, feedback_path(conn, :happy)
87 | assert html_response(conn, 200) =~ "happy"
88 | end
89 |
90 | test "/delighted", %{conn: conn} do
91 | insert_feedback(%{mood: "delighted"})
92 | insert_feedback(%{response: "response"})
93 | user = insert_validated_user()
94 | conn =
95 | conn
96 | |> assign(:current_user, user)
97 | conn = get conn, feedback_path(conn, :delighted)
98 | assert html_response(conn, 200) =~ "delighted"
99 | end
100 |
101 | test "/neutral", %{conn: conn} do
102 | insert_feedback(%{mood: "neutral"})
103 | insert_feedback(%{response: "response"})
104 | user = insert_validated_user()
105 | conn =
106 | conn
107 | |> assign(:current_user, user)
108 | conn = get conn, feedback_path(conn, :neutral)
109 | assert html_response(conn, 200) =~ "neutral"
110 | end
111 |
112 | test "/sad", %{conn: conn} do
113 | insert_feedback(%{mood: "sad"})
114 | insert_feedback(%{response: "response"})
115 | user = insert_validated_user()
116 | conn =
117 | conn
118 | |> assign(:current_user, user)
119 | conn = get conn, feedback_path(conn, :sad)
120 | assert html_response(conn, 200) =~ "sad"
121 | end
122 |
123 | test "/angry", %{conn: conn} do
124 | insert_feedback(%{mood: "angry"})
125 | insert_feedback(%{response: "response"})
126 | user = insert_validated_user()
127 | conn =
128 | conn
129 | |> assign(:current_user, user)
130 | conn = get conn, feedback_path(conn, :angry)
131 | assert html_response(conn, 200) =~ "angry"
132 | end
133 |
134 | test "/confused", %{conn: conn} do
135 | insert_feedback(%{mood: "confused"})
136 | insert_feedback(%{response: "response"})
137 | user = insert_validated_user()
138 | conn =
139 | conn
140 | |> assign(:current_user, user)
141 | conn = get conn, feedback_path(conn, :confused)
142 | assert html_response(conn, 200) =~ "confused"
143 | end
144 |
145 | test "strucuture of email is ok" do
146 | email = Email.send_email("test@email.com", "Welcome", "Hello there")
147 | assert email.to == "test@email.com"
148 | assert email.subject == "Welcome"
149 | assert email.text_body =~ "Hello there"
150 | end
151 | end
152 |
--------------------------------------------------------------------------------
/research.md:
--------------------------------------------------------------------------------
1 | # Research
2 |
3 | see: https://github.com/dwyl/feedback/issues/1
4 |
5 | ### Existing Feedback Mechanisms/Solutions
6 |
7 | + ***Uservoice***: https://www.uservoice.com is one of the most well-known
8 | product/user feedback platforms, but when we attempt to search their website
9 | for the word "anonymous"
10 | https://www.google.com/webhp?#q=https://www.uservoice.com:+anonymous
11 | we don't see any results in their product.
12 | But there is something on their forum: https://feedback.uservoice.com/forums/1-product-management/suggestions/956361-allow-anonymous-users-to-create-ideas-without-leav
13 | + ***Sayat.me***: https://www.sayat.me/ this is a feedback tool that is used
14 | by individuals to gain insight into how people honestly feel about them. It has
15 | a good interface for the feedback itself but it's too specific which means that
16 | it doesn't apply to organisations such as dwyl. The feedback is public and
17 | comments can be made on the feedback:
18 | 
19 | + ***Suggestion Ox***: https://www.suggestionox.com is a private feedback platform
20 | and is easy to set up but only allows you one suggestion box before
21 | you have to subscribe to some sort of service. The functionality looks good and
22 | it offers a few privacy features that could be nice, for example if you only want
23 | your feedback question to be viewed by certain people, you can give them a secret
24 | word that they have to enter before gaining access.
25 | 
26 | 
27 | Once your feedback has been submitted it appears in an inbox that organises and
28 | categorises them.
29 | 
30 | + ***3Sixty***: www.get3sixty.com is another feedback platform. However it isn't
31 | secure and the functionality isn't up to scratch. It is free to use but it
32 | imposes limits saying that you can only request feedback from 3 people per day
33 | which isn't great.
34 | 
35 | + ***15FIVE***: https://www.15five.com is similar to Suggestion Ox.
36 | 
37 | They give you the option to input how you're feeling on a scale of 1-5 when
38 | giving the feedback along with options to enter what's been going well and
39 | what the biggest challenge is you're facing at the minute:
40 | 
41 | Once the feedback has been submitted it gives you the opportunity to review and
42 | respond to it:
43 | 
44 | + Please add other existing providers
45 | and search for if they allow anonymous feedback ...
46 |
47 | ### Open Source Feedback Systems?
48 |
49 | > Are there any _Open Source_ feedback systems we can use and/or learn from.
50 |
51 | + ***PHPBack***: http://www.phpback.org/ is an open source project similar to
52 | Uservoice found at https://github.com/ivandiazwm/phpback/. It's a feedback system
53 | for products that can be directly injected into your website.
54 |
55 |
56 | + Scarce options for _Open Source_ feedback systems that will fit our exact
57 | needs. Please add other existing providers and add them to the list.
58 |
59 | ### Gather Requirements
60 |
61 | > Gather requirements from people you know who either work
62 | or _have_ worked somewhere they don't _love_.
63 | What _specifically_ would they like to have in a feedback system?
64 |
65 | #### Examples
66 |
67 | [@conorc1000](https://github.com/conorc1000):
68 | ##### Previous feedback experience
69 | Conor worked for a civil engineering company run by a husband and wife. The husband
70 | was the boss of the company and the wife was the company secretary. They would
71 | have an appraisal once a year where you could voice your opinion about anything
72 | regarding the company and your experience within it. However, this appraisal
73 | was more of a box ticking exercise and the feedback given wouldn't filter
74 | through to the boss which meant that no action could be taken on it, rendering
75 | the whole thing a bit pointless.
76 | ##### Ideal feedback experience
77 | Conor's ideal feedback system would consist of a free text input where you could
78 | voice your opinion on the organisation that you work with. He would have two
79 | toggles, one for anonymity and then the other for privacy. By switching the
80 | toggles on the feedback it would mean that you could attach your name to it, or
81 | not, and you could also say whether or not you would prefer if the feedback was
82 | kept private or made public. He said that it might be good to have an incentive
83 | to give feedback as well so that more people would get involved.
84 |
85 | [@roryc89](https://github.com/roryc89)
86 | ##### Previous feedback experience
87 | Rory worked for a spread-betting platform in London and said that he had similar
88 | opportunities to give feedback in the form of appraisals. His perception was
89 | that the feedback wasn't absorbed sufficiently and that it got lost among the
90 | noise.
91 | ##### Ideal feedback experience
92 | Rory's ideal feedback experience would be one with the ability to give anonymous
93 | feedback about anything that was going on within the organisation. Also he would
94 | want to open discussions about decisions that the agency makes in order to
95 | further understand the way it functions and why. Rory also believes that
96 | feedback should have the option to be private so that people are protected if
97 | neccessary (you might be able to work out the identity of a person just by
98 | the way they phrase something or the context in which they are talking).
99 |
--------------------------------------------------------------------------------
/mix.lock:
--------------------------------------------------------------------------------
1 | %{"bamboo": {:hex, :bamboo, "0.7.0", "2722e395a396dfedc12d300c900f65b216f7d7bf9d430c5dd0235690997878b7", [:mix], [{:httpoison, "~> 0.9", [hex: :httpoison, optional: false]}, {:plug, "~> 1.0", [hex: :plug, optional: false]}, {:poison, ">= 1.5.0", [hex: :poison, optional: false]}]},
2 | "bamboo_smtp": {:hex, :bamboo_smtp, "1.2.1", "47181e338cbee9d028e94f2bc5829816b26d719d8213b07d0fa107d95b591947", [:mix], [{:bamboo, "~> 0.7.0", [hex: :bamboo, optional: false]}, {:gen_smtp, "~> 0.11.0", [hex: :gen_smtp, optional: false]}]},
3 | "certifi": {:hex, :certifi, "1.0.0", "1c787a85b1855ba354f0b8920392c19aa1d06b0ee1362f9141279620a5be2039", [:rebar3], []},
4 | "comeonin": {:hex, :comeonin, "2.6.0", "74c288338b33205f9ce97e2117bb9a2aaab103a1811d243443d76fdb62f904ac", [:make, :mix], []},
5 | "connection": {:hex, :connection, "1.0.4", "a1cae72211f0eef17705aaededacac3eb30e6625b04a6117c1b2db6ace7d5976", [:mix], []},
6 | "cowboy": {:hex, :cowboy, "1.1.2", "61ac29ea970389a88eca5a65601460162d370a70018afe6f949a29dca91f3bb0", [:rebar3], [{:cowlib, "~> 1.0.2", [hex: :cowlib, optional: false]}, {:ranch, "~> 1.3.2", [hex: :ranch, optional: false]}]},
7 | "cowlib": {:hex, :cowlib, "1.0.2", "9d769a1d062c9c3ac753096f868ca121e2730b9a377de23dec0f7e08b1df84ee", [:make], []},
8 | "db_connection": {:hex, :db_connection, "1.1.2", "2865c2a4bae0714e2213a0ce60a1b12d76a6efba0c51fbda59c9ab8d1accc7a8", [:mix], [{:connection, "~> 1.0.2", [hex: :connection, optional: false]}, {:poolboy, "~> 1.5", [hex: :poolboy, optional: true]}, {:sbroker, "~> 1.0", [hex: :sbroker, optional: true]}]},
9 | "decimal": {:hex, :decimal, "1.3.1", "157b3cedb2bfcb5359372a7766dd7a41091ad34578296e951f58a946fcab49c6", [:mix], []},
10 | "ecto": {:hex, :ecto, "2.1.4", "d1ba932813ec0e0d9db481ef2c17777f1cefb11fc90fa7c142ff354972dfba7e", [:mix], [{:db_connection, "~> 1.1", [hex: :db_connection, optional: true]}, {:decimal, "~> 1.2", [hex: :decimal, optional: false]}, {:mariaex, "~> 0.8.0", [hex: :mariaex, optional: true]}, {:poison, "~> 2.2 or ~> 3.0", [hex: :poison, optional: true]}, {:poolboy, "~> 1.5", [hex: :poolboy, optional: false]}, {:postgrex, "~> 0.13.0", [hex: :postgrex, optional: true]}, {:sbroker, "~> 1.0", [hex: :sbroker, optional: true]}]},
11 | "excoveralls": {:hex, :excoveralls, "0.6.3", "894bf9254890a4aac1d1165da08145a72700ff42d8cb6ce8195a584cb2a4b374", [:mix], [{:exjsx, "~> 3.0", [hex: :exjsx, optional: false]}, {:hackney, ">= 0.12.0", [hex: :hackney, optional: false]}]},
12 | "exjsx": {:hex, :exjsx, "3.2.1", "1bc5bf1e4fd249104178f0885030bcd75a4526f4d2a1e976f4b428d347614f0f", [:mix], [{:jsx, "~> 2.8.0", [hex: :jsx, optional: false]}]},
13 | "fs": {:hex, :fs, "0.9.2", "ed17036c26c3f70ac49781ed9220a50c36775c6ca2cf8182d123b6566e49ec59", [:rebar], []},
14 | "gen_smtp": {:hex, :gen_smtp, "0.11.0", "d90ff2f021fc86cb2a4259b1f2b177ab6e506676265e26454bf5755855adc956", [:rebar3], []},
15 | "gettext": {:hex, :gettext, "0.13.1", "5e0daf4e7636d771c4c71ad5f3f53ba09a9ae5c250e1ab9c42ba9edccc476263", [:mix], []},
16 | "hackney": {:hex, :hackney, "1.7.1", "e238c52c5df3c3b16ce613d3a51c7220a784d734879b1e231c9babd433ac1cb4", [:rebar3], [{:certifi, "1.0.0", [hex: :certifi, optional: false]}, {:idna, "4.0.0", [hex: :idna, optional: false]}, {:metrics, "1.0.1", [hex: :metrics, optional: false]}, {:mimerl, "1.0.2", [hex: :mimerl, optional: false]}, {:ssl_verify_fun, "1.1.1", [hex: :ssl_verify_fun, optional: false]}]},
17 | "httpoison": {:hex, :httpoison, "0.11.1", "d06c571274c0e77b6cc50e548db3fd7779f611fbed6681fd60a331f66c143a0b", [:mix], [{:hackney, "~> 1.7.0", [hex: :hackney, optional: false]}]},
18 | "idna": {:hex, :idna, "4.0.0", "10aaa9f79d0b12cf0def53038547855b91144f1bfcc0ec73494f38bb7b9c4961", [:rebar3], []},
19 | "jsx": {:hex, :jsx, "2.8.2", "7acc7d785b5abe8a6e9adbde926a24e481f29956dd8b4df49e3e4e7bcc92a018", [:mix, :rebar3], []},
20 | "meck": {:hex, :meck, "0.8.4", "59ca1cd971372aa223138efcf9b29475bde299e1953046a0c727184790ab1520", [:make, :rebar], []},
21 | "metrics": {:hex, :metrics, "1.0.1", "25f094dea2cda98213cecc3aeff09e940299d950904393b2a29d191c346a8486", [:rebar3], []},
22 | "mime": {:hex, :mime, "1.1.0", "01c1d6f4083d8aa5c7b8c246ade95139620ef8effb009edde934e0ec3b28090a", [:mix], []},
23 | "mimerl": {:hex, :mimerl, "1.0.2", "993f9b0e084083405ed8252b99460c4f0563e41729ab42d9074fd5e52439be88", [:rebar3], []},
24 | "mock": {:hex, :mock, "0.2.1", "bfdba786903e77f9c18772dee472d020ceb8ef000783e737725a4c8f54ad28ec", [:mix], [{:meck, "~> 0.8.2", [hex: :meck, optional: false]}]},
25 | "phoenix": {:hex, :phoenix, "1.2.3", "b68dd6a7e6ff3eef38ad59771007d2f3f344988ea6e658e9b2c6ffb2ef494810", [:mix], [{:cowboy, "~> 1.0", [hex: :cowboy, optional: true]}, {:phoenix_pubsub, "~> 1.0", [hex: :phoenix_pubsub, optional: false]}, {:plug, "~> 1.4 or ~> 1.3.3 or ~> 1.2.4 or ~> 1.1.8 or ~> 1.0.5", [hex: :plug, optional: false]}, {:poison, "~> 1.5 or ~> 2.0", [hex: :poison, optional: false]}]},
26 | "phoenix_ecto": {:hex, :phoenix_ecto, "3.2.3", "450c749876ff1de4a78fdb305a142a76817c77a1cd79aeca29e5fc9a6c630b26", [:mix], [{:ecto, "~> 2.1", [hex: :ecto, optional: false]}, {:phoenix_html, "~> 2.9", [hex: :phoenix_html, optional: true]}, {:plug, "~> 1.0", [hex: :plug, optional: false]}]},
27 | "phoenix_html": {:hex, :phoenix_html, "2.9.3", "1b5a2122cbf743aa242f54dced8a4f1cc778b8bd304f4b4c0043a6250c58e258", [:mix], [{:plug, "~> 1.0", [hex: :plug, optional: false]}]},
28 | "phoenix_live_reload": {:hex, :phoenix_live_reload, "1.0.8", "4333f9c74190f485a74866beff2f9304f069d53f047f5fbb0fb8d1ee4c495f73", [:mix], [{:fs, "~> 0.9.1", [hex: :fs, optional: false]}, {:phoenix, "~> 1.0 or ~> 1.2-rc", [hex: :phoenix, optional: false]}]},
29 | "phoenix_pubsub": {:hex, :phoenix_pubsub, "1.0.1", "c10ddf6237007c804bf2b8f3c4d5b99009b42eca3a0dfac04ea2d8001186056a", [:mix], []},
30 | "plug": {:hex, :plug, "1.3.4", "b4ef3a383f991bfa594552ded44934f2a9853407899d47ecc0481777fb1906f6", [:mix], [{:cowboy, "~> 1.0.1 or ~> 1.1", [hex: :cowboy, optional: true]}, {:mime, "~> 1.0", [hex: :mime, optional: false]}]},
31 | "poison": {:hex, :poison, "2.2.0", "4763b69a8a77bd77d26f477d196428b741261a761257ff1cf92753a0d4d24a63", [:mix], []},
32 | "poolboy": {:hex, :poolboy, "1.5.1", "6b46163901cfd0a1b43d692657ed9d7e599853b3b21b95ae5ae0a777cf9b6ca8", [:rebar], []},
33 | "postgrex": {:hex, :postgrex, "0.13.2", "2b88168fc6a5456a27bfb54ccf0ba4025d274841a7a3af5e5deb1b755d95154e", [:mix], [{:connection, "~> 1.0", [hex: :connection, optional: false]}, {:db_connection, "~> 1.1", [hex: :db_connection, optional: false]}, {:decimal, "~> 1.0", [hex: :decimal, optional: false]}]},
34 | "ranch": {:hex, :ranch, "1.3.2", "e4965a144dc9fbe70e5c077c65e73c57165416a901bd02ea899cfd95aa890986", [:rebar3], []},
35 | "ssl_verify_fun": {:hex, :ssl_verify_fun, "1.1.1", "28a4d65b7f59893bc2c7de786dec1e1555bd742d336043fe644ae956c3497fbe", [:make, :rebar], []}}
36 |
--------------------------------------------------------------------------------
/web/templates/feedback/show.html.eex:
--------------------------------------------------------------------------------
1 |
2 | <%= if @conn.assigns.current_user do %>
3 |
4 |
5 |
6 | <% end %>
7 | <%= if !@conn.assigns.current_user do %>
8 | <%= if @feedback.response == nil do %>
9 |
10 | To track the response to your feedback, make a note of the url of this page
11 | and come back to it later.
12 |
13 | <% end %>
14 | <%= if @feedback.submitter_email == nil && @feedback.response == nil do %>
15 |
20 |
21 |
22 | If you want to be notified of a response by email, enter it below:
23 |
24 |
25 | (To remain anonymous we recommend setting up and using an obscure email address, for
26 | example: "ilovepuppies@gmail.com")
27 |
28 | <%= form_for @changeset, feedback_path(@conn, :update, @feedback.id), fn f -> %>
29 | <%= text_input f, :submitter_email, placeholder: "Enter your email address", class: "input" %>
30 | <%= error_tag f, :submitter_email %>
31 | <%= submit "Submit", class: "button" %>
32 | <% end %>
33 |
34 | <% end %>
35 | <% end %>
36 | <%= if @feedback.submitter_email != nil && @feedback.response == nil && !@conn.assigns.current_user do %>
37 |
38 | Feedback response notifications will be sent to
<%= @feedback.submitter_email %>
39 |
40 |
41 | <% end %>
42 | <%= if !@feedback.edit && @conn.assigns.current_user == nil do %>
43 |
44 |
45 | <%= if !@feedback.public do %>
46 |
47 | <% end %>
48 | <%= if !@conn.assigns.current_user do %>
49 |
50 | <%= form_for @changeset, feedback_path(@conn, :update, @feedback.id), fn f -> %>
51 | <%= hidden_input f, :edit, value: "true" %>
52 | <%= submit "Edit", class: "edit-button" %>
53 | <% end %>
54 |
55 | <% end %>
56 |
Feedback -
57 | <%= format_date(@feedback.inserted_at) %>
58 |
59 |
<%= @feedback.item %>
60 |
61 | <% end %>
62 | <%= if !@feedback.edit && @conn.assigns.current_user != nil do %>
63 |
64 |
65 | <%= if !@feedback.public do %>
66 |
67 | <% end %>
68 |
Feedback -
69 | <%= format_date(@feedback.inserted_at) %>
70 |
71 |
<%= @feedback.item %>
72 |
73 | <% end %>
74 | <%= if @feedback.edit && @conn.assigns.current_user do %>
75 |
76 |
77 | <%= if !@feedback.public do %>
78 |
79 | <% end %>
80 |
Feedback -
81 | <%= format_date(@feedback.inserted_at) %>
82 |
83 |
<%= @feedback.item %>
84 |
85 | <% end %>
86 | <%= if @feedback.edit && @conn.assigns.current_user == nil do %>
87 |
88 |
89 | <%= if !@feedback.public do %>
90 |
91 | <% end %>
92 |
Feedback -
93 | <%= format_date(@feedback.inserted_at) %>
94 |
95 | <%= form_for @changeset, feedback_path(@conn, :update, @feedback.id), fn f -> %>
96 | <%= textarea f, :item, placeholder: @feedback.item, class: "text_area text_area-edit" %>
97 | <%= error_tag f, :item %>
98 | <%= hidden_input f, :edit, value: "false" %>
99 | <%= hidden_input f, :edited, value: "true" %>
100 | <%= submit "Update", class: "button response-button" %>
101 | <% end %>
102 |
103 | <% end %>
104 | <%= if !@conn.assigns.current_user do %>
105 | <%= if !@feedback.response && @feedback.submitter_email == nil do %>
106 |
107 | There hasn't been a response to your feedback yet. Please check again later
108 | or we can notify you if you enter your email in the form above.
109 |
110 | <% end %>
111 | <%= if !@feedback.response && @feedback.submitter_email != nil do %>
112 |
113 | There hasn't been a response to your feedback yet. We will notify you by
114 | email once a response has been made.
115 |
116 | <% end %>
117 | <% end %>
118 | <%= if @feedback.response && @conn.assigns.current_user && !@feedback.response.edit do %>
119 |
120 |
121 | <%= form_for @response_changeset, response_path(@conn, :update, @feedback.response), fn f -> %>
122 | <%= hidden_input f, :edit, value: "true" %>
123 | <%= submit "Edit", class: "edit-button" %>
124 | <% end %>
125 |
126 |
Response -
127 | <%= format_date(@feedback.response.inserted_at) %>
128 | <%= if @feedback.response.edited do %>
129 | (edited)
130 | <% end %>
131 |
132 |
<%= @feedback.response.response %>
133 |
134 | <% end %>
135 | <%= if @feedback.response && !@conn.assigns.current_user do %>
136 |
137 |
Response -
138 | <%= format_date(@feedback.response.inserted_at) %>
139 | <%= if @feedback.response.edited do %>
140 | (edited)
141 | <% end %>
142 |
143 |
<%= @feedback.response.response %>
144 |
145 | <% end %>
146 | <%= if !@feedback.response && @conn.assigns.current_user do %>
147 | <%= form_for @response_changeset, response_path(@conn, :create), fn f -> %>
148 | <%= textarea f, :response, placeholder: "Enter your feedback response", class: "text_area" %>
149 | <%= error_tag f, :response %>
150 | <%= hidden_input f, :feedback_id, value: @feedback.id %>
151 | <%= submit "Respond", [class: "button response-button", id: "response-button"] %>
152 | <% end %>
153 | <% end %>
154 | <%= if @feedback.response && @feedback.response.edit && @conn.assigns.current_user do %>
155 |
156 |
Response -
157 | <%= format_date(@feedback.response.inserted_at) %>
158 | <%= if @feedback.response.edited do %>
159 | (edited)
160 | <% end %>
161 |
162 | <%= form_for @response_changeset, response_path(@conn, :update, @feedback.response), fn f -> %>
163 | <%= textarea f, :response, value: @feedback.response.response, class: "text_area text_area-edit" %>
164 | <%= error_tag f, :response %>
165 | <%= hidden_input f, :edit, value: "false" %>
166 | <%= hidden_input f, :edited, value: "true" %>
167 | <%= submit "Update", class: "button response-button" %>
168 | <% end %>
169 |
170 | <% end %>
171 |
172 |
--------------------------------------------------------------------------------
/web/static/css/app.css:
--------------------------------------------------------------------------------
1 | .alert-info {
2 | text-align: center;
3 | }
4 |
5 | .alert-danger {
6 | text-align: center;
7 | }
8 |
9 | .back-arrow {
10 | width: 3rem;
11 | margin-bottom: 1rem;
12 | }
13 |
14 | .back-button {
15 | display: inline-block;
16 | border: 1px solid #4ABDAC;
17 | border-radius: 5px;
18 | padding: 0.5rem;
19 | margin-bottom: 1rem;
20 | background-color: #4ABDAC;
21 | color: #ffffff;
22 | margin-top: 1rem;
23 | margin-right: 1rem;
24 | float: right;
25 | }
26 |
27 | .back-button:hover {
28 | color: #ffffff;
29 | }
30 |
31 | body {
32 | margin-top: 0;
33 | }
34 |
35 | .button {
36 | width: 100%;
37 | background-color: #4ABDAC;
38 | border-style: none;
39 | color: #ffffff;
40 | padding: 1rem;
41 | border-radius: 5px;
42 | font-size: 2rem;
43 | }
44 |
45 | .dashboard-container {
46 | position: relative;
47 | text-align: center;
48 | }
49 |
50 | .dashboard-emotion {
51 | width: 9rem;
52 | }
53 |
54 | .dashboard-emotion-container {
55 | display: inline-block;
56 | padding: 2rem;
57 | margin: 0.5rem;
58 | border-radius: 5px;
59 | position: relative;
60 | box-shadow: 1px 1px 5px #888888;
61 | height: 13.2rem;
62 | width: 13.2rem;
63 | }
64 |
65 | .dashboard-info {
66 | width: 9rem;
67 | }
68 |
69 | .dashboard-info-container {
70 | text-align: center;
71 | display: inline-block;
72 | padding-top: 1.5rem;
73 | margin: 0.4rem;
74 | border-radius: 5px;
75 | position: relative;
76 | color: #ffffff;
77 | background-color: #4ABDAC;
78 | height: 13.2rem;
79 | width: 13.2rem;
80 | }
81 |
82 | .dwyl-logo {
83 | width: 6rem;
84 | }
85 |
86 | .dwyl-logo-white {
87 | width: 4rem;
88 | margin-left: 1rem;
89 | margin-top: 0.5rem;
90 | }
91 |
92 | .dwyl-logo-container {
93 | text-align: center;
94 | margin-bottom: 1rem;
95 | }
96 |
97 | .edit-button {
98 | display: inline-block;
99 | color: grey;
100 | border: none;
101 | background-color: #ffffff;
102 | }
103 |
104 | .edit-button-container {
105 | position: absolute;
106 | }
107 |
108 | .edit-container form {
109 | position: absolute;
110 | right: 0.7rem;
111 | top: -1.1rem;
112 | }
113 |
114 | .emotion-container {
115 | text-align: center;
116 | min-height: 7rem;
117 | }
118 |
119 | .emotion-feedback-image {
120 | width: 12rem;
121 | }
122 |
123 | .emotion-feedback-image-container {
124 | text-align: center;
125 | margin-bottom: 2rem;
126 | }
127 |
128 | .feedback-anonymous-hint {
129 | text-align: center;
130 | font-style: italic;
131 | }
132 |
133 | .feedback-code-container {
134 | text-align: center;
135 | margin-bottom: 2rem;
136 | }
137 |
138 | .feedback-dashboard-title {
139 | color: #4ABDAC;
140 | text-transform: capitalize;
141 | }
142 |
143 | .feedback-date {
144 | font-size: 1.5rem;
145 | color: grey;
146 | }
147 |
148 | .feedback-edit-title {
149 | border-bottom: none;
150 | }
151 |
152 | .feedback-email-submitted {
153 | text-align: center;
154 | padding: 1rem;
155 | border: 1px dashed lightblue;
156 | border-radius: 5px;
157 | margin-top: 1rem;
158 | margin-bottom: 1rem;
159 | }
160 |
161 | .feedback-email-submitted-tick {
162 | width: 2rem;
163 | }
164 |
165 | .feedback-email-text {
166 | padding: 0.5rem;
167 | text-align: center;
168 | }
169 |
170 | .feedback-item {
171 | padding: 1.5rem;
172 | padding-left: 2rem;
173 | border-radius: 5px;
174 | background-color: #4ABDAC;
175 | color: #ffffff;
176 | margin-bottom: 1rem;
177 | position: relative;
178 | box-shadow: 2px 2px 5px #888888;
179 | }
180 |
181 | .feedback-item-container {
182 | border: 1px solid lightgray;
183 | padding: 1rem;
184 | border-radius: 5px;
185 | margin-bottom: 1rem;
186 | position: relative;
187 | box-shadow: 2px 2px 5px #888888;
188 | }
189 |
190 | .feedback-item-date {
191 | display: inline-block;
192 | color: #4ABDAC;
193 | background-color: #ffffff;
194 | border-radius: 500px;
195 | padding-left: 1rem;
196 | padding-right: 1rem;
197 | margin-top: 1rem;
198 | }
199 |
200 | .feedback-item-title {
201 | border-bottom: 1px solid lightgrey;
202 | margin-bottom: 0.5rem;
203 | font-size: 2rem;
204 | color: #4ABDAC;
205 | }
206 |
207 | .feedback-notification {
208 | display: inline-block;
209 | position: absolute;
210 | right: -1rem;
211 | top: -1rem;
212 | color: #ffffff;
213 | background-color: #e74c3c;
214 | border-radius: 100%;
215 | padding: 0.5rem;
216 | padding-left: 1rem;
217 | padding-right: 1rem;
218 | min-width: 3.5rem;
219 | min-height: 3.5rem;
220 | text-align: center;
221 | padding-top: 0.75rem;
222 | }
223 |
224 | .feedback-question {
225 | text-align: center;
226 | font-size: 2rem;
227 | }
228 |
229 | .feedback-responded {
230 | width: 4rem;
231 | float: right;
232 | position: absolute;
233 | right: 1rem;
234 | bottom: 2rem;
235 | }
236 |
237 | .feedback-responded-forum {
238 | width: 4rem;
239 | float: right;
240 | position: absolute;
241 | right: 0.1rem;
242 | bottom: 0rem;
243 | }
244 |
245 | .feedback-response-container {
246 | border: 1px solid lightgray;
247 | padding: 1rem;
248 | border-radius: 5px;
249 | position: relative;
250 | box-shadow: 2px 2px 5px #888888;
251 | }
252 |
253 | .feedback-response-title {
254 | border-bottom: 1px solid lightgrey;
255 | margin-bottom: 0.5rem;
256 | font-size: 2rem;
257 | color: #4ABDAC;
258 | }
259 |
260 | .feedback-response-message {
261 | background-color: #4ABDAC;
262 | padding: 1.5rem;
263 | color: #ffffff;
264 | font-size: 2rem;
265 | text-align: center;
266 | border-radius: 5px;
267 | margin-top: 1rem;
268 | }
269 |
270 | .help-block {
271 | text-transform: capitalize;
272 | color: #e74c3c;
273 | }
274 |
275 | .highlight {
276 | color: #4ABDAC;
277 | font-size: 1.6rem;
278 | }
279 |
280 | .horizontal-spacer {
281 | display: inline-block;
282 | width: 2rem;
283 | height: 1px;
284 | background-color: grey;
285 | margin-bottom: 0.5rem;
286 | }
287 |
288 | .horizontal-spacer-responded {
289 | display: inline-block;
290 | width: 7rem;
291 | height: 1px;
292 | background-color: grey;
293 | margin-bottom: 0.5rem;
294 | }
295 |
296 | .input {
297 | width: 100%;
298 | border: 1px solid lightgrey;
299 | border-radius: 5px;
300 | padding: 1rem;
301 | margin-bottom: 1rem;
302 | }
303 |
304 | .input:focus {
305 | outline: none;
306 | }
307 |
308 | .link:hover {
309 | text-decoration: none;
310 | }
311 |
312 | .mood-image {
313 | width: 3rem;
314 | position: absolute;
315 | right: 4.2rem;
316 | top: 0.5rem;
317 | }
318 |
319 | .mood-image-edit {
320 | width: 3rem;
321 | position: absolute;
322 | right: 1.5rem;
323 | top: 0.5rem;
324 | }
325 |
326 | .mood-image-forum {
327 | width: 3rem;
328 | position: absolute;
329 | right: 0.5rem;
330 | top: 0.5rem;
331 | }
332 |
333 | .navigation {
334 | min-height: 5rem;
335 | border-bottom: 1px solid #4ABDAC;
336 | margin-bottom: 2rem;
337 | background-color: #4ABDAC;
338 | box-shadow: 2px 2px 5px #888888;
339 | }
340 |
341 | .or {
342 | display: inline-block;
343 | margin-left: 0.3rem;
344 | margin-right: 0.3rem;
345 | color: grey;
346 | }
347 |
348 | .or-container {
349 | text-align: center;
350 | padding: 1rem;
351 | }
352 |
353 | .padlock {
354 | width: 2.5rem;
355 | position: absolute;
356 | right: 7.5rem;
357 | top: 0.7rem;
358 | }
359 |
360 | .padlock-edit {
361 | width: 2.5rem;
362 | position: absolute;
363 | right: 5rem;
364 | top: 0.7rem;
365 | }
366 |
367 | .privacy-toggle-container {
368 | position: relative;
369 | text-align: center;
370 | width: 20rem;
371 | margin: 0 auto;
372 | margin-bottom: 1rem;
373 | }
374 |
375 | .private {
376 | font-size: 2rem;
377 | position: absolute;
378 | top: 0.4rem;
379 | color: #4ABDAC;
380 | left: -0.5rem;
381 | }
382 |
383 | .public {
384 | font-size: 2rem;
385 | position: absolute;
386 | right: 0;
387 | top: 0.4rem;
388 | color: #B5B5B5;
389 | }
390 |
391 | .responded {
392 | display: inline-block;
393 | margin-left: 0.3rem;
394 | margin-right: 0.3rem;
395 | color: grey;
396 | }
397 |
398 | .responded-container {
399 | text-align: center;
400 | padding: 1rem;
401 | }
402 |
403 | .response-button {
404 | margin-top: 1rem;
405 | }
406 |
407 | .response-edited {
408 | color: #ADADAD;
409 | font-size: 1.3rem;
410 | }
411 |
412 | .subtitle {
413 | text-align: center;
414 | padding: 0.5rem;
415 | font-size: 1.5rem;
416 | margin-bottom: 3rem;
417 | }
418 |
419 | .text_area {
420 | resize: none;
421 | min-height: 15rem;
422 | outline: none;
423 | width: 100%;
424 | border: 1px solid lightgrey;
425 | border-radius: 5px;
426 | padding: 1rem;
427 | background-color: #fafbfc;
428 | }
429 |
430 | .text_area-edit {
431 | background-color: #fafbfc;
432 | }
433 |
434 | .title {
435 | text-align: center;
436 | font-size: 2.5rem;
437 | margin-bottom: 2rem;
438 | }
439 |
440 | .top-buffer {
441 | margin-top: 2rem;
442 | }
443 |
444 | .underline {
445 | text-decoration: underline;
446 | }
447 |
448 | /* emotion css */
449 |
450 | .delighted, .happy, .neutral, .confused, .sad, .angry {
451 | /* HIDE RADIO */
452 | visibility: hidden;
453 | /* Makes input not-clickable */
454 | position: absolute;
455 | /* Remove input from document flow */
456 | }
457 |
458 | .delighted + label {
459 | background-image: url("/images/delighted.svg");
460 | }
461 |
462 | .happy + label {
463 | background-image: url("/images/happy.svg");
464 | }
465 |
466 | .neutral + label {
467 | background-image: url("/images/neutral.svg");
468 | }
469 |
470 | .confused + label {
471 | background-image: url("/images/confused.svg");
472 | }
473 |
474 | .sad + label {
475 | background-image: url("/images/sad.svg");
476 | }
477 |
478 | .angry + label {
479 | background-image: url("/images/angry.svg");
480 | }
481 |
482 | .delighted + label::after, .happy + label::after, .neutral + label::after, .confused + label::after, .sad + label::after, .angry + label::after {
483 | text-align: center;
484 | width: 100%;
485 | position: relative;
486 | display: block;
487 | color: #EA5C37;
488 | }
489 |
490 | input + label {
491 | /* IMAGE STYLES */
492 | cursor: pointer;
493 | background-size: contain;
494 | background-repeat: no-repeat;
495 | display: inline-block;
496 | -webkit-o-transition: all 200ms ease-in;
497 | -moz-o-transition: all 200ms ease-in;
498 | transition: all 200ms ease-in;
499 | height: 2.5em;
500 | width: 2.5em;
501 | position: relative;
502 | top: 25%;
503 | transform: translateY(-25%);
504 | }
505 |
506 | label + input:hover {
507 | /* HIDE RADIO */
508 | -webkit-filter: brightness(1.2) grayscale(.5) opacity(.9);
509 | -moz-filter: brightness(1.2) grayscale(.5) opacity(.9);
510 | filter: brightness(1.2) grayscale(.5) opacity(.9);
511 | }
512 | input:checked + label {
513 | /* (RADIO CHECKED) IMAGE STYLES */
514 | -webkit-filter: none;
515 | -moz-filter: none;
516 | filter: none;
517 | background-color: rgb(234, 130, 75);
518 | background-color: rgba(234, 92, 55, 0.4);
519 | border-radius: 100%;
520 | width: 6rem;
521 | height: 6rem;
522 | margin-left: 0.5rem;
523 | margin-right: 0.5rem;
524 | }
525 |
526 | @media (min-width: 600px) {
527 | input + label {
528 | width: 6rem;
529 | height: 6rem;
530 | }
531 | input:checked + label {
532 | width: 9rem;
533 | height: 9rem;
534 | margin-left: 0.5rem;
535 | margin-right: 0.5rem;
536 | }
537 | .emotion-container {
538 | min-height: 10rem;
539 | }
540 | .dashboard-emotion {
541 | width: 14rem;
542 | }
543 | .dashboard-info-container {
544 | height: 18.1rem;
545 | width: 18.1rem;
546 | padding-top: 3.3rem;
547 | margin: 0.4rem;
548 | font-size: 1.6rem;
549 | }
550 | .dashboard-emotion-container {
551 | height: 18rem;
552 | width: 18rem;
553 | }
554 | }
555 |
556 | /* toggle css */
557 | .switch {
558 | position: relative;
559 | display: inline-block;
560 | width: 60px;
561 | height: 34px;
562 | }
563 |
564 | .switch input {display:none;}
565 |
566 | .slider {
567 | position: absolute;
568 | cursor: pointer;
569 | top: 0;
570 | left: 0;
571 | right: 0;
572 | bottom: 0;
573 | background-color: #4ABDAC;
574 | -webkit-transition: .4s;
575 | transition: .4s;
576 | }
577 |
578 | .slider:before {
579 | position: absolute;
580 | content: "";
581 | height: 26px;
582 | width: 26px;
583 | left: 4px;
584 | bottom: 4px;
585 | background-color: white;
586 | -webkit-transition: .4s;
587 | transition: .4s;
588 | }
589 |
590 | input:checked + .slider {
591 | background-color: #ccc;
592 | }
593 |
594 | input:focus + .slider {
595 | box-shadow: 0 0 1px #ccc;
596 | }
597 |
598 | input:checked + .slider:before {
599 | -webkit-transform: translateX(26px);
600 | -ms-transform: translateX(26px);
601 | transform: translateX(26px);
602 | }
603 |
604 | .slider.round {
605 | border-radius: 34px;
606 | }
607 |
608 | .slider.round:before {
609 | border-radius: 50%;
610 | }
611 |
--------------------------------------------------------------------------------