├── 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 |
3 | <%= for {emotion, feedback} <- @emotions do %> 4 | 5 |
6 | <%= if length(feedback) > 0 do %> 7 | 8 | <% end %> 9 | 10 |
11 |
12 | <% end %> 13 |
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 | 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 | 17 | 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 |
7 |
8 |

Resources

9 | 20 |
21 | 22 |
23 |

Help

24 | 35 |
36 |
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 | 26 |
27 | 28 | 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 | 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 | 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 | 31 |
32 | 33 | 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 | 31 |
32 | 33 | 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 | 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 | 12 |
13 |
<%= @feedback.item %>
14 |
15 | 16 | <%= if @feedback.response && !@conn.assigns.current_user do %> 17 |
18 |
Response - 19 | 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 | 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 | 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 | ![feedback-app-screenshot](https://user-images.githubusercontent.com/194400/44026238-4955d5ea-9eea-11e8-86b1-7e26ae69ea72.png) 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 | ![sayat.me feedback](https://cloud.githubusercontent.com/assets/12450298/24554452/38df2670-15f3-11e7-9cdd-8757072183e0.png) 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 | ![suggestion ox feedback](https://cloud.githubusercontent.com/assets/12450298/24554725/3864b056-15f4-11e7-9e09-748b4da8e5bc.png) 26 | ![suggestion ox form](https://cloud.githubusercontent.com/assets/12450298/24554757/5272e8fa-15f4-11e7-8c6e-33031ffc30c5.png) 27 | Once your feedback has been submitted it appears in an inbox that organises and 28 | categorises them. 29 | ![suggestion ox answer](https://cloud.githubusercontent.com/assets/12450298/24555098/81b8b8a0-15f5-11e7-9247-7463a41db3b9.png) 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 | ![3sixty](https://cloud.githubusercontent.com/assets/12450298/24555419/b3c02ff8-15f6-11e7-9bf4-080b13546c41.png) 35 | + ***15FIVE***: https://www.15five.com is similar to Suggestion Ox. 36 | ![15 five](https://cloud.githubusercontent.com/assets/12450298/24556562/6d0058d2-15fa-11e7-9102-7337c47af05f.png) 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 | ![15 five feedback](https://cloud.githubusercontent.com/assets/12450298/24556654/b82b2490-15fa-11e7-819f-f682e18894ce.png) 41 | Once the feedback has been submitted it gives you the opportunity to review and 42 | respond to it: 43 | ![15 five response](https://cloud.githubusercontent.com/assets/12450298/24557030/eb3f7ff6-15fb-11e7-815b-3f110b547f35.png) 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 |
16 |
17 |
OR
18 |
19 |
20 |
21 | 24 | 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 | 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 | 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 | 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 | 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 | 132 |
<%= @feedback.response.response %>
133 |
134 | <% end %> 135 | <%= if @feedback.response && !@conn.assigns.current_user do %> 136 |
137 | 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 | 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 | --------------------------------------------------------------------------------