├── .buildpacks ├── .gitignore ├── .travis.yml ├── LICENSE ├── README.md ├── compile ├── config ├── config.exs ├── dev.exs ├── prod.exs ├── test.exs └── travis.exs ├── elixir_buildpack.config ├── lib ├── phoenix_trello.ex └── phoenix_trello │ ├── board_channel │ ├── monitor.ex │ └── supervisor.ex │ ├── endpoint.ex │ ├── guardian_serializer.ex │ ├── permalink.ex │ └── repo.ex ├── mix.exs ├── mix.lock ├── package.json ├── phoenix_static_buildpack.config ├── priv ├── repo │ ├── migrations │ │ ├── 20151224075404_create_user.exs │ │ ├── 20151224093233_create_board.exs │ │ ├── 20151225091657_create_list.exs │ │ ├── 20151227084131_create_card.exs │ │ ├── 20151230081546_create_user_board.exs │ │ ├── 20160111104041_add_description_to_cards.exs │ │ ├── 20160111105636_create_comment.exs │ │ ├── 20160113072454_user_password_fix.exs │ │ ├── 20160115070933_add_slug_to_board.exs │ │ ├── 20160120080228_create_card_member.exs │ │ └── 20160127155137_add_tags_to_cards.exs │ └── seeds.exs └── static │ ├── images │ ├── favicon.png │ └── logo.png │ └── robots.txt ├── test ├── integration │ ├── add_cards_test.exs │ ├── add_lists_test.exs │ ├── new_board_test.exs │ ├── show_board_test.exs │ ├── sign_in_test.exs │ └── sign_up_test.exs ├── lib │ └── phoenix_trello │ │ └── board_channel │ │ └── monitor_test.exs ├── models │ ├── board_test.exs │ ├── card_member_test.exs │ ├── card_test.exs │ ├── comment_test.exs │ ├── list_test.exs │ ├── user_board_test.exs │ └── user_test.exs ├── support │ ├── channel_case.ex │ ├── conn_case.ex │ ├── factory.ex │ ├── integration_case.ex │ └── model_case.ex ├── test_helper.exs └── views │ ├── error_view_test.exs │ ├── layout_view_test.exs │ └── page_view_test.exs ├── web ├── channels │ ├── board_channel.ex │ ├── user_channel.ex │ └── user_socket.ex ├── controllers │ ├── api │ │ └── v1 │ │ │ ├── board_controller.ex │ │ │ ├── card_controller.ex │ │ │ ├── current_user_controller.ex │ │ │ ├── registration_controller.ex │ │ │ └── session_controller.ex │ └── page_controller.ex ├── helpers │ └── session.ex ├── models │ ├── board.ex │ ├── card.ex │ ├── card_member.ex │ ├── comment.ex │ ├── list.ex │ ├── user.ex │ └── user_board.ex ├── router.ex ├── static │ ├── css │ │ ├── application.sass │ │ ├── global │ │ │ ├── _base.sass │ │ │ ├── _grid_settings.sass │ │ │ ├── _layout.sass │ │ │ ├── _settings.sass │ │ │ ├── _skin.sass │ │ │ ├── _typography.sass │ │ │ └── _utilities.sass │ │ ├── libs │ │ │ ├── _library-variable-overrides.sass │ │ │ └── _normalize.sass │ │ └── modules │ │ │ ├── _boards.sass │ │ │ ├── _example-module.sass │ │ │ ├── _forms.sass │ │ │ ├── _main_header.sass │ │ │ ├── _members_selector.sass │ │ │ ├── _modals.sass │ │ │ ├── _modules.sass │ │ │ └── _sessions.sass │ └── js │ │ ├── actions │ │ ├── boards.js │ │ ├── current_board.js │ │ ├── current_card.js │ │ ├── header.js │ │ ├── lists.js │ │ ├── registrations.js │ │ └── sessions.js │ │ ├── application.js │ │ ├── components │ │ ├── boards │ │ │ ├── card.js │ │ │ ├── form.js │ │ │ └── members.js │ │ ├── cards │ │ │ ├── card.js │ │ │ ├── form.js │ │ │ ├── members_selector.js │ │ │ ├── modal.js │ │ │ └── tags_selector.js │ │ └── lists │ │ │ ├── card.js │ │ │ └── form.js │ │ ├── constants │ │ ├── index.js │ │ └── item_types.js │ │ ├── containers │ │ ├── authenticated.js │ │ └── root.js │ │ ├── layouts │ │ ├── header.js │ │ └── main.js │ │ ├── reducers │ │ ├── boards.js │ │ ├── current_board.js │ │ ├── current_card.js │ │ ├── header.js │ │ ├── index.js │ │ ├── registration.js │ │ └── session.js │ │ ├── routes │ │ └── index.js │ │ ├── store │ │ └── index.js │ │ ├── utils │ │ └── index.js │ │ └── views │ │ ├── boards │ │ └── show.js │ │ ├── cards │ │ └── show.js │ │ ├── home │ │ └── index.js │ │ ├── registrations │ │ └── new.js │ │ └── sessions │ │ └── new.js ├── templates │ ├── layout │ │ └── app.html.eex │ └── page │ │ └── index.html.eex ├── views │ ├── board_view.ex │ ├── card_view.ex │ ├── current_user_view.ex │ ├── error_helpers.ex │ ├── error_view.ex │ ├── layout_view.ex │ ├── page_view.ex │ ├── registration_view.ex │ └── session_view.ex └── web.ex └── webpack.config.js /.buildpacks: -------------------------------------------------------------------------------- 1 | https://github.com/HashNuke/heroku-buildpack-elixir 2 | https://github.com/gjaldon/phoenix-static-buildpack 3 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # App artifacts 2 | /_build 3 | /db 4 | /deps 5 | /*.ez 6 | 7 | # Generate on crash by the VM 8 | erl_crash.dump 9 | 10 | # The config/prod.secret.exs file by default contains sensitive 11 | # data and you should not commit it into version control. 12 | # 13 | # Alternatively, you may comment the line below and commit the 14 | # secrets file as long as you replace its contents by environment 15 | # variables. 16 | /node_modules 17 | /config/dev.secret.exs 18 | /config/prod.secret.exs 19 | /priv/static/css/application.css 20 | /priv/static/js/application.js 21 | 22 | *.log 23 | priv/static 24 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: elixir 2 | elixir: 3 | - 1.2.1 4 | otp_release: 5 | - 18.1 6 | addons: 7 | postgresql: '9.4' 8 | services: 9 | - postgresql 10 | before_script: 11 | - cp config/travis.exs config/test.exs 12 | - mix do ecto.create, ecto.migrate 13 | script: 14 | - mix test --exclude integration 15 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2016 Ricardo García Vega 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Phoenix Trello 2 | [![Build Status](https://travis-ci.org/bigardone/phoenix-trello.svg?branch=master)](https://travis-ci.org/bigardone/phoenix-trello) 3 | 4 | 5 | [Trello](http://trello.com) tribute done with [Elixir](https://github.com/elixir-lang/elixir), [Phoenix Framework](https://github.com/phoenixframework/phoenix), [Webpack](https://github.com/webpack/webpack), [React](https://github.com/facebook/react) and [Redux](https://github.com/rackt/redux). 6 | 7 | ![`board`](http://codeloveandboards.com/images/blog/trello_tribute_pt_1/sign-in-a8fa19da.jpg) 8 | 9 | ## Tutorial 10 | 1. [Intro and selected stack](http://codeloveandboards.com/blog/2016/01/04/trello-tribute-with-phoenix-and-react-pt-1/) 11 | 2. [Phoenix Framework project setup](http://codeloveandboards.com/blog/2016/01/11/trello-tribute-with-phoenix-and-react-pt-2/) 12 | 3. [The User model and JWT auth](http://codeloveandboards.com/blog/2016/01/12/trello-tribute-with-phoenix-and-react-pt-3/) 13 | 4. [Front-end for sign up with React and Redux](http://codeloveandboards.com/blog/2016/01/14/trello-tribute-with-phoenix-and-react-pt-4/) 14 | 5. [Database seeding and sign in controller](http://codeloveandboards.com/blog/2016/01/18/trello-tribute-with-phoenix-and-react-pt-5/) 15 | 6. [Front-end authentication with React and Redux](http://codeloveandboards.com/blog/2016/01/20/trello-tribute-with-phoenix-and-react-pt-6/) 16 | 7. [Setting up sockets and channels](http://codeloveandboards.com/blog/2016/01/25/trello-tribute-with-phoenix-and-react-pt-7/) 17 | 8. [Listing and creating new boards](http://codeloveandboards.com/blog/2016/01/28/trello-tribute-with-phoenix-and-react-pt-8/) 18 | 9. [Adding board members](http://codeloveandboards.com/blog/2016/02/04/trello-tribute-with-phoenix-and-react-pt-9/) 19 | 10. [Tracking connected board members](http://codeloveandboards.com/blog/2016/02/15/trello-tribute-with-phoenix-and-react-pt-10/) 20 | 11. [Adding lists and cards](http://codeloveandboards.com/blog/2016/02/24/trello-tribute-with-phoenix-and-react-pt-11/) 21 | 12. [Deploying our application on Heroku](http://codeloveandboards.com/blog/2016/03/04/trello-tribute-with-phoenix-and-react-pt-12/) 22 | 23 | ## Live demo 24 | https://phoenix-trello.herokuapp.com 25 | 26 | ## Requirements 27 | You need to have **Elixir v1.3** and **PostgreSQL** installed. 28 | 29 | ## Installation instructions 30 | To start your Phoenix Trello app: 31 | 32 | 1. Install dependencies with `mix deps.get` 33 | 2. Ensure webpack is installed. ie: `npm install -g webpack` 34 | 3. Install npm packages with `npm install` 35 | 4. Create and migrate your database with `mix ecto.create && mix ecto.migrate` 36 | 5. Run seeds to create demo user with `mix run priv/repo/seeds.exs` 37 | 6. Start Phoenix endpoint with `mix phoenix.server` 38 | 39 | Now you can visit [`localhost:4000`](http://localhost:4000) from your browser. 40 | 41 | Enjoy! 42 | 43 | ## Testing 44 | Integration tests with [Hound](https://github.com/HashNuke/hound) and [Selenium ChromeDriver](https://github.com/SeleniumHQ/selenium/wiki/ChromeDriver). Instructions: 45 | 46 | 1. Install **ChromeDriver** with `npm install -g chromedriver` 47 | 2. Run **ChromeDriver** in a new terminal window with `chromedriver &` 48 | 3. Run tests with `mix test` 49 | 50 | If you don't want to run integration tests just run `mix test --exclude integration`. 51 | 52 | ## License 53 | 54 | See [LICENSE](LICENSE). 55 | -------------------------------------------------------------------------------- /compile: -------------------------------------------------------------------------------- 1 | info "****** Building Phoenix static assets" 2 | webpack 3 | mix phoenix.digest 4 | -------------------------------------------------------------------------------- /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 | config :phoenix_trello, 9 | namespace: PhoenixTrello, 10 | ecto_repos: [PhoenixTrello.Repo] 11 | 12 | # Configures the endpoint 13 | config :phoenix_trello, PhoenixTrello.Endpoint, 14 | url: [host: "localhost"], 15 | root: Path.dirname(__DIR__), 16 | secret_key_base: "hWbd3QwLuaWKwJY5qYOKLGSBboxjnW46c4TzBAa+cMODz26RokgHQIJo6Nej3DGr", 17 | render_errors: [accepts: ~w(html json)], 18 | pubsub: [name: PhoenixTrello.PubSub, 19 | adapter: Phoenix.PubSub.PG2] 20 | 21 | # Configures Elixir's Logger 22 | config :logger, :console, 23 | format: "$time $metadata[$level] $message\n", 24 | metadata: [:request_id] 25 | 26 | # Import environment specific config. This must remain at the bottom 27 | # of this file so it overrides the configuration defined above. 28 | import_config "#{Mix.env}.exs" 29 | 30 | # Configure phoenix generators 31 | config :phoenix, :generators, 32 | migration: true, 33 | binary_id: false 34 | 35 | # Configure guardian 36 | config :guardian, Guardian, 37 | issuer: "PhoenixTrello", 38 | ttl: { 3, :days }, 39 | verify_issuer: true, 40 | serializer: PhoenixTrello.GuardianSerializer 41 | 42 | # Start Hound for PhantomJs 43 | config :hound, driver: "chrome_driver" 44 | -------------------------------------------------------------------------------- /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 :phoenix_trello, PhoenixTrello.Endpoint, 10 | http: [port: 4000], 11 | debug_errors: true, 12 | code_reloader: true, 13 | cache_static_lookup: false, 14 | check_origin: false, 15 | watchers: [ 16 | node: ["node_modules/webpack/bin/webpack.js", "--watch", "--color", cd: Path.expand("../", __DIR__)] 17 | ] 18 | 19 | # Watch static and templates for browser reloading. 20 | config :phoenix_trello, PhoenixTrello.Endpoint, 21 | live_reload: [ 22 | patterns: [ 23 | ~r{priv/static/.*(js|css|png|jpeg|jpg|gif|svg)$}, 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. 33 | # Do not configure such in production as keeping 34 | # and calculating stacktraces is usually expensive. 35 | config :phoenix, :stacktrace_depth, 20 36 | 37 | # Configure your database 38 | config :phoenix_trello, PhoenixTrello.Repo, 39 | adapter: Ecto.Adapters.Postgres, 40 | username: "postgres", 41 | password: "postgres", 42 | database: "phoenix_trello_dev", 43 | hostname: "localhost", 44 | pool_size: 10 45 | 46 | # Guardian configuration 47 | config :guardian, Guardian, 48 | secret_key: "W9cDv9fjPtsYv2gItOcFb5PzmRzqGkrOsJGmby0KpBOlHJIlhxMKFmIlcCG9PVFQ" 49 | -------------------------------------------------------------------------------- /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 :phoenix_trello, PhoenixTrello.Endpoint, 15 | http: [port: {:system, "PORT"}], 16 | url: [scheme: "https", host: "phoenix-trello.herokuapp.com", port: 443], 17 | force_ssl: [rewrite_on: [:x_forwarded_proto]], 18 | cache_static_manifest: "priv/static/manifest.json", 19 | secret_key_base: System.get_env("SECRET_KEY_BASE") 20 | 21 | # Do not print debug messages in production 22 | config :logger, level: :info 23 | 24 | # Configure your database 25 | config :phoenix_trello, PhoenixTrello.Repo, 26 | adapter: Ecto.Adapters.Postgres, 27 | url: System.get_env("DATABASE_URL"), 28 | pool_size: 20 29 | 30 | # Configure guardian 31 | config :guardian, Guardian, 32 | secret_key: System.get_env("GUARDIAN_SECRET_KEY") 33 | 34 | # ## SSL Support 35 | # 36 | # To get SSL working, you will need to add the `https` key 37 | # to the previous section and set your `:url` port to 443: 38 | # 39 | # config :phoenix_trello, PhoenixTrello.Endpoint, 40 | # ... 41 | # url: [host: "example.com", port: 443], 42 | # https: [port: 443, 43 | # keyfile: System.get_env("SOME_APP_SSL_KEY_PATH"), 44 | # certfile: System.get_env("SOME_APP_SSL_CERT_PATH")] 45 | # 46 | # Where those two env variables return an absolute path to 47 | # the key and cert in disk or a relative path inside priv, 48 | # for example "priv/ssl/server.key". 49 | # 50 | # We also recommend setting `force_ssl`, ensuring no data is 51 | # ever sent via http, always redirecting to https: 52 | # 53 | # config :phoenix_trello, PhoenixTrello.Endpoint, 54 | # force_ssl: [hsts: true] 55 | # 56 | # Check `Plug.SSL` for all available options in `force_ssl`. 57 | 58 | # ## Using releases 59 | # 60 | # If you are doing OTP releases, you need to instruct Phoenix 61 | # to start the server for all endpoints: 62 | # 63 | # config :phoenix, :serve_endpoints, true 64 | # 65 | # Alternatively, you can configure exactly which server to 66 | # start per endpoint: 67 | # 68 | # config :phoenix_trello, PhoenixTrello.Endpoint, server: true 69 | # 70 | 71 | # Finally import the config/prod.secret.exs 72 | # which should be versioned separately. 73 | # import_config "prod.secret.exs" 74 | -------------------------------------------------------------------------------- /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 :phoenix_trello, PhoenixTrello.Endpoint, 6 | http: [port: 4001], 7 | server: true 8 | 9 | # Print only warnings and errors during test 10 | config :logger, level: :warn 11 | 12 | # Configure your database 13 | config :phoenix_trello, PhoenixTrello.Repo, 14 | adapter: Ecto.Adapters.Postgres, 15 | username: "postgres", 16 | password: "postgres", 17 | database: "phoenix_trello_test", 18 | hostname: "localhost", 19 | pool: Ecto.Adapters.SQL.Sandbox 20 | 21 | # Guardian configuration 22 | config :guardian, Guardian, 23 | secret_key: "W9cDv9fjPtsYv2gItOcFb5PzmRzqGkrOsJGmby0KpBOlHJIlhxMKFmIlcCG9PVFQ" 24 | -------------------------------------------------------------------------------- /config/travis.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 :phoenix_trello, PhoenixTrello.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 :phoenix_trello, PhoenixTrello.Repo, 14 | adapter: Ecto.Adapters.Postgres, 15 | username: "postgres", 16 | password: "", 17 | database: "phoenix_trello_test", 18 | hostname: "localhost", 19 | pool: Ecto.Adapters.SQL.Sandbox 20 | -------------------------------------------------------------------------------- /elixir_buildpack.config: -------------------------------------------------------------------------------- 1 | # Elixir version 2 | elixir_version=1.2.1 3 | 4 | # Always rebuild from scratch on every deploy? 5 | always_rebuild=true 6 | -------------------------------------------------------------------------------- /lib/phoenix_trello.ex: -------------------------------------------------------------------------------- 1 | defmodule PhoenixTrello 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, warn: false 8 | 9 | children = [ 10 | # Start the endpoint when the application starts 11 | supervisor(PhoenixTrello.Endpoint, []), 12 | # Start the Ecto repository 13 | worker(PhoenixTrello.Repo, []), 14 | # Here you could define other workers and supervisors as children 15 | # worker(PhoenixTrello.Worker, [arg1, arg2, arg3]), 16 | supervisor(PhoenixTrello.BoardChannel.Supervisor, []), 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: PhoenixTrello.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 | PhoenixTrello.Endpoint.config_change(changed, removed) 29 | :ok 30 | end 31 | end 32 | -------------------------------------------------------------------------------- /lib/phoenix_trello/board_channel/monitor.ex: -------------------------------------------------------------------------------- 1 | defmodule PhoenixTrello.BoardChannel.Monitor do 2 | @moduledoc """ 3 | Board monitor that keeps track of connected users. 4 | """ 5 | 6 | use GenServer 7 | 8 | ##### 9 | # External API 10 | 11 | def create(board_id) do 12 | case GenServer.whereis(ref(board_id)) do 13 | nil -> 14 | Supervisor.start_child(PhoenixTrello.BoardChannel.Supervisor, [board_id]) 15 | _board -> 16 | {:error, :board_already_exists} 17 | end 18 | end 19 | 20 | def start_link(board_id) do 21 | GenServer.start_link(__MODULE__, [], name: ref(board_id)) 22 | end 23 | 24 | def user_joined(board_id, user) do 25 | try_call board_id, {:user_joined, user} 26 | end 27 | 28 | def users_in_board(board_id) do 29 | try_call board_id, {:users_in_board} 30 | end 31 | 32 | def user_left(board_id, user) do 33 | try_call board_id, {:user_left, user} 34 | end 35 | 36 | ##### 37 | # GenServer implementation 38 | 39 | def handle_call({:user_joined, user}, _from, users) do 40 | users = [user] ++ users 41 | |> Enum.uniq 42 | 43 | {:reply, users, users} 44 | end 45 | 46 | def handle_call({:users_in_board}, _from, users) do 47 | { :reply, users, users } 48 | end 49 | 50 | def handle_call({:user_left, user}, _from, users) do 51 | users = List.delete(users, user) 52 | {:reply, users, users} 53 | end 54 | 55 | defp ref(board_id) do 56 | {:global, {:board, board_id}} 57 | end 58 | 59 | defp try_call(board_id, call_function) do 60 | case GenServer.whereis(ref(board_id)) do 61 | nil -> 62 | {:error, :invalid_board} 63 | board -> 64 | GenServer.call(board, call_function) 65 | end 66 | end 67 | end 68 | -------------------------------------------------------------------------------- /lib/phoenix_trello/board_channel/supervisor.ex: -------------------------------------------------------------------------------- 1 | defmodule PhoenixTrello.BoardChannel.Supervisor do 2 | use Supervisor 3 | 4 | def start_link do 5 | Supervisor.start_link(__MODULE__, :ok, name: __MODULE__) 6 | end 7 | 8 | def init(:ok) do 9 | children = [ 10 | worker(PhoenixTrello.BoardChannel.Monitor, [], restart: :temporary) 11 | ] 12 | 13 | supervise(children, strategy: :simple_one_for_one) 14 | end 15 | end 16 | -------------------------------------------------------------------------------- /lib/phoenix_trello/endpoint.ex: -------------------------------------------------------------------------------- 1 | defmodule PhoenixTrello.Endpoint do 2 | use Phoenix.Endpoint, otp_app: :phoenix_trello 3 | 4 | socket "/socket", PhoenixTrello.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: :phoenix_trello, 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 | plug Plug.Session, 34 | store: :cookie, 35 | key: "_phoenix_trello_key", 36 | signing_salt: "utLqshqa" 37 | 38 | plug PhoenixTrello.Router 39 | end 40 | -------------------------------------------------------------------------------- /lib/phoenix_trello/guardian_serializer.ex: -------------------------------------------------------------------------------- 1 | defmodule PhoenixTrello.GuardianSerializer do 2 | @behaviour Guardian.Serializer 3 | 4 | alias PhoenixTrello.{Repo, User} 5 | 6 | def for_token(user = %User{}), do: { :ok, "User:#{user.id}" } 7 | def for_token(_), do: { :error, "Unknown resource type" } 8 | 9 | def from_token("User:" <> id), do: { :ok, Repo.get(User, String.to_integer(id)) } 10 | def from_token(_), do: { :error, "Unknown resource type" } 11 | end 12 | -------------------------------------------------------------------------------- /lib/phoenix_trello/permalink.ex: -------------------------------------------------------------------------------- 1 | defmodule PhoenixTrello.Permalink do 2 | @behaviour Ecto.Type 3 | 4 | def type, do: :id 5 | 6 | def cast(binary) when is_binary(binary) do 7 | case Integer.parse(binary) do 8 | {int, _} when int > 0 -> {:ok, int} 9 | _ -> :error 10 | end 11 | end 12 | 13 | def cast(integer) when is_integer(integer) do 14 | {:ok, integer} 15 | end 16 | 17 | def cast(_) do 18 | :error 19 | end 20 | 21 | def dump(integer) when is_integer(integer) do 22 | {:ok, integer} 23 | end 24 | 25 | def load(integer) when is_integer(integer) do 26 | {:ok, integer} 27 | end 28 | end 29 | -------------------------------------------------------------------------------- /lib/phoenix_trello/repo.ex: -------------------------------------------------------------------------------- 1 | defmodule PhoenixTrello.Repo do 2 | use Ecto.Repo, otp_app: :phoenix_trello 3 | end 4 | -------------------------------------------------------------------------------- /mix.exs: -------------------------------------------------------------------------------- 1 | defmodule PhoenixTrello.Mixfile do 2 | use Mix.Project 3 | 4 | def project do 5 | [app: :phoenix_trello, 6 | version: "0.0.1", 7 | elixir: "~> 1.0", 8 | elixirc_paths: elixirc_paths(Mix.env), 9 | compilers: [:phoenix] ++ Mix.compilers, 10 | build_embedded: Mix.env == :prod, 11 | start_permanent: Mix.env == :prod, 12 | aliases: aliases, 13 | deps: deps] 14 | end 15 | 16 | # Configuration for the OTP application. 17 | # 18 | # Type `mix help compile.app` for more information. 19 | def application do 20 | [mod: {PhoenixTrello, []}, 21 | applications: [ 22 | :phoenix, 23 | :phoenix_pubsub, 24 | :phoenix_html, 25 | :cowboy, 26 | :logger, 27 | :phoenix_ecto, 28 | :postgrex, 29 | :comeonin, 30 | :ex_machina 31 | ]] 32 | end 33 | 34 | # Specifies which paths to compile per environment. 35 | defp elixirc_paths(:test), do: ["lib", "web", "test/support"] 36 | defp elixirc_paths(_), do: ["lib", "web"] 37 | 38 | # Specifies your project dependencies. 39 | # 40 | # Type `mix help deps` for examples and options. 41 | defp deps do 42 | [ 43 | {:phoenix, "~> 1.2.1"}, 44 | {:phoenix_pubsub, "~> 1.0"}, 45 | {:phoenix_ecto, "~> 3.0.1"}, 46 | {:postgrex, ">= 0.0.0", override: true}, 47 | {:phoenix_html, "~> 2.6.2"}, 48 | {:phoenix_live_reload, "~> 1.0", only: :dev}, 49 | {:cowboy, "~> 1.0"}, 50 | {:comeonin, "~> 2.5.3"}, 51 | {:guardian, "~> 0.13.0"}, 52 | {:credo, "~> 0.4.11", only: [:dev, :test]}, 53 | {:ex_machina, "~> 1.0.2"}, 54 | {:exactor, "~> 2.2.0"}, 55 | {:hound, "~> 1.0.2"}, 56 | {:mix_test_watch, "~> 0.2", only: :dev} 57 | ] 58 | end 59 | 60 | # Aliases are shortcut or tasks specific to the current project. 61 | # For example, to create, migrate and run the seeds file at once: 62 | # 63 | # $ mix ecto.setup 64 | # 65 | # See the documentation for `Mix` for more info on aliases. 66 | defp aliases do 67 | ["ecto.setup": ["ecto.create", "ecto.migrate", "run priv/repo/seeds.exs"], 68 | "ecto.reset": ["ecto.drop", "ecto.setup"], 69 | "test": ["ecto.create --quiet", "ecto.migrate", "test"]] 70 | end 71 | end 72 | -------------------------------------------------------------------------------- /mix.lock: -------------------------------------------------------------------------------- 1 | %{"backoff": {:hex, :backoff, "1.1.1"}, 2 | "base64url": {:hex, :base64url, "0.0.1", "36a90125f5948e3afd7be97662a1504b934dd5dac78451ca6e9abf85a10286be", [:rebar], []}, 3 | "bunt": {:hex, :bunt, "0.1.6", "5d95a6882f73f3b9969fdfd1953798046664e6f77ec4e486e6fafc7caad97c6f", [:mix], []}, 4 | "certifi": {:hex, :certifi, "0.4.0", "a7966efb868b179023618d29a407548f70c52466bf1849b9e8ebd0e34b7ea11f", [:rebar3], []}, 5 | "comeonin": {:hex, :comeonin, "2.5.3", "ccd70ebf465eaf4e11fc5a13fd0f5be2538b6b4975d4f7a13d571670b31da060", [:mix, :make, :make], []}, 6 | "connection": {:hex, :connection, "1.0.4", "a1cae72211f0eef17705aaededacac3eb30e6625b04a6117c1b2db6ace7d5976", [:mix], []}, 7 | "cowboy": {:hex, :cowboy, "1.0.4", "a324a8df9f2316c833a470d918aaf73ae894278b8aa6226ce7a9bf699388f878", [:rebar, :make], [{:cowlib, "~> 1.0.0", [hex: :cowlib, optional: false]}, {:ranch, "~> 1.0", [hex: :ranch, optional: false]}]}, 8 | "cowlib": {:hex, :cowlib, "1.0.2", "9d769a1d062c9c3ac753096f868ca121e2730b9a377de23dec0f7e08b1df84ee", [:make], []}, 9 | "credo": {:hex, :credo, "0.4.11", "03a64e9d53309b7132556284dda0be57ba1013885725124cfea7748d740c6170", [:mix], [{:bunt, "~> 0.1.6", [hex: :bunt, optional: false]}]}, 10 | "db_connection": {:hex, :db_connection, "1.0.0-rc.5", "1d9ab6e01387bdf2de7a16c56866971f7c2f75aea7c69cae2a0346e4b537ae0d", [:mix], [{:connection, "~> 1.0.2", [hex: :connection, optional: false]}, {:poolboy, "~> 1.5", [hex: :poolboy, optional: true]}, {:sbroker, "~> 1.0.0-beta.3", [hex: :sbroker, optional: true]}]}, 11 | "decimal": {:hex, :decimal, "1.1.2", "79a769d4657b2d537b51ef3c02d29ab7141d2b486b516c109642d453ee08e00c", [:mix], []}, 12 | "ecto": {:hex, :ecto, "2.0.5", "7f4c79ac41ffba1a4c032b69d7045489f0069c256de606523c65d9f8188e502d", [:mix], [{:db_connection, "~> 1.0-rc.4", [hex: :db_connection, optional: true]}, {:decimal, "~> 1.1.2 or ~> 1.2", [hex: :decimal, optional: false]}, {:mariaex, "~> 0.7.7", [hex: :mariaex, optional: true]}, {:poison, "~> 1.5 or ~> 2.0", [hex: :poison, optional: true]}, {:poolboy, "~> 1.5", [hex: :poolboy, optional: false]}, {:postgrex, "~> 0.12.0", [hex: :postgrex, optional: true]}, {:sbroker, "~> 1.0-beta", [hex: :sbroker, optional: true]}]}, 13 | "ex_machina": {:hex, :ex_machina, "1.0.2", "1cc49e1a09e3f7ab2ecb630c17f14c2872dc4ec145d6d05a9c3621936a63e34f", [:mix], [{:ecto, "~> 2.0", [hex: :ecto, optional: true]}]}, 14 | "exactor": {:hex, :exactor, "2.2.2", "90b27d72c05614801a60f8400afd4e4346dfc33ea9beffe3b98a794891d2ff96", [:mix], []}, 15 | "fs": {:hex, :fs, "0.9.2", "ed17036c26c3f70ac49781ed9220a50c36775c6ca2cf8182d123b6566e49ec59", [:rebar], []}, 16 | "guardian": {:hex, :guardian, "0.13.0", "37c5b5302617276093570ee938baca146f53e1d5de1f5c2b8effb1d2fea596d2", [:mix], [{:jose, "~> 1.8", [hex: :jose, optional: false]}, {:phoenix, "~> 1.2.0", [hex: :phoenix, optional: true]}, {:plug, "~> 1.0", [hex: :plug, optional: false]}, {:poison, ">= 1.3.0", [hex: :poison, optional: false]}, {:uuid, ">=1.1.1", [hex: :uuid, optional: false]}]}, 17 | "hackney": {:hex, :hackney, "1.6.1", "ddd22d42db2b50e6a155439c8811b8f6df61a4395de10509714ad2751c6da817", [:rebar3], [{:certifi, "0.4.0", [hex: :certifi, optional: false]}, {:idna, "1.2.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.0", [hex: :ssl_verify_fun, optional: false]}]}, 18 | "hound": {:hex, :hound, "1.0.2", "b6dd20142d00c28009fad503a23fa4a76bc11899e0d198f36a9c1448427788b2", [:mix], [{:httpoison, "~> 0.8", [hex: :httpoison, optional: false]}, {:poison, ">= 1.4.0", [hex: :poison, optional: false]}]}, 19 | "httpoison": {:hex, :httpoison, "0.9.1", "6c2b4eaf2588a6f3ef29663d28c992531ca3f0bc832a97e0359bc822978e1c5d", [:mix], [{:hackney, "~> 1.6.0", [hex: :hackney, optional: false]}]}, 20 | "idna": {:hex, :idna, "1.2.0", "ac62ee99da068f43c50dc69acf700e03a62a348360126260e87f2b54eced86b2", [:rebar3], []}, 21 | "jose": {:hex, :jose, "1.8.0", "1ee027c5c0ff3922e3bfe58f7891509e8f87f771ba609ee859e623cc60237574", [:mix, :rebar3], [{:base64url, "~> 0.0.1", [hex: :base64url, optional: false]}]}, 22 | "metrics": {:hex, :metrics, "1.0.1", "25f094dea2cda98213cecc3aeff09e940299d950904393b2a29d191c346a8486", [:rebar3], []}, 23 | "mime": {:hex, :mime, "1.0.1", "05c393850524767d13a53627df71beeebb016205eb43bfbd92d14d24ec7a1b51", [:mix], []}, 24 | "mimerl": {:hex, :mimerl, "1.0.2", "993f9b0e084083405ed8252b99460c4f0563e41729ab42d9074fd5e52439be88", [:rebar3], []}, 25 | "mix_test_watch": {:hex, :mix_test_watch, "0.2.6", "9fcc2b1b89d1594c4a8300959c19d50da2f0ff13642c8f681692a6e507f92cab", [:mix], [{:fs, "~> 0.9.1", [hex: :fs, optional: false]}]}, 26 | "phoenix": {:hex, :phoenix, "1.2.1", "6dc592249ab73c67575769765b66ad164ad25d83defa3492dc6ae269bd2a68ab", [:mix], [{:cowboy, "~> 1.0", [hex: :cowboy, optional: true]}, {:phoenix_pubsub, "~> 1.0", [hex: :phoenix_pubsub, optional: false]}, {:plug, "~> 1.1", [hex: :plug, optional: false]}, {:poison, "~> 1.5 or ~> 2.0", [hex: :poison, optional: false]}]}, 27 | "phoenix_ecto": {:hex, :phoenix_ecto, "3.0.1", "42eb486ef732cf209d0a353e791806721f33ff40beab0a86f02070a5649ed00a", [:mix], [{:ecto, "~> 2.0", [hex: :ecto, optional: false]}, {:phoenix_html, "~> 2.6", [hex: :phoenix_html, optional: true]}, {:plug, "~> 1.0", [hex: :plug, optional: false]}]}, 28 | "phoenix_html": {:hex, :phoenix_html, "2.6.2", "944a5e581b0d899e4f4c838a69503ebd05300fe35ba228a74439e6253e10e0c0", [:mix], [{:plug, "~> 1.0", [hex: :plug, optional: false]}]}, 29 | "phoenix_live_reload": {:hex, :phoenix_live_reload, "1.0.5", "829218c4152ba1e9848e2bf8e161fcde6b4ec679a516259442561d21fde68d0b", [:mix], [{:fs, "~> 0.9.1", [hex: :fs, optional: false]}, {:phoenix, "~> 1.0 or ~> 1.2-rc", [hex: :phoenix, optional: false]}]}, 30 | "phoenix_pubsub": {:hex, :phoenix_pubsub, "1.0.0", "c31af4be22afeeebfaf246592778c8c840e5a1ddc7ca87610c41ccfb160c2c57", [:mix], []}, 31 | "plug": {:hex, :plug, "1.2.0", "496bef96634a49d7803ab2671482f0c5ce9ce0b7b9bc25bc0ae8e09859dd2004", [:mix], [{:cowboy, "~> 1.0", [hex: :cowboy, optional: true]}, {:mime, "~> 1.0", [hex: :mime, optional: false]}]}, 32 | "poison": {:hex, :poison, "2.2.0", "4763b69a8a77bd77d26f477d196428b741261a761257ff1cf92753a0d4d24a63", [:mix], []}, 33 | "poolboy": {:hex, :poolboy, "1.5.1", "6b46163901cfd0a1b43d692657ed9d7e599853b3b21b95ae5ae0a777cf9b6ca8", [:rebar], []}, 34 | "postgrex": {:hex, :postgrex, "0.12.0", "bdeeb4c42768c47c3c92228e66c70357fe9a9384fbc9de06abba774b22dd0635", [:mix], [{:connection, "~> 1.0", [hex: :connection, optional: false]}, {:db_connection, "~> 1.0-rc.4", [hex: :db_connection, optional: false]}, {:decimal, "~> 1.0", [hex: :decimal, optional: false]}]}, 35 | "ranch": {:hex, :ranch, "1.2.1", "a6fb992c10f2187b46ffd17ce398ddf8a54f691b81768f9ef5f461ea7e28c762", [:make], []}, 36 | "ssl_verify_fun": {:hex, :ssl_verify_fun, "1.1.0", "edee20847c42e379bf91261db474ffbe373f8acb56e9079acb6038d4e0bf414f", [:rebar, :make], []}, 37 | "ssl_verify_hostname": {:hex, :ssl_verify_hostname, "1.0.5"}, 38 | "uuid": {:hex, :uuid, "1.1.5", "96cb36d86ee82f912efea4d50464a5df606bf3f1163d6bdbb302d98474969369", [:mix], []}} 39 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "phoenix_trello", 3 | "version": "1.0.0", 4 | "description": "Trello tribute done in Elixir and Phoenix.", 5 | "main": "index.js", 6 | "directories": { 7 | "test": "test" 8 | }, 9 | "scripts": { 10 | "test": "echo \"Error: no test specified\" && exit 1" 11 | }, 12 | "author": "Ricardo García Vega", 13 | "license": "ISC", 14 | "private": true, 15 | "engines": { 16 | "node": "5.3.0", 17 | "npm": "3.5.x" 18 | }, 19 | "devDependencies": { 20 | "babel-core": "^6.3.26", 21 | "babel-loader": "^6.2.0", 22 | "babel-plugin-transform-decorators-legacy": "^1.3.4", 23 | "babel-preset-es2015": "^6.6.0", 24 | "babel-preset-react": "^6.3.13", 25 | "babel-preset-stage-0": "^6.3.13", 26 | "babel-preset-stage-2": "^6.3.13", 27 | "css-loader": "^0.23.1", 28 | "extract-text-webpack-plugin": "^1.0.1", 29 | "node-libs-browser": "^1.0.0", 30 | "node-sass": "^3.4.2", 31 | "sass-loader": "^3.1.2", 32 | "style-loader": "^0.13.0", 33 | "webpack": "^1.12.11" 34 | }, 35 | "dependencies": { 36 | "bourbon": "^4.2.6", 37 | "bourbon-neat": "^1.7.2", 38 | "classnames": "^2.2.1", 39 | "es6-promise": "^3.0.2", 40 | "history": "^1.17.0", 41 | "invariant": "^2.2.0", 42 | "isomorphic-fetch": "^2.2.1", 43 | "moment": "^2.11.1", 44 | "phoenix": "file:deps/phoenix", 45 | "phoenix_html": "file:deps/phoenix_html", 46 | "react": "^0.14.5", 47 | "react-addons-css-transition-group": "^0.14.5", 48 | "react-dnd": "^2.0.2", 49 | "react-dnd-html5-backend": "^2.0.2", 50 | "react-dom": "^0.14.5", 51 | "react-gravatar": "^2.2.2", 52 | "react-page-click": "^2.0.0", 53 | "react-redux": "^4.0.4", 54 | "react-router": "^2.0.1", 55 | "react-router-redux": "^4.0.0", 56 | "redux": "^3.3.1", 57 | "redux-logger": "^2.3.2", 58 | "redux-thunk": "^1.0.2" 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /phoenix_static_buildpack.config: -------------------------------------------------------------------------------- 1 | # We can set the version of Node to use for the app here 2 | node_version=5.3.0 3 | 4 | # We can set the version of NPM to use for the app here 5 | npm_version=3.5.2 6 | -------------------------------------------------------------------------------- /priv/repo/migrations/20151224075404_create_user.exs: -------------------------------------------------------------------------------- 1 | defmodule PhoenixTrello.Repo.Migrations.CreateUser do 2 | use Ecto.Migration 3 | 4 | def change do 5 | create table(:users) do 6 | add :first_name, :string, null: false 7 | add :last_name, :string, null: false 8 | add :email, :string, null: false 9 | add :crypted_password, :string, null: false 10 | 11 | timestamps 12 | end 13 | 14 | create unique_index(:users, [:email]) 15 | end 16 | end 17 | -------------------------------------------------------------------------------- /priv/repo/migrations/20151224093233_create_board.exs: -------------------------------------------------------------------------------- 1 | defmodule PhoenixTrello.Repo.Migrations.CreateBoard do 2 | use Ecto.Migration 3 | 4 | def change do 5 | create table(:boards) do 6 | add :name, :string, null: false 7 | 8 | add :user_id, references(:users, on_delete: :delete_all), null: false 9 | 10 | timestamps 11 | end 12 | 13 | create index(:boards, [:user_id]) 14 | end 15 | end 16 | -------------------------------------------------------------------------------- /priv/repo/migrations/20151225091657_create_list.exs: -------------------------------------------------------------------------------- 1 | defmodule PhoenixTrello.Repo.Migrations.CreateList do 2 | use Ecto.Migration 3 | 4 | def change do 5 | create table(:lists) do 6 | add :name, :string, null: false 7 | add :position, :integer, defaul: 0 8 | add :board_id, references(:boards, on_delete: :delete_all) 9 | 10 | timestamps 11 | end 12 | 13 | create index(:lists, [:board_id]) 14 | end 15 | end 16 | -------------------------------------------------------------------------------- /priv/repo/migrations/20151227084131_create_card.exs: -------------------------------------------------------------------------------- 1 | defmodule PhoenixTrello.Repo.Migrations.CreateCard do 2 | use Ecto.Migration 3 | 4 | def change do 5 | create table(:cards) do 6 | add :name, :string, null: false 7 | add :position, :integer, default: 0 8 | add :list_id, references(:lists, on_delete: :delete_all), null: false 9 | 10 | timestamps 11 | end 12 | 13 | create index(:cards, [:list_id]) 14 | end 15 | end 16 | -------------------------------------------------------------------------------- /priv/repo/migrations/20151230081546_create_user_board.exs: -------------------------------------------------------------------------------- 1 | defmodule PhoenixTrello.Repo.Migrations.CreateUserBoard do 2 | use Ecto.Migration 3 | 4 | def change do 5 | create table(:user_boards) do 6 | add :user_id, references(:users, on_delete: :delete_all), null: false 7 | add :board_id, references(:boards, on_delete: :delete_all), null: false 8 | 9 | timestamps 10 | end 11 | 12 | create index(:user_boards, [:user_id]) 13 | create index(:user_boards, [:board_id]) 14 | create unique_index(:user_boards, [:user_id, :board_id]) 15 | end 16 | end 17 | -------------------------------------------------------------------------------- /priv/repo/migrations/20160111104041_add_description_to_cards.exs: -------------------------------------------------------------------------------- 1 | defmodule PhoenixTrello.Repo.Migrations.AddDescriptionToCards do 2 | use Ecto.Migration 3 | 4 | def change do 5 | alter table(:cards) do 6 | add :description, :text 7 | end 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /priv/repo/migrations/20160111105636_create_comment.exs: -------------------------------------------------------------------------------- 1 | defmodule PhoenixTrello.Repo.Migrations.CreateComment do 2 | use Ecto.Migration 3 | 4 | def change do 5 | create table(:comments) do 6 | add :text, :string, null: false 7 | add :user_id, references(:users, on_delete: :delete_all) 8 | add :card_id, references(:cards, on_delete: :delete_all) 9 | 10 | timestamps 11 | end 12 | 13 | create index(:comments, [:user_id]) 14 | create index(:comments, [:card_id]) 15 | end 16 | end 17 | -------------------------------------------------------------------------------- /priv/repo/migrations/20160113072454_user_password_fix.exs: -------------------------------------------------------------------------------- 1 | defmodule PhoenixTrello.Repo.Migrations.UserPasswordFix do 2 | use Ecto.Migration 3 | 4 | def change do 5 | rename table(:users), :crypted_password, to: :encrypted_password 6 | end 7 | end 8 | -------------------------------------------------------------------------------- /priv/repo/migrations/20160115070933_add_slug_to_board.exs: -------------------------------------------------------------------------------- 1 | defmodule PhoenixTrello.Repo.Migrations.AddSlugToBoard do 2 | use Ecto.Migration 3 | 4 | def change do 5 | alter table(:boards) do 6 | add :slug, :string 7 | end 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /priv/repo/migrations/20160120080228_create_card_member.exs: -------------------------------------------------------------------------------- 1 | defmodule PhoenixTrello.Repo.Migrations.CreateCardMember do 2 | use Ecto.Migration 3 | 4 | def change do 5 | create table(:card_members) do 6 | add :card_id, references(:cards, on_delete: :delete_all), null: false 7 | add :user_board_id, references(:user_boards, on_delete: :delete_all), null: false 8 | 9 | timestamps 10 | end 11 | 12 | create index(:card_members, [:card_id]) 13 | create index(:card_members, [:user_board_id]) 14 | create unique_index(:card_members, [:card_id, :user_board_id]) 15 | end 16 | end 17 | -------------------------------------------------------------------------------- /priv/repo/migrations/20160127155137_add_tags_to_cards.exs: -------------------------------------------------------------------------------- 1 | defmodule PhoenixTrello.Repo.Migrations.AddTagsToCards do 2 | use Ecto.Migration 3 | 4 | def change do 5 | alter table(:cards) do 6 | add :tags, {:array, :string}, default: [] 7 | end 8 | 9 | create index(:cards, [:tags]) 10 | end 11 | end 12 | -------------------------------------------------------------------------------- /priv/repo/seeds.exs: -------------------------------------------------------------------------------- 1 | alias PhoenixTrello.{Repo, User} 2 | 3 | [ 4 | %{ 5 | first_name: "John", 6 | last_name: "Doe", 7 | email: "john@phoenix-trello.com", 8 | password: "12345678" 9 | }, 10 | ] 11 | |> Enum.map(&User.changeset(%User{}, &1)) 12 | |> Enum.each(&Repo.insert!(&1)) 13 | -------------------------------------------------------------------------------- /priv/static/images/favicon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bigardone/phoenix-trello/60c874de63126b5659a42ef8e05846ca16e3e30a/priv/static/images/favicon.png -------------------------------------------------------------------------------- /priv/static/images/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bigardone/phoenix-trello/60c874de63126b5659a42ef8e05846ca16e3e30a/priv/static/images/logo.png -------------------------------------------------------------------------------- /priv/static/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 | -------------------------------------------------------------------------------- /test/integration/add_cards_test.exs: -------------------------------------------------------------------------------- 1 | defmodule PhoenixTrello.AddCardsTest do 2 | use PhoenixTrello.IntegrationCase 3 | 4 | alias PhoenixTrello.{Board} 5 | 6 | setup do 7 | user = create_user 8 | board = create_board(user) 9 | 10 | list = board 11 | |> build_assoc(:lists) 12 | |> PhoenixTrello.List.changeset(%{name: "First list"}) 13 | |> Repo.insert! 14 | 15 | 16 | {:ok, %{user: user, board: board, list: list}} 17 | end 18 | 19 | @tag :integration 20 | test "Clicking on a previously created list", %{user: user, board: board, list: list} do 21 | user_sign_in(%{user: user, board: board}) 22 | 23 | navigate_to "/boards/#{Board.slug_id(board)}" 24 | 25 | assert element_displayed?({:css, ".view-container.boards.show"}) 26 | 27 | assert element_displayed?({:id, "list_#{list.id}"}) 28 | 29 | find_element(:id, "list_#{list.id}") 30 | |> find_within_element(:css, ".add-new") 31 | |> click 32 | 33 | assert element_displayed?({:id, "new_card_form"}) 34 | 35 | new_card_form = find_element(:id, "new_card_form") 36 | 37 | new_card_form 38 | |> find_within_element(:id, "card_name") 39 | |> fill_field("New card") 40 | 41 | new_card_form 42 | |> find_within_element(:css, "button") 43 | |> click 44 | 45 | card = board 46 | |> last_card 47 | 48 | assert element_displayed?({:id, "card_#{card.id}"}) 49 | assert page_source =~ card.name 50 | end 51 | 52 | defp last_card(board) do 53 | Board 54 | |> Repo.get!(board.id) 55 | |> Repo.preload([:cards]) 56 | |> Map.get(:cards) 57 | |> List.last 58 | end 59 | end 60 | -------------------------------------------------------------------------------- /test/integration/add_lists_test.exs: -------------------------------------------------------------------------------- 1 | defmodule PhoenixTrello.AddListsTest do 2 | use PhoenixTrello.IntegrationCase 3 | 4 | alias PhoenixTrello.{Board} 5 | 6 | setup do 7 | user = create_user 8 | board = create_board(user) 9 | 10 | {:ok, %{user: user, board: board }} 11 | end 12 | 13 | @tag :integration 14 | test "Clicking on previously created board", %{user: user, board: board} do 15 | user_sign_in(%{user: user, board: board}) 16 | 17 | navigate_to "/boards/#{Board.slug_id(board)}" 18 | 19 | assert element_displayed?({:css, ".view-container.boards.show"}) 20 | assert page_source =~ "Add new list..." 21 | 22 | click {:css, ".list.add-new"} 23 | 24 | assert element_displayed?({:css, ".list.form"}) 25 | 26 | new_list_form = find_element(:id, "new_list_form") 27 | 28 | new_list_form 29 | |> find_within_element(:id, "list_name") 30 | |> fill_field("New list") 31 | 32 | new_list_form 33 | |> find_within_element(:css, "button") 34 | |> click 35 | 36 | list = user 37 | |> last_list 38 | 39 | assert element_displayed?({:id, "list_#{list.id}"}) 40 | end 41 | 42 | defp last_list(user) do 43 | user 44 | |> Repo.preload([boards: [:lists]]) 45 | |> Map.get(:boards) 46 | |> List.last 47 | |> Repo.preload(:lists) 48 | |> Map.get(:lists) 49 | |> List.last 50 | end 51 | end 52 | -------------------------------------------------------------------------------- /test/integration/new_board_test.exs: -------------------------------------------------------------------------------- 1 | defmodule PhoenixTrello.NewBoardTest do 2 | use PhoenixTrello.IntegrationCase 3 | 4 | setup do 5 | user = create_user 6 | 7 | {:ok, %{user: user}} 8 | end 9 | 10 | @tag :integration 11 | test "GET / with existing user", %{user: user} do 12 | user_sign_in(%{user: user}) 13 | 14 | click({:id, "add_new_board"}) 15 | 16 | assert element_displayed?({:id, "new_board_form"}) 17 | 18 | new_board_form = find_element(:id, "new_board_form") 19 | 20 | new_board_form 21 | |> find_within_element(:id, "board_name") 22 | |> fill_field("New board") 23 | 24 | new_board_form 25 | |> find_within_element(:css, "button") 26 | |> click 27 | 28 | assert element_displayed?({:css, ".view-container.boards.show"}) 29 | 30 | board = last_board(user) 31 | 32 | assert page_title =~ board.name 33 | assert page_source =~ "New board" 34 | assert page_source =~ "Add new list..." 35 | end 36 | 37 | def last_board(user) do 38 | user 39 | |> Repo.preload(:boards) 40 | |> Map.get(:boards) 41 | |> Enum.at(0) 42 | end 43 | end 44 | -------------------------------------------------------------------------------- /test/integration/show_board_test.exs: -------------------------------------------------------------------------------- 1 | defmodule PhoenixTrello.ShowBoardTest do 2 | use PhoenixTrello.IntegrationCase 3 | 4 | alias PhoenixTrello.{Board} 5 | 6 | setup do 7 | user = create_user 8 | board = create_board(user) 9 | 10 | {:ok, %{user: user, board: board |> Repo.preload([:user, :members, lists: :cards])}} 11 | end 12 | 13 | @tag :integration 14 | test "Clicking on previously created board", %{user: user, board: board} do 15 | user_sign_in(%{user: user, board: board}) 16 | 17 | assert page_source =~ board.name 18 | 19 | board_id = board 20 | |> Board.slug_id 21 | 22 | click({:id, board_id}) 23 | 24 | assert element_displayed?({:css, ".view-container.boards.show"}) 25 | 26 | assert page_title =~ board.name 27 | assert page_source =~ board.name 28 | assert page_source =~ "Add new list..." 29 | end 30 | end 31 | -------------------------------------------------------------------------------- /test/integration/sign_in_test.exs: -------------------------------------------------------------------------------- 1 | defmodule PhoenixTrello.SignInTest do 2 | use PhoenixTrello.IntegrationCase 3 | 4 | @tag :integration 5 | test "GET /" do 6 | navigate_to "/" 7 | 8 | assert page_title == "Sign in | Phoenix Trello" 9 | assert element_displayed?({:id, "sign_in_form"}) 10 | end 11 | 12 | @tag :integration 13 | test "Sign in with wrong email/password" do 14 | navigate_to "/" 15 | 16 | assert element_displayed?({:id, "sign_in_form"}) 17 | 18 | sign_in_form = find_element(:id, "sign_in_form") 19 | 20 | sign_in_form 21 | |> find_within_element(:id, "user_email") 22 | |> fill_field("incorrect@email.com") 23 | 24 | sign_in_form 25 | |> find_within_element(:css, "button") 26 | |> click 27 | 28 | assert element_displayed?({:class, "error"}) 29 | 30 | assert page_source =~ "Invalid email or password" 31 | end 32 | 33 | @tag :integration 34 | test "Sign in with existing email/password" do 35 | user = create_user 36 | 37 | user_sign_in(%{user: user}) 38 | 39 | assert page_source =~ "#{user.first_name} #{user.last_name}" 40 | assert page_source =~ "My boards" 41 | end 42 | end 43 | -------------------------------------------------------------------------------- /test/integration/sign_up_test.exs: -------------------------------------------------------------------------------- 1 | defmodule PhoenixTrello.SignUpTest do 2 | use PhoenixTrello.IntegrationCase 3 | 4 | @tag :integration 5 | test "GET /sign_up" do 6 | navigate_to "/sign_up" 7 | 8 | assert page_title == "Sign up | Phoenix Trello" 9 | assert element_displayed?({:id, "sign_up_form"}) 10 | end 11 | 12 | @tag :integration 13 | test "Siginig up with correct data" do 14 | navigate_to "/sign_up" 15 | 16 | assert element_displayed?({:id, "sign_up_form"}) 17 | 18 | sign_up_form = find_element(:id, "sign_up_form") 19 | 20 | sign_up_form 21 | |> find_within_element(:id, "user_first_name") 22 | |> fill_field("John") 23 | 24 | sign_up_form 25 | |> find_within_element(:id, "user_last_name") 26 | |> fill_field("Doe") 27 | 28 | sign_up_form 29 | |> find_within_element(:id, "user_email") 30 | |> fill_field("john@doe.com") 31 | 32 | sign_up_form 33 | |> find_within_element(:id, "user_password") 34 | |> fill_field("12345678") 35 | 36 | sign_up_form 37 | |> find_within_element(:id, "user_password_confirmation") 38 | |> fill_field("12345678") 39 | 40 | sign_up_form 41 | |> find_within_element(:css, "button") 42 | |> click 43 | 44 | assert element_displayed?({:id, "authentication_container"}) 45 | 46 | assert page_source =~ "John Doe" 47 | assert page_source =~ "My boards" 48 | end 49 | end 50 | -------------------------------------------------------------------------------- /test/lib/phoenix_trello/board_channel/monitor_test.exs: -------------------------------------------------------------------------------- 1 | defmodule PhoenixTrello.BoardChannel.MonitorTest do 2 | use ExUnit.Case, async: true 3 | 4 | alias PhoenixTrello.{BoardChannel.Monitor, User} 5 | 6 | @board_id "1-board" 7 | 8 | setup_all do 9 | users = %{ 10 | first_user: %User{id: 1}, 11 | second_user: %User{id: 2}, 12 | third_user: %User{id: 3} 13 | } 14 | 15 | Monitor.create(@board_id) 16 | 17 | {:ok, %{users: users}} 18 | end 19 | 20 | test "it adds a user calling :user_joined", %{users: users} do 21 | Monitor.user_joined(@board_id, users.first_user.id) 22 | Monitor.user_joined(@board_id, users.second_user.id) 23 | new_state = Monitor.user_joined(@board_id, users.third_user.id) 24 | 25 | assert new_state == [users.third_user.id, users.second_user.id, users.first_user.id] 26 | end 27 | 28 | test "it removes a user when calling :user_left", %{users: users} do 29 | Monitor.user_joined(@board_id, users.first_user.id) 30 | Monitor.user_joined(@board_id, users.second_user.id) 31 | new_state = Monitor.user_joined(@board_id, users.third_user.id) 32 | assert new_state == [users.third_user.id, users.second_user.id, users.first_user.id] 33 | 34 | new_state = Monitor.user_left(@board_id, users.third_user.id) 35 | assert new_state == [users.second_user.id, users.first_user.id] 36 | 37 | new_state = Monitor.user_left(@board_id, users.second_user.id) 38 | assert new_state == [users.first_user.id] 39 | end 40 | 41 | test "it returns the list of users in channel when calling :users_in_board", %{users: users} do 42 | Monitor.user_joined(@board_id, users.first_user.id) 43 | Monitor.user_joined(@board_id, users.second_user.id) 44 | Monitor.user_joined(@board_id, users.third_user.id) 45 | 46 | returned_users = Monitor.users_in_board(@board_id) 47 | 48 | assert returned_users === [users.third_user.id, users.second_user.id, users.first_user.id] 49 | end 50 | end 51 | -------------------------------------------------------------------------------- /test/models/board_test.exs: -------------------------------------------------------------------------------- 1 | defmodule PhoenixTrello.BoardTest do 2 | use PhoenixTrello.ModelCase 3 | 4 | import PhoenixTrello.Factory 5 | 6 | alias PhoenixTrello.Board 7 | 8 | @valid_attrs %{name: "some content"} 9 | @invalid_attrs %{} 10 | 11 | test "changeset with valid attributes" do 12 | user = insert(:user) 13 | attributes = @valid_attrs 14 | |> Map.put(:user_id, user.id) 15 | 16 | changeset = Board.changeset(build(:board), attributes) 17 | assert changeset.valid? 18 | 19 | %{slug: slug} = changeset.changes 20 | assert slug == "some-content" 21 | end 22 | 23 | test "changeset with invalid attributes" do 24 | changeset = Board.changeset(%Board{}, @invalid_attrs) 25 | refute changeset.valid? 26 | end 27 | end 28 | -------------------------------------------------------------------------------- /test/models/card_member_test.exs: -------------------------------------------------------------------------------- 1 | defmodule PhoenixTrello.CardMemberTest do 2 | use PhoenixTrello.ModelCase 3 | 4 | alias PhoenixTrello.CardMember 5 | 6 | @valid_attrs %{card_id: 1, user_board_id: 1} 7 | @invalid_attrs %{} 8 | 9 | test "changeset with valid attributes" do 10 | changeset = CardMember.changeset(%CardMember{}, @valid_attrs) 11 | assert changeset.valid? 12 | end 13 | 14 | test "changeset with invalid attributes" do 15 | changeset = CardMember.changeset(%CardMember{}, @invalid_attrs) 16 | refute changeset.valid? 17 | end 18 | end 19 | -------------------------------------------------------------------------------- /test/models/card_test.exs: -------------------------------------------------------------------------------- 1 | defmodule PhoenixTrello.CardTest do 2 | use PhoenixTrello.ModelCase 3 | 4 | import PhoenixTrello.Factory 5 | 6 | alias PhoenixTrello.{Card} 7 | 8 | @valid_attrs %{name: "some content"} 9 | @invalid_attrs %{} 10 | 11 | setup do 12 | user = insert(:user) 13 | board = insert(:board, %{user: user}) 14 | list = insert(:list, %{board: board}) 15 | 16 | {:ok, list: list} 17 | end 18 | 19 | test "existing cards for the same list", %{list: list} do 20 | count = 3 21 | 22 | for i <- 1..(count-1) do 23 | list 24 | |> build_assoc(:cards) 25 | |> Card.changeset(%{name: "Card #{i}"}) 26 | |> Repo.insert 27 | end 28 | 29 | {:ok, last_card} = list 30 | |> build_assoc(:cards) 31 | |> Card.changeset(%{name: "Last"}) 32 | |> Repo.insert 33 | 34 | assert last_card.position == 1024 * count 35 | end 36 | end 37 | -------------------------------------------------------------------------------- /test/models/comment_test.exs: -------------------------------------------------------------------------------- 1 | defmodule PhoenixTrello.CommentTest do 2 | use PhoenixTrello.ModelCase 3 | 4 | import PhoenixTrello.Factory 5 | 6 | alias PhoenixTrello.Comment 7 | 8 | @valid_attrs %{text: "some content"} 9 | @invalid_attrs %{} 10 | 11 | setup do 12 | list = insert(:list_with_cards) 13 | |> Repo.preload([:board, :cards]) 14 | 15 | {:ok, list: list, card: List.first(list.cards)} 16 | end 17 | 18 | test "changeset with valid attributes", %{list: list, card: card} do 19 | attributes = @valid_attrs 20 | |> Map.put(:user_id, list.board.user_id) 21 | |> Map.put(:card_id, card.id) 22 | 23 | changeset = Comment.changeset(%Comment{}, attributes) 24 | assert changeset.valid? 25 | end 26 | 27 | test "changeset with invalid attributes" do 28 | changeset = Comment.changeset(%Comment{}, @invalid_attrs) 29 | refute changeset.valid? 30 | end 31 | end 32 | -------------------------------------------------------------------------------- /test/models/list_test.exs: -------------------------------------------------------------------------------- 1 | defmodule PhoenixTrello.ListTest do 2 | use PhoenixTrello.ModelCase 3 | 4 | import PhoenixTrello.Factory 5 | 6 | alias PhoenixTrello.{Repo, List} 7 | 8 | @valid_attrs %{name: "some content"} 9 | @invalid_attrs %{} 10 | 11 | setup do 12 | {:ok, board: insert(:board)} 13 | end 14 | 15 | test "changeset with valid attributes", %{board: board} do 16 | changeset = List.changeset(%List{board_id: board.id}, @valid_attrs) 17 | 18 | assert changeset.valid? 19 | 20 | list = Repo.insert!(changeset) 21 | assert list.position == 1024 22 | end 23 | 24 | test "changeset with invalid attributes", %{board: board} do 25 | changeset = List.changeset(%List{board_id: board.id}, @invalid_attrs) 26 | 27 | refute changeset.valid? 28 | end 29 | 30 | test "existing lists for the same board", %{board: board} do 31 | count = 3 32 | 33 | for i <- 1..(count-1) do 34 | board 35 | |> build_assoc(:lists) 36 | |> List.changeset(%{name: "List #{i}"}) 37 | |> Repo.insert 38 | end 39 | 40 | {:ok, last_list} = board 41 | |> build_assoc(:lists) 42 | |> List.changeset(%{name: "Last"}) 43 | |> Repo.insert 44 | 45 | assert last_list.position == 1024 * count 46 | end 47 | end 48 | -------------------------------------------------------------------------------- /test/models/user_board_test.exs: -------------------------------------------------------------------------------- 1 | defmodule PhoenixTrello.UserBoardTest do 2 | use PhoenixTrello.ModelCase 3 | 4 | import PhoenixTrello.Factory 5 | 6 | alias PhoenixTrello.UserBoard 7 | 8 | @valid_attrs %{} 9 | @invalid_attrs %{} 10 | 11 | setup do 12 | user = insert(:user) 13 | board = insert(:board) 14 | 15 | {:ok, user: user, board: board} 16 | end 17 | 18 | test "changeset with valid attributes", %{user: user, board: board} do 19 | attributes = @valid_attrs 20 | |> Map.put(:user_id, user.id) 21 | |> Map.put(:board_id, board.id) 22 | 23 | changeset = UserBoard.changeset(%UserBoard{}, attributes) 24 | assert changeset.valid? 25 | end 26 | 27 | test "changeset with invalid attributes", _ do 28 | changeset = UserBoard.changeset(%UserBoard{}, @invalid_attrs) 29 | refute changeset.valid? 30 | end 31 | end 32 | -------------------------------------------------------------------------------- /test/models/user_test.exs: -------------------------------------------------------------------------------- 1 | defmodule PhoenixTrello.UserTest do 2 | use PhoenixTrello.ModelCase 3 | 4 | alias PhoenixTrello.User 5 | 6 | @valid_attrs %{ 7 | encrypted_password: "some content", 8 | email: "email@email.com", 9 | first_name: "some content", 10 | last_name: "some content", 11 | password: "123456" 12 | } 13 | @invalid_attrs %{} 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 | end 25 | -------------------------------------------------------------------------------- /test/support/channel_case.ex: -------------------------------------------------------------------------------- 1 | defmodule PhoenixTrello.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 | imports 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 PhoenixTrello.Repo 24 | import Ecto.Model 25 | import Ecto.Query, only: [from: 2] 26 | 27 | 28 | # The default endpoint for testing 29 | @endpoint PhoenixTrello.Endpoint 30 | end 31 | end 32 | 33 | setup tags do 34 | :ok = Ecto.Adapters.SQL.Sandbox.checkout(PhoenixTrello.Repo) 35 | 36 | unless tags[:async] do 37 | Ecto.Adapters.SQL.Sandbox.mode(PhoenixTrello.Repo, {:shared, self()}) 38 | end 39 | 40 | :ok 41 | end 42 | end 43 | -------------------------------------------------------------------------------- /test/support/conn_case.ex: -------------------------------------------------------------------------------- 1 | defmodule PhoenixTrello.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 | imports 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 PhoenixTrello.Repo 24 | import Ecto.Model, except: [build: 2] 25 | import Ecto.Query, only: [from: 2] 26 | 27 | import PhoenixTrello.Router.Helpers 28 | 29 | import PhoenixTrello.Factory 30 | 31 | # The default endpoint for testing 32 | @endpoint PhoenixTrello.Endpoint 33 | end 34 | end 35 | 36 | setup tags do 37 | :ok = Ecto.Adapters.SQL.Sandbox.checkout(PhoenixTrello.Repo) 38 | 39 | unless tags[:async] do 40 | Ecto.Adapters.SQL.Sandbox.mode(PhoenixTrello.Repo, {:shared, self()}) 41 | end 42 | 43 | {:ok, conn: Phoenix.ConnTest.build_conn()} 44 | end 45 | end 46 | -------------------------------------------------------------------------------- /test/support/factory.ex: -------------------------------------------------------------------------------- 1 | defmodule PhoenixTrello.Factory do 2 | use ExMachina.Ecto, repo: PhoenixTrello.Repo 3 | 4 | alias PhoenixTrello.{User, Board, List, Card} 5 | 6 | def user_factory do 7 | %User{ 8 | first_name: sequence(:first_name, &"First #{&1}"), 9 | last_name: sequence(:last_name, &"Last #{&1}"), 10 | email: sequence(:email, &"email-#{&1}@foo.com"), 11 | encrypted_password: "12345678" 12 | } 13 | end 14 | 15 | def board_factory do 16 | %Board{ 17 | name: sequence(:name, &"Name #{&1}"), 18 | user: build(:user) 19 | } 20 | end 21 | 22 | def board_with_lists_factory do 23 | %Board{ 24 | name: sequence(:name, &"Name #{&1}"), 25 | user: build(:user), 26 | lists: build_list(3, :list) 27 | } 28 | end 29 | 30 | def list_factory do 31 | %List{ 32 | name: sequence(:name, &"Name #{&1}") 33 | } 34 | end 35 | 36 | def card_factory do 37 | %Card{ 38 | name: sequence(:name, &"Name #{&1}") 39 | } 40 | end 41 | 42 | def list_with_cards_factory do 43 | %List{ 44 | name: sequence(:name, &"Name #{&1}"), 45 | board: build(:board), 46 | cards: build_list(5, :card) 47 | } 48 | end 49 | end 50 | -------------------------------------------------------------------------------- /test/support/integration_case.ex: -------------------------------------------------------------------------------- 1 | defmodule PhoenixTrello.IntegrationCase do 2 | use ExUnit.CaseTemplate 3 | use Hound.Helpers 4 | import PhoenixTrello.Factory 5 | 6 | alias PhoenixTrello.{Repo, User, Board, UserBoard} 7 | 8 | using do 9 | quote do 10 | use Hound.Helpers 11 | 12 | import Ecto, only: [build_assoc: 2] 13 | import Ecto.Model 14 | import Ecto.Query, only: [from: 2] 15 | import PhoenixTrello.Router.Helpers 16 | import PhoenixTrello.Factory 17 | import PhoenixTrello.IntegrationCase 18 | 19 | alias PhoenixTrello.Repo 20 | 21 | # The default endpoint for testing 22 | @endpoint PhoenixTrello.Endpoint 23 | 24 | hound_session 25 | end 26 | end 27 | 28 | setup tags do 29 | :ok = Ecto.Adapters.SQL.Sandbox.checkout(PhoenixTrello.Repo) 30 | 31 | unless tags[:async] do 32 | Ecto.Adapters.SQL.Sandbox.mode(PhoenixTrello.Repo, {:shared, self()}) 33 | end 34 | 35 | :ok 36 | end 37 | 38 | def create_user do 39 | build(:user) 40 | |> User.changeset(%{password: "12345678"}) 41 | |> Repo.insert! 42 | end 43 | 44 | def create_board(user) do 45 | board = user 46 | |> Ecto.build_assoc(:owned_boards) 47 | |> Board.changeset(%{name: "My new board"}) 48 | |> Repo.insert! 49 | 50 | board 51 | |> Ecto.build_assoc(:user_boards) 52 | |> UserBoard.changeset(%{user_id: user.id}) 53 | |> Repo.insert! 54 | 55 | board 56 | |> Repo.preload([:user, :members, lists: :cards]) 57 | end 58 | 59 | def user_sign_in(%{user: user}) do 60 | navigate_to "/" 61 | 62 | sign_in_form = find_element(:id, "sign_in_form") 63 | 64 | sign_in_form 65 | |> find_within_element(:id, "user_email") 66 | |> fill_field(user.email) 67 | 68 | sign_in_form 69 | |> find_within_element(:id, "user_password") 70 | |> fill_field(user.password) 71 | 72 | sign_in_form 73 | |> find_within_element(:css, "button") 74 | |> click 75 | 76 | assert element_displayed?({:id, "authentication_container"}) 77 | end 78 | end 79 | -------------------------------------------------------------------------------- /test/support/model_case.ex: -------------------------------------------------------------------------------- 1 | defmodule PhoenixTrello.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 PhoenixTrello.Repo 20 | import Ecto, only: [build_assoc: 2] 21 | import Ecto.Model, except: [build: 2] 22 | import Ecto.Query, only: [from: 2] 23 | import PhoenixTrello.ModelCase 24 | end 25 | end 26 | 27 | setup tags do 28 | :ok = Ecto.Adapters.SQL.Sandbox.checkout(PhoenixTrello.Repo) 29 | 30 | unless tags[:async] do 31 | Ecto.Adapters.SQL.Sandbox.mode(PhoenixTrello.Repo, {:shared, self()}) 32 | end 33 | 34 | :ok 35 | end 36 | 37 | @doc """ 38 | Helper for returning list of errors in model when passed certain data. 39 | 40 | ## Examples 41 | 42 | Given a User model that lists `:name` as a required field and validates 43 | `:password` to be safe, it would return: 44 | 45 | iex> errors_on(%User{}, %{password: "password"}) 46 | [password: "is unsafe", name: "is blank"] 47 | 48 | You could then write your assertion like: 49 | 50 | assert {:password, "is unsafe"} in errors_on(%User{}, %{password: "password"}) 51 | 52 | You can also create the changeset manually and retrieve the errors 53 | field directly: 54 | 55 | iex> changeset = User.changeset(%User{}, password: "password") 56 | iex> {:password, "is unsafe"} in changeset.errors 57 | true 58 | """ 59 | def errors_on(struct, data) do 60 | struct.__struct__.changeset(struct, data) 61 | |> Ecto.Changeset.traverse_errors(&PhoenixTrello.ErrorHelpers.translate_error/1) 62 | |> Enum.flat_map(fn {key, errors} -> for msg <- errors, do: {key, msg} end) 63 | end 64 | end 65 | -------------------------------------------------------------------------------- /test/test_helper.exs: -------------------------------------------------------------------------------- 1 | Application.ensure_all_started(:hound) 2 | 3 | ExUnit.start 4 | 5 | Ecto.Adapters.SQL.Sandbox.mode(PhoenixTrello.Repo, :manual) 6 | -------------------------------------------------------------------------------- /test/views/error_view_test.exs: -------------------------------------------------------------------------------- 1 | defmodule PhoenixTrello.ErrorViewTest do 2 | use PhoenixTrello.ConnCase 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(PhoenixTrello.ErrorView, "404.html", []) == 9 | "Page not found" 10 | end 11 | 12 | test "render 500.html" do 13 | assert render_to_string(PhoenixTrello.ErrorView, "500.html", []) == 14 | "Server internal error" 15 | end 16 | 17 | test "render any other" do 18 | assert render_to_string(PhoenixTrello.ErrorView, "505.html", []) == 19 | "Server internal error" 20 | end 21 | end 22 | -------------------------------------------------------------------------------- /test/views/layout_view_test.exs: -------------------------------------------------------------------------------- 1 | defmodule PhoenixTrello.LayoutViewTest do 2 | use PhoenixTrello.ConnCase 3 | end -------------------------------------------------------------------------------- /test/views/page_view_test.exs: -------------------------------------------------------------------------------- 1 | defmodule PhoenixTrello.PageViewTest do 2 | use PhoenixTrello.ConnCase 3 | end 4 | -------------------------------------------------------------------------------- /web/channels/board_channel.ex: -------------------------------------------------------------------------------- 1 | defmodule PhoenixTrello.BoardChannel do 2 | @moduledoc """ 3 | Board channel 4 | """ 5 | 6 | use PhoenixTrello.Web, :channel 7 | 8 | alias PhoenixTrello.{User, Board, UserBoard, List, Card, Comment, CardMember} 9 | alias PhoenixTrello.BoardChannel.Monitor 10 | 11 | def join("boards:" <> board_id, _params, socket) do 12 | current_user = socket.assigns.current_user 13 | board = get_current_board(socket, board_id) 14 | 15 | Monitor.create(board_id) 16 | 17 | send(self, {:after_join, Monitor.user_joined(board_id, current_user.id)}) 18 | 19 | {:ok, %{board: board}, assign(socket, :board, board)} 20 | end 21 | 22 | def handle_info({:after_join, connected_users}, socket) do 23 | broadcast! socket, "user:joined", %{users: connected_users} 24 | {:noreply, socket} 25 | end 26 | 27 | def handle_in("lists:create", %{"list" => list_params}, socket) do 28 | board = socket.assigns.board 29 | 30 | changeset = board 31 | |> build_assoc(:lists) 32 | |> List.changeset(list_params) 33 | 34 | case Repo.insert(changeset) do 35 | {:ok, list} -> 36 | list = Repo.preload(list, [:board, :cards]) 37 | 38 | broadcast! socket, "list:created", %{list: list} 39 | {:noreply, socket} 40 | {:error, _changeset} -> 41 | {:reply, {:error, %{error: "Error creating list"}}, socket} 42 | end 43 | end 44 | 45 | def handle_in("cards:create", %{"card" => card_params}, socket) do 46 | board = socket.assigns.board 47 | changeset = board 48 | |> assoc(:lists) 49 | |> Repo.get!(card_params["list_id"]) 50 | |> build_assoc(:cards) 51 | |> Card.changeset(card_params) 52 | 53 | case Repo.insert(changeset) do 54 | {:ok, card} -> 55 | card = board 56 | |> assoc(:cards) 57 | |> Card.preload_all 58 | |> Repo.get!(card.id) 59 | 60 | broadcast! socket, "card:created", %{card: card} 61 | {:noreply, socket} 62 | {:error, _changeset} -> 63 | {:reply, {:error, %{error: "Error creating card"}}, socket} 64 | end 65 | end 66 | 67 | def handle_in("members:add", %{"email" => email}, socket) do 68 | try do 69 | board = socket.assigns.board 70 | user = User 71 | |> Repo.get_by(email: email) 72 | 73 | changeset = user 74 | |> build_assoc(:user_boards) 75 | |> UserBoard.changeset(%{board_id: board.id}) 76 | 77 | case Repo.insert(changeset) do 78 | {:ok, _board_user} -> 79 | broadcast! socket, "member:added", %{user: user} 80 | 81 | PhoenixTrello.Endpoint.broadcast_from! self(), "users:#{user.id}", "boards:add", %{board: board} 82 | 83 | {:noreply, socket} 84 | {:error, _changeset} -> 85 | {:reply, {:error, %{error: "Error adding new member"}}, socket} 86 | end 87 | catch 88 | _, _-> {:reply, {:error, %{error: "User does not exist"}}, socket} 89 | end 90 | end 91 | 92 | def handle_in("card:update", %{"card" => card_params}, socket) do 93 | card = socket.assigns.board 94 | |> assoc(:cards) 95 | |> Repo.get!(card_params["id"]) 96 | 97 | changeset = Card.update_changeset(card, card_params) 98 | 99 | case Repo.update(changeset) do 100 | {:ok, card} -> 101 | board = get_current_board(socket) 102 | 103 | card = Card 104 | |> Card.preload_all 105 | |> Repo.get(card.id) 106 | 107 | broadcast! socket, "card:updated", %{board: board, card: card} 108 | {:noreply, socket} 109 | {:error, _changeset} -> 110 | {:reply, {:error, %{error: "Error updating card"}}, socket} 111 | end 112 | end 113 | 114 | def handle_in("list:update", %{"list" => list_params}, socket) do 115 | list = socket.assigns.board 116 | |> assoc(:lists) 117 | |> Repo.get!(list_params["id"]) 118 | 119 | changeset = List.update_changeset(list, list_params) 120 | 121 | case Repo.update(changeset) do 122 | {:ok, _list} -> 123 | board = get_current_board(socket) 124 | broadcast! socket, "list:updated", %{board: board} 125 | {:noreply, socket} 126 | {:error, _changeset} -> 127 | {:reply, {:error, %{error: "Error updating list"}}, socket} 128 | end 129 | end 130 | 131 | def handle_in("card:add_comment", %{"card_id" => card_id, "text" => text}, socket) do 132 | current_user = socket.assigns.current_user 133 | 134 | comment = socket.assigns.board 135 | |> assoc(:cards) 136 | |> Repo.get!(card_id) 137 | |> build_assoc(:comments) 138 | 139 | changeset = Comment.changeset(comment, %{text: text, user_id: current_user.id}) 140 | 141 | case Repo.insert(changeset) do 142 | {:ok, _comment} -> 143 | card = Card 144 | |> Card.preload_all 145 | |> Repo.get(card_id) 146 | 147 | broadcast! socket, "comment:created", %{board: get_current_board(socket), card: card} 148 | {:noreply, socket} 149 | {:error, _changeset} -> 150 | {:reply, {:error, %{error: "Error creating comment"}}, socket} 151 | end 152 | end 153 | 154 | def handle_in("card:add_member", %{"card_id" => card_id, "user_id" => user_id}, socket) do 155 | try do 156 | current_board = socket.assigns.board 157 | 158 | card_member = current_board 159 | |> assoc(:cards) 160 | |> Repo.get!(card_id) 161 | |> build_assoc(:card_members) 162 | 163 | user_board = UserBoard 164 | |> UserBoard.find_by_user_and_board(user_id, current_board.id) 165 | |> Repo.one!() 166 | 167 | changeset = CardMember.changeset(card_member, %{user_board_id: user_board.id}) 168 | 169 | case Repo.insert(changeset) do 170 | {:ok, _} -> 171 | card = Card 172 | |> Card.preload_all 173 | |> Repo.get(card_id) 174 | 175 | broadcast! socket, "card:updated", %{board: get_current_board(socket), card: card} 176 | {:noreply, socket} 177 | {:error, _} -> 178 | {:reply, {:error, %{error: "Error adding new member"}}, socket} 179 | end 180 | catch 181 | _, _-> {:reply, {:error, %{error: "Member does not exist"}}, socket} 182 | end 183 | end 184 | 185 | def handle_in("card:remove_member", %{"card_id" => card_id, "user_id" => user_id}, socket) do 186 | current_board = socket.assigns.board 187 | 188 | user_board = UserBoard 189 | |> UserBoard.find_by_user_and_board(user_id, current_board.id) 190 | |> Repo.one! 191 | 192 | card_member = CardMember 193 | |> CardMember.get_by_card_and_user_board(card_id, user_board.id) 194 | |> Repo.one! 195 | 196 | case Repo.delete(card_member) do 197 | {:ok, _} -> 198 | card = Card 199 | |> Card.preload_all 200 | |> Repo.get(card_id) 201 | 202 | broadcast! socket, "card:updated", %{board: get_current_board(socket), card: card} 203 | {:noreply, socket} 204 | {:error, _changeset} -> 205 | {:reply, {:error, %{error: "Error creating comment"}}, socket} 206 | end 207 | end 208 | 209 | def terminate(_reason, socket) do 210 | board_id = Board.slug_id(socket.assigns.board) 211 | user_id = socket.assigns.current_user.id 212 | 213 | broadcast! socket, "user:left", %{users: Monitor.user_left(board_id, user_id)} 214 | 215 | :ok 216 | end 217 | 218 | defp get_current_board(socket, board_id) do 219 | socket.assigns.current_user 220 | |> assoc(:boards) 221 | |> Board.preload_all 222 | |> Repo.get(board_id) 223 | end 224 | 225 | defp get_current_board(socket), do: get_current_board(socket, socket.assigns.board.id) 226 | end 227 | -------------------------------------------------------------------------------- /web/channels/user_channel.ex: -------------------------------------------------------------------------------- 1 | defmodule PhoenixTrello.UserChannel do 2 | use PhoenixTrello.Web, :channel 3 | 4 | def join("users:" <> user_id, _params, socket) do 5 | current_user = socket.assigns.current_user 6 | 7 | if String.to_integer(user_id) == current_user.id do 8 | {:ok, socket} 9 | else 10 | {:error, %{reason: "Invalid user"}} 11 | end 12 | end 13 | end 14 | -------------------------------------------------------------------------------- /web/channels/user_socket.ex: -------------------------------------------------------------------------------- 1 | defmodule PhoenixTrello.UserSocket do 2 | use Phoenix.Socket 3 | 4 | alias PhoenixTrello.{GuardianSerializer} 5 | 6 | # Channels 7 | channel "boards:*", PhoenixTrello.BoardChannel 8 | channel "users:*", PhoenixTrello.UserChannel 9 | 10 | # Transports 11 | transport :websocket, Phoenix.Transports.WebSocket 12 | transport :longpoll, Phoenix.Transports.LongPoll 13 | 14 | def connect(%{"token" => token}, socket) do 15 | case Guardian.decode_and_verify(token) do 16 | {:ok, claims} -> 17 | case GuardianSerializer.from_token(claims["sub"]) do 18 | {:ok, user} -> 19 | {:ok, assign(socket, :current_user, user)} 20 | {:error, _reason} -> 21 | :error 22 | end 23 | {:error, _reason} -> 24 | :error 25 | end 26 | end 27 | 28 | def connect(_params, _socket), do: :error 29 | 30 | def id(socket), do: "users_socket:#{socket.assigns.current_user.id}" 31 | end 32 | -------------------------------------------------------------------------------- /web/controllers/api/v1/board_controller.ex: -------------------------------------------------------------------------------- 1 | defmodule PhoenixTrello.BoardController do 2 | use PhoenixTrello.Web, :controller 3 | 4 | plug Guardian.Plug.EnsureAuthenticated, handler: PhoenixTrello.SessionController 5 | plug :scrub_params, "board" when action in [:create] 6 | 7 | alias PhoenixTrello.{Repo, Board, UserBoard} 8 | 9 | def index(conn, _params) do 10 | current_user = Guardian.Plug.current_resource(conn) 11 | 12 | owned_boards = current_user 13 | |> assoc(:owned_boards) 14 | |> Board.preload_all 15 | |> Repo.all 16 | 17 | invited_boards = current_user 18 | |> assoc(:boards) 19 | |> Board.not_owned_by(current_user.id) 20 | |> Board.preload_all 21 | |> Repo.all 22 | 23 | render(conn, "index.json", owned_boards: owned_boards, invited_boards: invited_boards) 24 | end 25 | 26 | def create(conn, %{"board" => board_params}) do 27 | current_user = Guardian.Plug.current_resource(conn) 28 | 29 | changeset = current_user 30 | |> build_assoc(:owned_boards) 31 | |> Board.changeset(board_params) 32 | 33 | if changeset.valid? do 34 | board = Repo.insert!(changeset) 35 | 36 | board 37 | |> build_assoc(:user_boards) 38 | |> UserBoard.changeset(%{user_id: current_user.id}) 39 | |> Repo.insert! 40 | 41 | conn 42 | |> put_status(:created) 43 | |> render("show.json", board: board ) 44 | else 45 | conn 46 | |> put_status(:unprocessable_entity) 47 | |> render("error.json", changeset: changeset) 48 | end 49 | end 50 | end 51 | -------------------------------------------------------------------------------- /web/controllers/api/v1/card_controller.ex: -------------------------------------------------------------------------------- 1 | defmodule PhoenixTrello.CardController do 2 | use PhoenixTrello.Web, :controller 3 | 4 | plug Guardian.Plug.EnsureAuthenticated, handler: PhoenixTrello.SessionController 5 | 6 | alias PhoenixTrello.{Repo, Card} 7 | 8 | def show(conn, %{"board_id" => board_id, "id" => card_id}) do 9 | card = Card 10 | |> Card.get_by_user_and_board(card_id, current_user(conn).id, board_id) 11 | |> Repo.one! 12 | 13 | render(conn, "show.json", card: card) 14 | end 15 | 16 | defp current_user(conn) do 17 | Guardian.Plug.current_resource(conn) 18 | end 19 | end 20 | -------------------------------------------------------------------------------- /web/controllers/api/v1/current_user_controller.ex: -------------------------------------------------------------------------------- 1 | defmodule PhoenixTrello.CurrentUserController do 2 | use PhoenixTrello.Web, :controller 3 | 4 | plug Guardian.Plug.EnsureAuthenticated, handler: PhoenixTrello.SessionController 5 | 6 | def show(conn, _) do 7 | user = Guardian.Plug.current_resource(conn) 8 | 9 | conn 10 | |> put_status(:ok) 11 | |> render("show.json", user: user) 12 | end 13 | end 14 | -------------------------------------------------------------------------------- /web/controllers/api/v1/registration_controller.ex: -------------------------------------------------------------------------------- 1 | defmodule PhoenixTrello.RegistrationController do 2 | use PhoenixTrello.Web, :controller 3 | 4 | alias PhoenixTrello.{Repo, User} 5 | 6 | plug :scrub_params, "user" when action in [:create] 7 | 8 | def create(conn, %{"user" => user_params}) do 9 | changeset = User.changeset(%User{}, user_params) 10 | 11 | case Repo.insert(changeset) do 12 | {:ok, user} -> 13 | {:ok, jwt, _full_claims} = user |> Guardian.encode_and_sign(:token) 14 | 15 | conn 16 | |> put_status(:created) 17 | |> render(PhoenixTrello.SessionView, "show.json", jwt: jwt, user: user) 18 | 19 | {:error, changeset} -> 20 | conn 21 | |> put_status(:unprocessable_entity) 22 | |> render("error.json", changeset: changeset) 23 | end 24 | end 25 | end 26 | -------------------------------------------------------------------------------- /web/controllers/api/v1/session_controller.ex: -------------------------------------------------------------------------------- 1 | defmodule PhoenixTrello.SessionController do 2 | use PhoenixTrello.Web, :controller 3 | 4 | plug :scrub_params, "session" when action in [:create] 5 | 6 | def create(conn, %{"session" => session_params}) do 7 | case PhoenixTrello.Session.authenticate(session_params) do 8 | {:ok, user} -> 9 | {:ok, jwt, _full_claims} = user |> Guardian.encode_and_sign(:token) 10 | 11 | conn 12 | |> put_status(:created) 13 | |> render("show.json", jwt: jwt, user: user) 14 | 15 | :error -> 16 | conn 17 | |> put_status(:unprocessable_entity) 18 | |> render("error.json") 19 | end 20 | end 21 | 22 | def delete(conn, _) do 23 | {:ok, claims} = Guardian.Plug.claims(conn) 24 | 25 | conn 26 | |> Guardian.Plug.current_token 27 | |> Guardian.revoke!(claims) 28 | 29 | conn 30 | |> render("delete.json") 31 | end 32 | 33 | def unauthenticated(conn, _params) do 34 | conn 35 | |> put_status(:forbidden) 36 | |> render(PhoenixTrello.SessionView, "forbidden.json", error: "Not Authenticated") 37 | end 38 | end 39 | -------------------------------------------------------------------------------- /web/controllers/page_controller.ex: -------------------------------------------------------------------------------- 1 | defmodule PhoenixTrello.PageController do 2 | use PhoenixTrello.Web, :controller 3 | 4 | def index(conn, _params) do 5 | render conn, "index.html" 6 | end 7 | end 8 | -------------------------------------------------------------------------------- /web/helpers/session.ex: -------------------------------------------------------------------------------- 1 | defmodule PhoenixTrello.Session do 2 | alias PhoenixTrello.{Repo, User} 3 | 4 | def authenticate(%{"email" => email, "password" => password}) do 5 | user = Repo.get_by(User, email: String.downcase(email)) 6 | 7 | case check_password(user, password) do 8 | true -> {:ok, user} 9 | _ -> :error 10 | end 11 | end 12 | 13 | defp check_password(user, password) do 14 | case user do 15 | nil -> Comeonin.Bcrypt.dummy_checkpw() 16 | _ -> Comeonin.Bcrypt.checkpw(password, user.encrypted_password) 17 | end 18 | end 19 | end 20 | -------------------------------------------------------------------------------- /web/models/board.ex: -------------------------------------------------------------------------------- 1 | defmodule PhoenixTrello.Board do 2 | use PhoenixTrello.Web, :model 3 | 4 | alias __MODULE__ 5 | alias PhoenixTrello.{Repo, Permalink, List, Comment, Card, UserBoard, User} 6 | 7 | @primary_key {:id, Permalink, autogenerate: true} 8 | 9 | schema "boards" do 10 | field :name, :string 11 | field :slug, :string 12 | 13 | belongs_to :user, User 14 | has_many :lists, List 15 | has_many :cards, through: [:lists, :cards] 16 | has_many :user_boards, UserBoard 17 | has_many :members, through: [:user_boards, :user] 18 | 19 | timestamps 20 | end 21 | 22 | @required_fields ~w(name user_id) 23 | @optional_fields ~w(slug) 24 | 25 | @doc """ 26 | Creates a changeset based on the `model` and `params`. 27 | 28 | If no params are provided, an invalid changeset is returned 29 | with no validation performed. 30 | """ 31 | def changeset(model, params \\ %{}) do 32 | model 33 | |> cast(params, @required_fields, @optional_fields) 34 | |> slugify_name() 35 | end 36 | 37 | def not_owned_by(query \\ %Board{}, user_id) do 38 | from b in query, 39 | where: b.user_id != ^user_id 40 | end 41 | 42 | def preload_all(query) do 43 | comments_query = from c in Comment, order_by: [desc: c.inserted_at], preload: :user 44 | cards_query = from c in Card, order_by: c.position, preload: [[comments: ^comments_query], :members] 45 | lists_query = from l in List, order_by: l.position, preload: [cards: ^cards_query] 46 | 47 | from b in query, preload: [:user, :members, lists: ^lists_query] 48 | end 49 | 50 | def slug_id(board) do 51 | "#{board.id}-#{board.slug}" 52 | end 53 | 54 | defp slugify_name(current_changeset) do 55 | if name = get_change(current_changeset, :name) do 56 | put_change(current_changeset, :slug, slugify(name)) 57 | else 58 | current_changeset 59 | end 60 | end 61 | 62 | defp slugify(value) do 63 | value 64 | |> String.downcase() 65 | |> String.replace(~r/[^\w-]+/, "-") 66 | end 67 | end 68 | 69 | defimpl Phoenix.Param, for: Board do 70 | def to_param(%{slug: slug, id: id}) do 71 | "#{id}-#{slug}" 72 | end 73 | end 74 | 75 | defimpl Poison.Encoder, for: PhoenixTrello.Board do 76 | def encode(model, options) do 77 | model 78 | |> Map.take([:name, :lists, :user, :members]) 79 | |> Map.put(:id, PhoenixTrello.Board.slug_id(model)) 80 | |> Poison.Encoder.encode(options) 81 | end 82 | end 83 | -------------------------------------------------------------------------------- /web/models/card.ex: -------------------------------------------------------------------------------- 1 | defmodule PhoenixTrello.Card do 2 | use PhoenixTrello.Web, :model 3 | 4 | alias PhoenixTrello.{Repo, List, Card, Comment, CardMember} 5 | 6 | @derive {Poison.Encoder, only: [:id, :list_id, :name, :description, :position, :comments, :tags, :members]} 7 | 8 | schema "cards" do 9 | field :name, :string 10 | field :description, :string 11 | field :position, :integer 12 | field :tags, {:array, :string} 13 | 14 | belongs_to :list, List 15 | has_many :comments, Comment 16 | has_many :card_members, CardMember 17 | has_many :members, through: [:card_members, :user] 18 | 19 | timestamps 20 | end 21 | 22 | @required_fields ~w(name list_id) 23 | @optional_fields ~w(description position tags) 24 | 25 | @doc """ 26 | Creates a changeset based on the `model` and `params`. 27 | 28 | If no params are provided, an invalid changeset is returned 29 | with no validation performed. 30 | """ 31 | def changeset(model, params \\ %{}) do 32 | model 33 | |> cast(params, @required_fields, @optional_fields) 34 | |> calculate_position() 35 | end 36 | 37 | def update_changeset(model, params \\ %{}) do 38 | model 39 | |> cast(params, @required_fields, @optional_fields) 40 | end 41 | 42 | defp calculate_position(current_changeset) do 43 | model = current_changeset.data 44 | 45 | query = from(c in Card, 46 | select: c.position, 47 | where: c.list_id == ^(model.list_id), 48 | order_by: [desc: c.position], 49 | limit: 1) 50 | 51 | case Repo.one(query) do 52 | nil -> put_change(current_changeset, :position, 1024) 53 | position -> put_change(current_changeset, :position, position + 1024) 54 | end 55 | end 56 | 57 | def preload_all(query \\ %Card{}) do 58 | comments_query = from c in Comment, order_by: [desc: c.inserted_at], preload: :user 59 | 60 | from c in query, preload: [:members, [comments: ^comments_query]] 61 | end 62 | 63 | def get_by_user_and_board(query \\ %Card{}, card_id, user_id, board_id) do 64 | from c in query, 65 | left_join: co in assoc(c, :comments), 66 | left_join: cu in assoc(co, :user), 67 | left_join: me in assoc(c, :members), 68 | join: l in assoc(c, :list), 69 | join: b in assoc(l, :board), 70 | join: ub in assoc(b, :user_boards), 71 | where: ub.user_id == ^user_id and b.id == ^board_id and c.id == ^card_id, 72 | preload: [comments: {co, user: cu }, members: me] 73 | end 74 | end 75 | -------------------------------------------------------------------------------- /web/models/card_member.ex: -------------------------------------------------------------------------------- 1 | defmodule PhoenixTrello.CardMember do 2 | use PhoenixTrello.Web, :model 3 | 4 | alias __MODULE__ 5 | 6 | schema "card_members" do 7 | belongs_to :card, PhoenixTrello.Card 8 | belongs_to :user_board, PhoenixTrello.UserBoard 9 | has_one :user, through: [:user_board, :user] 10 | 11 | timestamps 12 | end 13 | 14 | @required_fields ~w(card_id user_board_id) 15 | @optional_fields ~w() 16 | 17 | @doc """ 18 | Creates a changeset based on the `model` and `params`. 19 | 20 | If no params are provided, an invalid changeset is returned 21 | with no validation performed. 22 | """ 23 | def changeset(model, params \\ %{}) do 24 | model 25 | |> cast(params, @required_fields, @optional_fields) 26 | |> unique_constraint(:user_board_id, name: :card_members_card_id_user_board_id_index) 27 | end 28 | 29 | def get_by_card_and_user_board(query \\ %CardMember{}, card_id, user_board_id) do 30 | from cm in query, 31 | where: cm.card_id == ^card_id and cm.user_board_id == ^user_board_id, 32 | limit: 1 33 | end 34 | end 35 | -------------------------------------------------------------------------------- /web/models/comment.ex: -------------------------------------------------------------------------------- 1 | defmodule PhoenixTrello.Comment do 2 | use PhoenixTrello.Web, :model 3 | 4 | @derive {Poison.Encoder, only: [:id, :user, :card_id, :text, :inserted_at]} 5 | 6 | schema "comments" do 7 | field :text, :string 8 | 9 | belongs_to :user, PhoenixTrello.User 10 | belongs_to :card, PhoenixTrello.Card 11 | 12 | timestamps 13 | end 14 | 15 | @required_fields ~w(user_id card_id text) 16 | @optional_fields ~w() 17 | 18 | @doc """ 19 | Creates a changeset based on the `model` and `params`. 20 | 21 | If no params are provided, an invalid changeset is returned 22 | with no validation performed. 23 | """ 24 | def changeset(model, params \\ %{}) do 25 | model 26 | |> cast(params, @required_fields, @optional_fields) 27 | end 28 | end 29 | -------------------------------------------------------------------------------- /web/models/list.ex: -------------------------------------------------------------------------------- 1 | defmodule PhoenixTrello.List do 2 | use PhoenixTrello.Web, :model 3 | 4 | alias PhoenixTrello.{Board, Repo, List, Card} 5 | 6 | @derive {Poison.Encoder, only: [:id, :board_id, :name, :position, :cards]} 7 | 8 | schema "lists" do 9 | field :name, :string 10 | field :position, :integer 11 | 12 | belongs_to :board, Board 13 | has_many :cards, Card 14 | 15 | timestamps 16 | end 17 | 18 | @required_fields ~w(name) 19 | @optional_fields ~w(position) 20 | 21 | @doc """ 22 | Creates a changeset based on the `model` and `params`. 23 | 24 | If no params are provided, an invalid changeset is returned 25 | with no validation performed. 26 | """ 27 | def changeset(model, params \\ %{}) do 28 | model 29 | |> cast(params, @required_fields, @optional_fields) 30 | |> calculate_position() 31 | end 32 | 33 | def update_changeset(model, params \\ %{}) do 34 | model 35 | |> cast(params, @required_fields, @optional_fields) 36 | end 37 | 38 | defp calculate_position(current_changeset) do 39 | model = current_changeset.data 40 | 41 | query = from(l in List, 42 | select: l.position, 43 | where: l.board_id == ^(model.board_id), 44 | order_by: [desc: l.position], 45 | limit: 1) 46 | 47 | case Repo.one(query) do 48 | nil -> put_change(current_changeset, :position, 1024) 49 | position -> put_change(current_changeset, :position, position + 1024) 50 | end 51 | end 52 | end 53 | -------------------------------------------------------------------------------- /web/models/user.ex: -------------------------------------------------------------------------------- 1 | defmodule PhoenixTrello.User do 2 | use PhoenixTrello.Web, :model 3 | 4 | alias PhoenixTrello.{Board, UserBoard} 5 | 6 | @derive {Poison.Encoder, only: [:id, :first_name, :last_name, :email]} 7 | 8 | schema "users" do 9 | field :first_name, :string 10 | field :last_name, :string 11 | field :email, :string 12 | field :encrypted_password, :string 13 | field :password, :string, virtual: true 14 | 15 | has_many :owned_boards, Board 16 | has_many :user_boards, UserBoard 17 | has_many :boards, through: [:user_boards, :board] 18 | 19 | timestamps 20 | end 21 | 22 | @required_fields ~w(first_name last_name email password) 23 | @optional_fields ~w(encrypted_password) 24 | 25 | @doc """ 26 | Creates a changeset based on the `model` and `params`. 27 | 28 | If no params are provided, an invalid changeset is returned 29 | with no validation performed. 30 | """ 31 | def changeset(model, params \\ %{}) do 32 | model 33 | |> cast(params, @required_fields, @optional_fields) 34 | |> validate_format(:email, ~r/@/) 35 | |> validate_length(:password, min: 5) 36 | |> validate_confirmation(:password, message: "Password does not match") 37 | |> unique_constraint(:email, message: "Email already taken") 38 | |> generate_encrypted_password 39 | end 40 | 41 | defp generate_encrypted_password(current_changeset) do 42 | case current_changeset do 43 | %Ecto.Changeset{valid?: true, changes: %{password: password}} -> 44 | put_change(current_changeset, :encrypted_password, Comeonin.Bcrypt.hashpwsalt(password)) 45 | _ -> 46 | current_changeset 47 | end 48 | end 49 | end 50 | -------------------------------------------------------------------------------- /web/models/user_board.ex: -------------------------------------------------------------------------------- 1 | defmodule PhoenixTrello.UserBoard do 2 | use PhoenixTrello.Web, :model 3 | 4 | alias __MODULE__ 5 | alias PhoenixTrello.{User, Board} 6 | 7 | schema "user_boards" do 8 | belongs_to :user, User 9 | belongs_to :board, Board 10 | 11 | timestamps 12 | end 13 | 14 | @required_fields ~w(user_id board_id) 15 | @optional_fields ~w() 16 | 17 | @doc """ 18 | Creates a changeset based on the `model` and `params`. 19 | 20 | If no params are provided, an invalid changeset is returned 21 | with no validation performed. 22 | """ 23 | def changeset(model, params \\ %{}) do 24 | model 25 | |> cast(params, @required_fields, @optional_fields) 26 | |> unique_constraint(:user_id, name: :user_boards_user_id_board_id_index) 27 | end 28 | 29 | def find_by_user_and_board(query \\ %UserBoard{}, user_id, board_id) do 30 | from u in query, 31 | where: u.user_id == ^user_id and u.board_id == ^board_id 32 | end 33 | end 34 | -------------------------------------------------------------------------------- /web/router.ex: -------------------------------------------------------------------------------- 1 | defmodule PhoenixTrello.Router do 2 | use PhoenixTrello.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 | end 11 | 12 | pipeline :api do 13 | plug :accepts, ["json"] 14 | plug Guardian.Plug.VerifyHeader 15 | plug Guardian.Plug.LoadResource 16 | end 17 | 18 | scope "/api", PhoenixTrello do 19 | pipe_through :api 20 | 21 | scope "/v1" do 22 | post "/registrations", RegistrationController, :create 23 | 24 | post "/sessions", SessionController, :create 25 | delete "/sessions", SessionController, :delete 26 | 27 | get "/current_user", CurrentUserController, :show 28 | 29 | resources "/boards", BoardController, only: [:index, :create] do 30 | resources "/cards", CardController, only: [:show] 31 | end 32 | end 33 | end 34 | 35 | scope "/", PhoenixTrello do 36 | pipe_through :browser # Use the default browser stack 37 | 38 | get "/*path", PageController, :index 39 | end 40 | end 41 | -------------------------------------------------------------------------------- /web/static/css/application.sass: -------------------------------------------------------------------------------- 1 | // - - css-burrito v1.5 | mit license | github.com/jasonreece/css-burrito 2 | 3 | // - - - - - - - - - - - - - - - - - - - 4 | // - - application.scss 5 | // contains an import section for libs, global, and modules, the inbox, 6 | // and a shame section for quick fixes and hacks. 7 | 8 | // - - - - - - - - - - - - - - - - - - - 9 | // - - libs 10 | // add css frameworks and libraries here. 11 | // be sure to load them after the variable overrides file 12 | // if you want to change variable values. 13 | 14 | // - - variable overrides for sass libraries 15 | // @import "libs/library-variable-overrides"; 16 | 17 | // - - reset - normalize.css v3.0.2 18 | @import libs/normalize 19 | 20 | @import ../../../node_modules/bourbon/app/assets/stylesheets/bourbon 21 | @import ../../../node_modules/bourbon-neat/app/assets/stylesheets/neat-helpers 22 | @import global/grid_settings 23 | @import ../../../node_modules/bourbon-neat/app/assets/stylesheets/neat 24 | 25 | // - - - - - - - - - - - - - - - - - - - 26 | // - - global 27 | 28 | // - - global/settings 29 | // @font-face declarations, variables 30 | @import global/settings 31 | 32 | // - - global/utilities 33 | // extends, functions, and mixins 34 | @import global/utilities 35 | 36 | // - - global/base 37 | // base-level tags (body, p, etc.) 38 | @import global/base 39 | 40 | // - - global/layout 41 | // margin, padding, sizing 42 | @import global/layout 43 | 44 | // - - global/skin 45 | // backgrounds, borders, box-shadow, etc 46 | @import global/skin 47 | 48 | // - - global/typography 49 | // fonts and colors 50 | @import global/typography 51 | 52 | // - - - - - - - - - - - - - - - - - - - 53 | // - - modules 54 | // add new modules to the modules/_modules.scss file and they'll get pulled in here. 55 | @import modules/modules 56 | 57 | // - - - - - - - - - - - - - - - - - - - 58 | // - - inbox 59 | // the inbox allows developers, and those not actively working on the project 60 | // to quickly add styles that are easily seen by the maintainer of the file. 61 | 62 | // - - - - - - - - - - - - - - - - - - - 63 | // - - shame 64 | // need to add a quick fix, hack, or questionable technique? add it here, fix it later. 65 | -------------------------------------------------------------------------------- /web/static/css/global/_base.sass: -------------------------------------------------------------------------------- 1 | // - - - - - - - - - - - - - - - - - - - 2 | // - - base 3 | // project defaults for base elements - h1-h6, p, a, etc. 4 | 5 | 6 | h1, h2, h3 7 | color: #fff 8 | -------------------------------------------------------------------------------- /web/static/css/global/_grid_settings.sass: -------------------------------------------------------------------------------- 1 | // Neat Overrides 2 | /////////////////////////////////////////////////////////////////////////////// 3 | // $column: 90px; 4 | // $gutter: 30px; 5 | // $grid-columns: 12; 6 | // $max-width: em(1088); 7 | 8 | // Neat Breakpoints 9 | /////////////////////////////////////////////////////////////////////////////// 10 | $medium-screen: em(1024) 11 | $large-screen: em(1366) 12 | $x-large-screen: em(1920) 13 | 14 | $small-screen-up: new-breakpoint(max-width ($medium-screen - em(1))) 15 | $medium-screen-up: new-breakpoint(min-width $medium-screen max-width ($large-screen - em(1))) 16 | $large-screen-up: new-breakpoint(min-width $large-screen max-width ($x-large-screen - em(1))) 17 | $x-large-screen-up: new-breakpoint(min-width $x-large-screen) 18 | -------------------------------------------------------------------------------- /web/static/css/global/_layout.sass: -------------------------------------------------------------------------------- 1 | // - - - - - - - - - - - - - - - - - - - 2 | // - - layout 3 | // global layout classes - height, width, padding, margin, etc. 4 | 5 | html 6 | height: 100vh 7 | body 8 | height: 100vh 9 | 10 | #main_container 11 | height: calc(100% - 40px) 12 | > div 13 | height: 100% 14 | 15 | #main_footer 16 | text-align: center 17 | small 18 | line-height: 40px 19 | color: rgba(#fff, .5) 20 | 21 | a 22 | +transition 23 | color: $gray 24 | &:hover 25 | color: #fff 26 | 27 | 28 | .application-container 29 | height: 100vh 30 | 31 | 32 | .main-container 33 | +display(flex) 34 | height: calc(100% - 65px) 35 | 36 | .view-container 37 | flex: 1 38 | padding: 0 $base-spacing 39 | height: calc(100% - 3em) 40 | 41 | .view-header 42 | padding: $base-spacing/2 0 43 | 44 | h3 45 | margin-bottom: 0 46 | -------------------------------------------------------------------------------- /web/static/css/global/_settings.sass: -------------------------------------------------------------------------------- 1 | // - - - - - - - - - - - - - - - - - - - 2 | // - - settings 3 | // @font-face declarations, variables 4 | 5 | // - - - - - - - - - - - - - - - - - - - 6 | // - - @font-face 7 | // add fonts to project as needed by updating the $fonts map below in the following format - 8 | // 'font-family-name': 'font-weight' 9 | 10 | // $fonts: ( 11 | // 'font-family-one': ('font-one-light', 'font-one-dark'), 12 | // 'font-family-two': ('font-two-light','font-two-medium') 13 | // ) 14 | 15 | // @each $font-family, $font-weights in $fonts { 16 | // @each $font-weight in $font-weights { 17 | // @font-face { 18 | // name: $font-weight 19 | // src: 20 | // url('/path/to/fonts/#{$font-family}/#{$font-weight}.eot?#iefix') format('embedded-opentype'), 21 | // url('/path/to/fonts/#{$font-family}/#{$font-weight}.woff') format('woff'), 22 | // url('/path/to/fonts/#{$font-family}/#{$font-weight}.ttf') format('truetype'), 23 | // url('/path/to/fonts/#{$font-family}/#{$font-weight}.svg##{$font-weight}') format('svg') 24 | // } 25 | // } 26 | // } 27 | 28 | // - - - - - - - - - - - - - - - - - - - 29 | // - - variables 30 | $base-line-height: 1.5 31 | $heading-line-height: 1.2em 32 | $base-border-radius: 3px 33 | $base-spacing: $base-line-height * 1em 34 | $small-spacing: $base-spacing / 2 35 | $base-z-index: 0 36 | 37 | // - - colors 38 | $blue: #3498db 39 | $green: #2ecc71 40 | $red: #e74c3c 41 | $yellow: #f1c40f 42 | $orange: #e67e22 43 | $purple: #89609E 44 | $gray: #bdc3c7 45 | $asphalt: #34495e 46 | $pink: #ff5aec 47 | 48 | $dark-blue: shade($blue, 15) 49 | $dark-purple: shade($purple, 15) 50 | $light-gray: tint($gray, 50) 51 | $lighter-gray: tint($gray, 20) 52 | $medium-gray: shade($gray, 10) 53 | $dark-gray: shade($gray, 60) 54 | $light-red: tint($red, 15) 55 | $light-yellow: tint($yellow, 15) 56 | $light-green: tint($green, 15) 57 | $dark-asphalt: shade($asphalt, 7) 58 | $light-asphalt: tint($asphalt, 30) 59 | $light-blue: tint($blue, 20) 60 | $dark-orange: shade($orange, 15) 61 | $dark-red: shade($red, 15) 62 | $light-pink: tint($pink, 31) 63 | 64 | $base-font-color: $dark-gray 65 | $action-color: $blue 66 | 67 | $base-body-color: $light-gray 68 | 69 | $base-link-color: $blue 70 | $hover-link-color: shade($base-link-color, 15) 71 | 72 | $base-border-color: $light-gray 73 | $base-border: 1px solid $base-border-color 74 | 75 | $base-background-color: #fff 76 | $secondary-background-color: tint($base-border-color, 75%) 77 | 78 | $form-box-shadow: none 79 | $form-box-shadow-focus: none 80 | 81 | // - - animations 82 | $base-duration: 150ms 83 | $base-timing: ease 84 | 85 | // - - typography 86 | $base-font-family: "Source Sans Pro", sans-serif 87 | $heading-font-family: 'Open Sans', sans-serif 88 | $base-font-size: 1em 89 | 90 | $tags-colors: (green: #61bd4f, yellow: #f2d600, orange: #ffab4a, red: #eb5a46, purple: #c377e0, blue: #0079bf) 91 | 92 | // - - grid 93 | 94 | +keyframes(fadeIn) 95 | from 96 | opacity: 0 97 | to 98 | opacity: 1 99 | -------------------------------------------------------------------------------- /web/static/css/global/_skin.sass: -------------------------------------------------------------------------------- 1 | // - - - - - - - - - - - - - - - - - - - 2 | // - - skin 3 | // global skin styles - gradients, colors, box-shadows, etc. 4 | 5 | body 6 | background: $purple 7 | 8 | 9 | 10 | @each $key, $color in $tags-colors 11 | .tag.#{$key} 12 | background: $color 13 | &:hover, &.selected 14 | box-shadow: -8px 0 shade($color, 20) 15 | -------------------------------------------------------------------------------- /web/static/css/global/_typography.sass: -------------------------------------------------------------------------------- 1 | // - - - - - - - - - - - - - - - - - - - 2 | // - - typography 3 | // global typography styles 4 | body 5 | color: $base-font-color 6 | font-family: $base-font-family 7 | font-size: $base-font-size 8 | line-height: $base-line-height 9 | 10 | 11 | h1, 12 | h2, 13 | h3, 14 | h4, 15 | h5, 16 | h6 17 | font-family: $heading-font-family 18 | line-height: $heading-line-height 19 | margin: 0 0 $small-spacing 20 | 21 | 22 | p 23 | margin: 0 0 $small-spacing 24 | 25 | 26 | a 27 | color: $action-color 28 | text-decoration: none 29 | transition: color $base-duration $base-timing 30 | 31 | &:active, 32 | &:focus, 33 | &:hover 34 | color: shade($action-color, 25%) 35 | 36 | 37 | 38 | hr 39 | border-bottom: $base-border 40 | border-left: 0 41 | border-right: 0 42 | border-top: 0 43 | margin: $base-spacing 0 44 | 45 | 46 | img, 47 | picture 48 | margin: 0 49 | max-width: 100% 50 | -------------------------------------------------------------------------------- /web/static/css/global/_utilities.sass: -------------------------------------------------------------------------------- 1 | // - - - - - - - - - - - - - - - - - - - 2 | // - - utilities 3 | // placeholders extends, mixins, functions, and utility classes 4 | 5 | // - - - - - - - - - - - - - - - - - - - 6 | // - - placeholder extends 7 | 8 | // - - - - - - - - - - - - - - - - - - - 9 | // - - mixins 10 | 11 | // - - clearfix 12 | // @mixin clearfix { 13 | // &:before, 14 | // &:after { 15 | // content: ' '; 16 | // display: table; 17 | // } 18 | // &:after { 19 | // clear: both; 20 | // } 21 | // & { 22 | // *zoom: 1; 23 | // } 24 | // } 25 | 26 | // - - breakpoint 27 | // adds responsive breakpoints. 28 | // @mixin breakpoint($width) { 29 | // @media (min-width: $width) { 30 | // @content; 31 | // } 32 | // } 33 | 34 | // - - attention 35 | // adds accessibility pseudo selectors to hover states. 36 | // @mixin attention() { 37 | // &:hover, 38 | // &:active, 39 | // &:focus { 40 | // @content; 41 | // } 42 | // } 43 | 44 | // - - - - - - - - - - - - - - - - - - - 45 | // - - functions 46 | 47 | // - - - - - - - - - - - - - - - - - - - 48 | // - - utilities 49 | @each $color in (purple, orange, red, green) 50 | .#{$color} 51 | color: $color 52 | -------------------------------------------------------------------------------- /web/static/css/libs/_library-variable-overrides.sass: -------------------------------------------------------------------------------- 1 | // - - - - - - - - - - - - - - - - - - - 2 | // - - library variable overrides 3 | // variable overrides for css frameworks and libraries 4 | -------------------------------------------------------------------------------- /web/static/css/modules/_boards.sass: -------------------------------------------------------------------------------- 1 | .boards.index 2 | overflow-y: scroll 3 | 4 | .boards-wrapper 5 | $margin: 1em 6 | 7 | +display(flex) 8 | +flex-wrap(wrap) 9 | +flex-flow(row wrap) 10 | margin: 0 (-$margin/2) $margin (-$margin/2) 11 | 12 | .board 13 | +transition 14 | +flex(0 0 25%) 15 | cursor: pointer 16 | 17 | .inner 18 | background: rgba($light-gray, .9) 19 | border-radius: $base-border-radius 20 | margin: 0 $margin/2 $margin $margin/2 21 | line-height: 1.75em 22 | overflow: hidden 23 | padding: $base-spacing/2 24 | min-height: 5em 25 | animation-duration: .3s 26 | animation-name: fadeIn 27 | 28 | 29 | &.form 30 | input 31 | +fill-parent 32 | margin-bottom: .5em 33 | 34 | &.add-new 35 | .inner 36 | +transition 37 | background: rgba(#000, .1) 38 | cursor: pointer 39 | color: $gray 40 | 41 | &:hover 42 | background: rgba(#000, .2) 43 | 44 | .inner 45 | position: relative 46 | 47 | a 48 | +transition 49 | +transform(translate(-50%, -50%)) 50 | display: block 51 | position: absolute 52 | top: 50% 53 | left: 50% 54 | color: $gray 55 | 56 | .boards.show 57 | .fa-spin 58 | color: #fff 59 | .view-header 60 | position: relative 61 | 62 | h3 63 | margin: 0 64 | color: #fff 65 | 66 | .board-users 67 | +transform(translateY(-50%)) 68 | list-style: none 69 | margin: 0 70 | padding: 0 71 | position: absolute 72 | right: 0 73 | top: 50% 74 | z-index: 99 75 | 76 | > span > li 77 | display: inline-block 78 | margin-left: .5em 79 | 80 | &.connected 81 | opacity: 1 82 | 83 | .react-gravatar 84 | opacity: 1 85 | 86 | .react-gravatar, .add-new 87 | +transition 88 | border-radius: $base-border-radius 89 | width: 30px 90 | height: 30px 91 | opacity: .2 92 | 93 | .add-new 94 | +transition 95 | opacity: 1 96 | display: block 97 | position: relative 98 | border: 1px solid rgba(#fff, .5) 99 | color: rgba(#fff, .5) 100 | line-height: 1em 101 | font-size: .9em 102 | 103 | .fa 104 | +transform(translate(-50%, -50%)) 105 | position: absolute 106 | top: 50% 107 | left: 50% 108 | 109 | &:hover 110 | color: #fff 111 | border-color: #fff 112 | 113 | .drop-down 114 | +transition 115 | position: absolute 116 | right: 0 117 | top: 100% 118 | list-style: none 119 | padding: $base-spacing/2 120 | background: rgba($light-gray, .9) 121 | border-radius: $base-border-radius 122 | min-width: 20em 123 | display: none 124 | animation-duration: .3s 125 | animation-name: fadeIn 126 | 127 | &.active 128 | display: block 129 | 130 | form 131 | input 132 | +fill-parent 133 | margin-bottom: .5em 134 | 135 | .avatar-appear 136 | opacity: 0.01 137 | 138 | .avatar-appear.avatar-appear-active 139 | +transition(opacity .5s ease-in) 140 | opacity: 1 141 | 142 | .avatar-enter 143 | opacity: 0.01 144 | 145 | .avatar-enter.avatar-enter-active 146 | +transition(opacity 500ms ease-in) 147 | opacity: 1 148 | 149 | .avatar-leave 150 | opacity: 1 151 | 152 | .avatar-leave.avatar-leave-active 153 | +transition(opacity 300ms ease-in) 154 | opacity: 0.01 155 | 156 | .canvas-wrapper 157 | position: relative 158 | height: calc(100% - 1em) 159 | 160 | .canvas 161 | position: absolute 162 | top: 0 163 | bottom: 0 164 | left: 0 165 | right: 0 166 | overflow-x: auto 167 | overflow-y: hidden 168 | white-space: nowrap 169 | 170 | 171 | .lists-wrapper 172 | $margin: 1em 173 | +clearfix 174 | margin: 0 175 | +display(flex) 176 | 177 | .list 178 | +flex(0 0 17em) 179 | margin: 0 $margin/1.5 0 0 180 | animation-duration: .3s 181 | animation-name: fadeIn 182 | 183 | > .inner 184 | background: rgba($light-gray, .9) 185 | border-radius: $base-border-radius 186 | padding: $base-spacing/2 187 | cursor: -webkit-grab 188 | cursor: -moz-grab 189 | cursor: grab 190 | 191 | .list.form 192 | +flex(none) 193 | +fill-parent 194 | display: block 195 | margin: 0 0 $base-spacing 0 196 | 197 | .inner 198 | padding: 0 199 | 200 | header 201 | +fill-parent 202 | cursor: pointer 203 | text-overflow: ellipsis 204 | white-space: nowrap 205 | overflow: hidden 206 | 207 | &.form 208 | display: inline-block 209 | input 210 | +fill-parent 211 | display: block 212 | margin-bottom: .5em 213 | 214 | &:not(.add-new) 215 | .inner .inner 216 | background: transparent 217 | 218 | &.add-new 219 | .inner 220 | +transition 221 | background: rgba(#000, .1) 222 | cursor: pointer 223 | color: $gray 224 | 225 | &:hover 226 | background: rgba(#000, .2) 227 | footer 228 | .add-new 229 | +transition 230 | display: block 231 | margin: -($base-spacing/2) 232 | margin-top: $base-spacing/2 233 | padding: .5em $base-spacing/2 234 | color: tint($dark-gray, 25) 235 | border-radius: 0 0 $base-border-radius $base-border-radius 236 | 237 | &:hover 238 | background: rgba($gray, .9) 239 | color: $dark-gray 240 | 241 | .card:not(.form) 242 | +transition 243 | margin-bottom: $base-spacing/3 244 | cursor: pointer 245 | animation-duration: .3s 246 | animation-name: fadeIn 247 | white-space: normal 248 | 249 | &.is-over 250 | padding-top: 50px 251 | position: relative 252 | 253 | :before 254 | background: rgba($dark-gray, .1) 255 | border-radius: $base-border-radius 256 | content: "" 257 | display: block 258 | height: 40px 259 | width: 100% 260 | position: absolute 261 | top: 0 262 | left: 0 263 | margin-bottom: $base-spacing/3 264 | 265 | .card-content 266 | background: #fff 267 | padding: $base-spacing/3 268 | border-radius: $base-border-radius 269 | border-bottom: 1px solid $gray 270 | 271 | .tags-wrapper 272 | +clearfix 273 | 274 | .tag 275 | +span-columns(2) 276 | animation-duration: .3s 277 | animation-name: fadeIn 278 | height: .5em 279 | border-radius: $base-border-radius 280 | &:hover 281 | box-shadow: 0 0 0 282 | 283 | .react-gravatar 284 | border-radius: $base-border-radius 285 | width: 30px 286 | height: 30px 287 | float: right 288 | margin-left: 5px 289 | animation-duration: .3s 290 | animation-name: fadeIn 291 | 292 | &:hover 293 | background: rgba($gray, .4) 294 | 295 | footer 296 | +clearfix 297 | small 298 | color: tint($dark-gray, 35) 299 | 300 | .card.form 301 | textarea 302 | +fill-parent 303 | animation-duration: .3s 304 | animation-name: fadeIn 305 | display: block 306 | margin-bottom: .5em 307 | border: 0px none 308 | border-bottom: 1px solid $gray 309 | -------------------------------------------------------------------------------- /web/static/css/modules/_example-module.sass: -------------------------------------------------------------------------------- 1 | // - - - - - - - - - - - - - - - - - - - 2 | // - - example-module module 3 | // styles for the example-module module 4 | -------------------------------------------------------------------------------- /web/static/css/modules/_forms.sass: -------------------------------------------------------------------------------- 1 | form, .form 2 | [type=text], [type=password], [type=date], [type=email], select, .selectize-input, textarea, [type=number], button 3 | border: 1px solid $gray 4 | padding: .75em 5 | border-radius: $base-border-radius 6 | box-shadow: none 7 | line-height: 1.2em 8 | 9 | &:focus 10 | box-shadow: none 11 | outline: none 12 | 13 | .error 14 | +animation(fadeIn .3s) 15 | color: $red 16 | -------------------------------------------------------------------------------- /web/static/css/modules/_main_header.sass: -------------------------------------------------------------------------------- 1 | $header-height: $base-line-height * 1.8em 2 | 3 | .main-header 4 | +clearfix 5 | background: $dark-purple 6 | line-height: $header-height 7 | padding: 0 $base-spacing 8 | color: #fff 9 | position: relative 10 | 11 | .logo 12 | +transform(translate(-50%, -50%)) 13 | display: inline-block 14 | background: url(/images/logo.png) 0 0 no-repeat 15 | background-size: contain 16 | height: 32px 17 | width: 110px 18 | position: absolute 19 | top: 50% 20 | left: 50% 21 | 22 | 23 | nav 24 | float: left 25 | margin: 0 26 | padding: 0 27 | 28 | &.right 29 | float: right 30 | 31 | ul 32 | list-style: none 33 | display: inline-block 34 | margin: 0 35 | -webkit-margin-before: 0 36 | -webkit-padding-start: 0 37 | li 38 | display: inline-block 39 | 40 | a 41 | +transition 42 | color: rgba(#fff, .5) 43 | background: rgba(#fff, .15) 44 | border-radius: $base-border-radius 45 | padding: .3em .5em 46 | line-height: 1em 47 | 48 | &.current-user 49 | position: relative 50 | overflow: hidden 51 | padding-left: 35px 52 | margin-right: .5em 53 | 54 | .react-gravatar 55 | width: 30px 56 | height: 30px 57 | border-radius: $base-border-radius 58 | margin-right: .5em 59 | position: absolute 60 | top: 50% 61 | margin-top: -15px 62 | left: 0 63 | 64 | 65 | 66 | .fa 67 | color: #fff 68 | 69 | &:hover 70 | color: #fff 71 | 72 | &#boards_nav 73 | > ul > li 74 | position: relative 75 | 76 | a 77 | display: block 78 | 79 | .dropdown 80 | position: absolute 81 | display: block 82 | z-index: 99 83 | background: #fff 84 | border-radius: $base-border-radius 85 | left: 0 86 | margin-top: .5em 87 | color: $base-font-color 88 | width: 15em 89 | padding: .3em 90 | box-shadow: 0 3px 6px rgba(0,0,0,.4) 91 | animation-duration: .3s 92 | animation-name: fadeIn 93 | 94 | header 95 | padding: 0 .5em 96 | color: $gray 97 | 98 | ul 99 | display: block 100 | position: relative 101 | max-height: 15em 102 | overflow-y: auto 103 | 104 | li 105 | display: block 106 | margin-bottom: .3em 107 | 108 | a 109 | +transition 110 | color: $base-font-color 111 | font-weight: bold 112 | display: block 113 | padding: .7em .5em 114 | background: rgba($purple, .2) 115 | 116 | &:hover 117 | background: rgba($purple, .5) 118 | 119 | &.options 120 | margin-top: 1em 121 | li 122 | a 123 | background: #fff 124 | text-decoration: underline 125 | font-weight: normal 126 | color: $dark-gray 127 | 128 | &:hover 129 | background: $light-gray 130 | -------------------------------------------------------------------------------- /web/static/css/modules/_members_selector.sass: -------------------------------------------------------------------------------- 1 | .members-selector, .tags-selector 2 | background: #fff 3 | border: 1px solid $gray 4 | border-radius: $base-border-radius 5 | position: absolute 6 | padding: $base-spacing/2 7 | width: 18em 8 | animation-duration: .3s 9 | animation-name: fadeIn 10 | 11 | header 12 | border-bottom: 1px solid $light-gray 13 | margin-bottom: $base-spacing/2 14 | text-align: center 15 | position: relative 16 | padding-bottom: .5em 17 | 18 | > .close 19 | position: absolute 20 | right: 0 21 | top: 0 22 | line-height: 1em 23 | color: tint($dark-gray, 30) 24 | 25 | ul 26 | list-style: none 27 | padding: 0 28 | margin: 0 29 | 30 | li 31 | margin-bottom: 3px 32 | 33 | a 34 | +transition 35 | border-radius: $base-border-radius 36 | padding: 3px 37 | display: block 38 | position: relative 39 | color: $base-font-color 40 | 41 | &.tag 42 | height: 2em 43 | 44 | .fa 45 | color: #fff 46 | 47 | @each $key, $color in $tags-colors 48 | &.tag.#{$key} 49 | &:hover, &.selected 50 | box-shadow: -8px 0 shade($color, 20) 51 | 52 | .fa 53 | +transform(translateY(-50%)) 54 | position: absolute 55 | right: $base-spacing/3 56 | top: 50% 57 | 58 | &:not(.tag):hover 59 | background: $blue 60 | color: #fff 61 | 62 | .react-gravatar 63 | border-radius: $base-border-radius 64 | width: 30px 65 | height: 30px 66 | vertical-align: middle 67 | margin-right: .5em 68 | -------------------------------------------------------------------------------- /web/static/css/modules/_modals.sass: -------------------------------------------------------------------------------- 1 | .md-overlay 2 | position: fixed 3 | width: 100% 4 | height: 100% 5 | top: 0 6 | left: 0 7 | z-index: 1000 8 | background: rgba(#333, .7) 9 | overflow-y: scroll 10 | animation-duration: .3s 11 | animation-name: fadeIn 12 | 13 | .md-modal 14 | +backface-visibility(hidden) 15 | position: fixed 16 | pointer-events: none 17 | z-index: 2000 18 | top: 0 19 | left: 0 20 | right: 0 21 | bottom: 0 22 | overflow-y: scroll 23 | 24 | .md-content 25 | $width: 700px 26 | +pad($base-spacing) 27 | border-radius: $base-border-radius 28 | background: $light-gray 29 | position: absolute 30 | pointer-events: all 31 | width: $width 32 | top: $base-spacing*2 33 | left: 50% 34 | margin-left: -$width/2 35 | animation-duration: .3s 36 | animation-name: fadeIn 37 | 38 | > header 39 | margin-bottom: 1em 40 | 41 | 42 | .card-modal 43 | +clearfix 44 | position: relative 45 | 46 | .react-gravatar 47 | border-radius: $base-border-radius 48 | width: 30px 49 | height: 30px 50 | 51 | > .close 52 | position: absolute 53 | right: $base-spacing/2 54 | top: $base-spacing/2 55 | line-height: 1em 56 | color: tint($dark-gray, 30) 57 | 58 | &:hover 59 | color: $dark-gray 60 | 61 | .info 62 | +span-columns(9) 63 | 64 | .options 65 | +span-columns(3) 66 | position: relative 67 | overflow: visible 68 | 69 | .button 70 | +fill-parent 71 | +transition 72 | background: #fff 73 | padding: .3em .5em 74 | border-radius: $base-border-radius 75 | border-bottom: 1px solid $gray 76 | display: block 77 | color: $base-font-color 78 | font-weight: bold 79 | margin-bottom: $base-spacing/3 80 | 81 | .fa 82 | color: $gray 83 | margin-right: .5em 84 | 85 | &:hover 86 | background: $blue 87 | color: #fff 88 | 89 | .fa 90 | color: #fff 91 | 92 | header 93 | h3 94 | color: $base-font-color 95 | 96 | 97 | input, textarea 98 | +fill-parent 99 | display: block 100 | margin-bottom: .5em 101 | border: 0px none 102 | border-bottom: 1px solid $gray 103 | 104 | .form-wrapper 105 | +clearfix 106 | margin: $base-spacing 0 107 | 108 | .gravatar-wrapper 109 | +span-columns(1) 110 | .react-gravatar 111 | border-radius: $base-border-radius 112 | width: 30px 113 | height: 30px 114 | 115 | .form-controls 116 | +span-columns(11) 117 | 118 | .comment 119 | +clearfix 120 | margin-bottom: $base-spacing/2 121 | animation-duration: .3s 122 | animation-name: fadeIn 123 | 124 | .gravatar-wrapper 125 | +span-columns(1) 126 | 127 | 128 | .info-wrapper 129 | +span-columns(11) 130 | h5 131 | margin-bottom: .5em 132 | .text 133 | background: #fff 134 | border-radius: $base-border-radius 135 | padding: .5em 136 | border: 0px none 137 | border-bottom: 1px solid $gray 138 | 139 | .items-wrapper 140 | +clearfix 141 | 142 | .card-members 143 | +span-columns(6) 144 | margin-bottom: $base-spacing 145 | animation-duration: .3s 146 | animation-name: fadeIn 147 | .react-gravatar 148 | margin-right: 5px 149 | animation-duration: .3s 150 | animation-name: fadeIn 151 | 152 | .card-tags 153 | +span-columns(6) 154 | margin-bottom: $base-spacing 155 | animation-duration: .3s 156 | animation-name: fadeIn 157 | .tag 158 | width: 30px 159 | height: 30px 160 | margin-right: 5px 161 | box-shadow: 0 0 0 162 | display: inline-block 163 | border-radius: $base-border-radius 164 | animation-duration: .3s 165 | animation-name: fadeIn 166 | -------------------------------------------------------------------------------- /web/static/css/modules/_modules.sass: -------------------------------------------------------------------------------- 1 | // - - - - - - - - - - - - - - - - - - - 2 | // - - modules 3 | // add new modules here 4 | 5 | // @import "example-module"; 6 | 7 | @import forms 8 | @import modals 9 | 10 | @import main_header 11 | @import sessions 12 | @import boards 13 | @import members_selector 14 | -------------------------------------------------------------------------------- /web/static/css/modules/_sessions.sass: -------------------------------------------------------------------------------- 1 | .sessions.new, .registrations.new 2 | background: $purple 3 | background-size: cover 4 | position: relative 5 | height: 100% 6 | 7 | main 8 | +transform(translate(-50%, -50%)) 9 | position: absolute 10 | width: 450px 11 | top: 40% 12 | left: 50% 13 | text-align: center 14 | 15 | header 16 | text-align: center 17 | margin-bottom: 2em 18 | 19 | .logo 20 | display: inline-block 21 | background: url(/images/logo.png) 0 0 no-repeat 22 | background-size: contain 23 | height: 62px 24 | width: 198px 25 | vertical-align: middle 26 | 27 | form 28 | margin-bottom: $base-spacing 29 | .field 30 | margin: .5em 0 31 | 32 | [type="text"], [type="email"], [type="password"], button 33 | border: 0px none 34 | width: 350px 35 | padding: 1em 36 | font-size: 1.1em 37 | 38 | button 39 | +transition 40 | background: transparent 41 | color: #fff 42 | border: 1px solid #fff 43 | 44 | &:hover 45 | color: $purple 46 | background: #fff 47 | 48 | a 49 | +transition 50 | color: rgba(#fff, .5) 51 | 52 | &:hover 53 | color: #fff 54 | -------------------------------------------------------------------------------- /web/static/js/actions/boards.js: -------------------------------------------------------------------------------- 1 | import Constants from '../constants'; 2 | import { push } from 'react-router-redux'; 3 | import { httpGet, httpPost } from '../utils'; 4 | import CurrentBoardActions from './current_board'; 5 | 6 | const Actions = { 7 | fetchBoards: () => { 8 | return dispatch => { 9 | dispatch({ type: Constants.BOARDS_FETCHING }); 10 | 11 | httpGet('/api/v1/boards') 12 | .then((data) => { 13 | dispatch({ 14 | type: Constants.BOARDS_RECEIVED, 15 | ownedBoards: data.owned_boards, 16 | invitedBoards: data.invited_boards, 17 | }); 18 | }); 19 | }; 20 | }, 21 | 22 | showForm: (show) => { 23 | return dispatch => { 24 | dispatch({ 25 | type: Constants.BOARDS_SHOW_FORM, 26 | show: show, 27 | }); 28 | }; 29 | }, 30 | 31 | create: (data) => { 32 | return dispatch => { 33 | httpPost('/api/v1/boards', { board: data }) 34 | .then((data) => { 35 | dispatch({ 36 | type: Constants.BOARDS_NEW_BOARD_CREATED, 37 | board: data, 38 | }); 39 | 40 | dispatch(push(`/boards/${data.id}`)); 41 | }) 42 | .catch((error) => { 43 | error.response.json() 44 | .then((json) => { 45 | dispatch({ 46 | type: Constants.BOARDS_CREATE_ERROR, 47 | errors: json.errors, 48 | }); 49 | }); 50 | }); 51 | }; 52 | }, 53 | 54 | reset: () => { 55 | return dispatch => { 56 | dispatch({ 57 | type: Constants.BOARDS_RESET, 58 | }); 59 | }; 60 | }, 61 | }; 62 | 63 | export default Actions; 64 | -------------------------------------------------------------------------------- /web/static/js/actions/current_board.js: -------------------------------------------------------------------------------- 1 | import Constants from '../constants'; 2 | 3 | const Actions = { 4 | showForm: (show) => { 5 | return dispatch => { 6 | dispatch({ 7 | type: Constants.CURRENT_BOARD_SHOW_FORM, 8 | show: show, 9 | }); 10 | }; 11 | }, 12 | 13 | connectToChannel: (socket, boardId) => { 14 | return dispatch => { 15 | const channel = socket.channel(`boards:${boardId}`); 16 | 17 | dispatch({ type: Constants.CURRENT_BOARD_FETCHING }); 18 | 19 | channel.join().receive('ok', (response) => { 20 | dispatch({ 21 | type: Constants.BOARDS_SET_CURRENT_BOARD, 22 | board: response.board, 23 | }); 24 | }); 25 | 26 | channel.on('user:joined', (msg) => { 27 | dispatch({ 28 | type: Constants.CURRENT_BOARD_CONNECTED_USERS, 29 | users: msg.users, 30 | }); 31 | }); 32 | 33 | channel.on('user:left', (msg) => { 34 | dispatch({ 35 | type: Constants.CURRENT_BOARD_CONNECTED_USERS, 36 | users: msg.users, 37 | }); 38 | }); 39 | 40 | channel.on('list:created', (msg) => { 41 | dispatch({ 42 | type: Constants.CURRENT_BOARD_LIST_CREATED, 43 | list: msg.list, 44 | }); 45 | }); 46 | 47 | channel.on('card:created', (msg) => { 48 | dispatch({ 49 | type: Constants.CURRENT_BOARD_CARD_CREATED, 50 | card: msg.card, 51 | }); 52 | }); 53 | 54 | channel.on('member:added', (msg) => { 55 | dispatch({ 56 | type: Constants.CURRENT_BOARD_MEMBER_ADDED, 57 | user: msg.user, 58 | }); 59 | }); 60 | 61 | channel.on('card:updated', (msg) => { 62 | dispatch({ 63 | type: Constants.BOARDS_SET_CURRENT_BOARD, 64 | board: msg.board, 65 | }); 66 | 67 | dispatch({ 68 | type: Constants.CURRENT_CARD_SET, 69 | card: msg.card, 70 | }); 71 | }); 72 | 73 | channel.on('list:updated', (msg) => { 74 | dispatch({ 75 | type: Constants.BOARDS_SET_CURRENT_BOARD, 76 | board: msg.board, 77 | }); 78 | }); 79 | 80 | channel.on('comment:created', (msg) => { 81 | dispatch({ 82 | type: Constants.BOARDS_SET_CURRENT_BOARD, 83 | board: msg.board, 84 | }); 85 | 86 | dispatch({ 87 | type: Constants.CURRENT_CARD_SET, 88 | card: msg.card, 89 | }); 90 | }); 91 | 92 | dispatch({ 93 | type: Constants.CURRENT_BOARD_CONNECTED_TO_CHANNEL, 94 | channel: channel, 95 | }); 96 | }; 97 | }, 98 | 99 | leaveChannel: (channel) => { 100 | return dispatch => { 101 | channel.leave(); 102 | 103 | dispatch({ 104 | type: Constants.CURRENT_BOARD_RESET, 105 | }); 106 | }; 107 | }, 108 | 109 | addNewMember: (channel, email) => { 110 | return dispatch => { 111 | channel.push('members:add', { email: email }) 112 | .receive('error', (data) => { 113 | dispatch({ 114 | type: Constants.CURRENT_BOARD_ADD_MEMBER_ERROR, 115 | error: data.error, 116 | }); 117 | }); 118 | }; 119 | }, 120 | 121 | updateCard: (channel, card) => { 122 | return dispatch => { 123 | channel.push('card:update', { card: card }); 124 | }; 125 | }, 126 | 127 | updateList: (channel, list) => { 128 | return dispatch => { 129 | channel.push('list:update', { list: list }); 130 | }; 131 | }, 132 | 133 | showMembersForm: (show) => { 134 | return dispatch => { 135 | dispatch({ 136 | type: Constants.CURRENT_BOARD_SHOW_MEMBERS_FORM, 137 | show: show, 138 | }); 139 | }; 140 | }, 141 | 142 | editList: (listId) => { 143 | return dispatch => { 144 | dispatch({ 145 | type: Constants.CURRENT_BOARD_EDIT_LIST, 146 | listId: listId, 147 | }); 148 | }; 149 | }, 150 | 151 | showCardForm: (listId) => { 152 | return dispatch => { 153 | dispatch({ 154 | type: Constants.CURRENT_BOARD_SHOW_CARD_FORM_FOR_LIST, 155 | listId: listId, 156 | }); 157 | }; 158 | }, 159 | }; 160 | 161 | export default Actions; 162 | -------------------------------------------------------------------------------- /web/static/js/actions/current_card.js: -------------------------------------------------------------------------------- 1 | import Constants from '../constants'; 2 | import {httpGet} from '../utils'; 3 | 4 | const Actions = { 5 | showCard: (card) => { 6 | return dispatch => { 7 | dispatch({ 8 | type: Constants.CURRENT_CARD_SET, 9 | card: card 10 | }); 11 | }; 12 | }, 13 | 14 | editCard: (edit) => { 15 | return dispatch => { 16 | dispatch({ 17 | type: Constants.CURRENT_CARD_EDIT, 18 | edit: edit, 19 | }); 20 | }; 21 | }, 22 | 23 | createCardComment: (channel, comment) => { 24 | return dispatch => { 25 | channel.push('card:add_comment', comment); 26 | }; 27 | }, 28 | 29 | reset: (channel, comment) => { 30 | return dispatch => { 31 | dispatch({ 32 | type: Constants.CURRENT_CARD_RESET, 33 | }); 34 | }; 35 | }, 36 | 37 | showMembersSelector: (show) => { 38 | return dispatch => { 39 | dispatch({ 40 | type: Constants.CURRENT_CARD_SHOW_MEMBERS_SELECTOR, 41 | show: show, 42 | }); 43 | }; 44 | }, 45 | 46 | showTagsSelector: (show) => { 47 | return dispatch => { 48 | dispatch({ 49 | type: Constants.CURRENT_CARD_SHOW_TAGS_SELECTOR, 50 | show: show, 51 | }); 52 | }; 53 | }, 54 | 55 | addMember: (channel, cardId, userId) => { 56 | return dispatch => { 57 | channel.push('card:add_member', { card_id: cardId, user_id: userId }); 58 | }; 59 | }, 60 | 61 | removeMember: (channel, cardId, userId) => { 62 | return dispatch => { 63 | channel.push('card:remove_member', { card_id: cardId, user_id: userId }); 64 | }; 65 | }, 66 | 67 | updateTags: (channel, cardId, tags) => { 68 | return dispatch => { 69 | channel.push('card:update', { card: { id: cardId, tags: tags } }); 70 | }; 71 | }, 72 | }; 73 | 74 | export default Actions; 75 | -------------------------------------------------------------------------------- /web/static/js/actions/header.js: -------------------------------------------------------------------------------- 1 | import Constants from '../constants'; 2 | import { push } from 'react-router-redux'; 3 | import { httpGet, httpPost } from '../utils'; 4 | import CurrentBoardActions from './current_board'; 5 | 6 | const Actions = { 7 | showBoards: (show) => { 8 | return dispatch => { 9 | dispatch({ 10 | type: Constants.HEADER_SHOW_BOARDS, 11 | show: show, 12 | }); 13 | }; 14 | }, 15 | 16 | visitBoard: (socket, channel, boardId) => { 17 | return dispatch => { 18 | if (channel) { 19 | dispatch(CurrentBoardActions.leaveChannel(channel)); 20 | dispatch(CurrentBoardActions.connectToChannel(socket, boardId)); 21 | } 22 | 23 | dispatch(push(`/boards/${boardId}`)); 24 | 25 | dispatch({ 26 | type: Constants.HEADER_SHOW_BOARDS, 27 | show: false, 28 | }); 29 | }; 30 | }, 31 | }; 32 | 33 | export default Actions; 34 | -------------------------------------------------------------------------------- /web/static/js/actions/lists.js: -------------------------------------------------------------------------------- 1 | import Constants from '../constants'; 2 | 3 | const Actions = { 4 | showForm: (show) => { 5 | return dispatch => { 6 | dispatch({ 7 | type: Constants.LISTS_SHOW_FORM, 8 | show: show, 9 | }); 10 | }; 11 | }, 12 | 13 | save: (channel, data) => { 14 | return dispatch => { 15 | const topic = data.id ? 'list:update' : 'lists:create'; 16 | 17 | channel.push(topic, { list: data }); 18 | }; 19 | }, 20 | 21 | createCard: (channel, data) => { 22 | return dispatch => { 23 | channel.push('cards:create', { card: data }); 24 | }; 25 | }, 26 | }; 27 | 28 | export default Actions; 29 | -------------------------------------------------------------------------------- /web/static/js/actions/registrations.js: -------------------------------------------------------------------------------- 1 | import { push } from 'react-router-redux'; 2 | import Constants from '../constants'; 3 | import { httpPost } from '../utils'; 4 | import {setCurrentUser} from './sessions'; 5 | 6 | const Actions = {}; 7 | 8 | Actions.signUp = (data) => { 9 | return dispatch => { 10 | httpPost('/api/v1/registrations', { user: data }) 11 | .then((data) => { 12 | localStorage.setItem('phoenixAuthToken', data.jwt); 13 | 14 | setCurrentUser(dispatch, data.user); 15 | 16 | dispatch(push('/')); 17 | }) 18 | .catch((error) => { 19 | error.response.json() 20 | .then((errorJSON) => { 21 | dispatch({ 22 | type: Constants.REGISTRATIONS_ERROR, 23 | errors: errorJSON.errors, 24 | }); 25 | }); 26 | }); 27 | }; 28 | }; 29 | 30 | export default Actions; 31 | -------------------------------------------------------------------------------- /web/static/js/actions/sessions.js: -------------------------------------------------------------------------------- 1 | import { push } from 'react-router-redux'; 2 | import Constants from '../constants'; 3 | import { Socket } from 'phoenix'; 4 | import { httpGet, httpPost, httpDelete } from '../utils'; 5 | 6 | export function setCurrentUser(dispatch, user) { 7 | const socket = new Socket('/socket', { 8 | params: { token: localStorage.getItem('phoenixAuthToken') }, 9 | logger: (kind, msg, data) => { console.log(`${kind}: ${msg}`, data); }, 10 | }); 11 | 12 | socket.connect(); 13 | 14 | const channel = socket.channel(`users:${user.id}`); 15 | 16 | if (channel.state != 'joined') { 17 | channel.join().receive('ok', () => { 18 | dispatch({ 19 | type: Constants.CURRENT_USER, 20 | currentUser: user, 21 | socket: socket, 22 | channel: channel, 23 | }); 24 | }); 25 | } 26 | 27 | channel.on('boards:add', (msg) => { 28 | dispatch({ 29 | type: Constants.BOARDS_ADDED, 30 | board: msg.board, 31 | }); 32 | }); 33 | }; 34 | 35 | const Actions = { 36 | signIn: (email, password) => { 37 | return dispatch => { 38 | const data = { 39 | session: { 40 | email: email, 41 | password: password, 42 | }, 43 | }; 44 | 45 | httpPost('/api/v1/sessions', data) 46 | .then((data) => { 47 | localStorage.setItem('phoenixAuthToken', data.jwt); 48 | setCurrentUser(dispatch, data.user); 49 | dispatch(push('/')); 50 | }) 51 | .catch((error) => { 52 | error.response.json() 53 | .then((errorJSON) => { 54 | dispatch({ 55 | type: Constants.SESSIONS_ERROR, 56 | error: errorJSON.error, 57 | }); 58 | }); 59 | }); 60 | }; 61 | }, 62 | 63 | currentUser: () => { 64 | return dispatch => { 65 | const authToken = localStorage.getItem('phoenixAuthToken'); 66 | 67 | httpGet('/api/v1/current_user') 68 | .then(function (data) { 69 | setCurrentUser(dispatch, data); 70 | }) 71 | .catch(function (error) { 72 | console.log(error); 73 | dispatch(push('/sign_in')); 74 | }); 75 | }; 76 | }, 77 | 78 | signOut: () => { 79 | return dispatch => { 80 | httpDelete('/api/v1/sessions') 81 | .then((data) => { 82 | localStorage.removeItem('phoenixAuthToken'); 83 | 84 | dispatch({ type: Constants.USER_SIGNED_OUT, }); 85 | 86 | dispatch(push('/sign_in')); 87 | 88 | dispatch({ type: Constants.BOARDS_FULL_RESET }); 89 | }) 90 | .catch(function (error) { 91 | console.log(error); 92 | }); 93 | }; 94 | }, 95 | }; 96 | 97 | export default Actions; 98 | -------------------------------------------------------------------------------- /web/static/js/application.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import ReactDOM from 'react-dom'; 3 | import { browserHistory } from 'react-router'; 4 | import { syncHistoryWithStore } from 'react-router-redux'; 5 | import configureStore from './store'; 6 | import Root from './containers/root'; 7 | 8 | const store = configureStore(browserHistory); 9 | const history = syncHistoryWithStore(browserHistory, store); 10 | 11 | const target = document.getElementById('main_container'); 12 | const node = ; 13 | 14 | ReactDOM.render(node, target); 15 | -------------------------------------------------------------------------------- /web/static/js/components/boards/card.js: -------------------------------------------------------------------------------- 1 | import React, {PropTypes} from 'react'; 2 | import { push } from 'react-router-redux'; 3 | 4 | export default class BoardCard extends React.Component { 5 | _handleClick() { 6 | this.props.dispatch(push(`/boards/${this.props.id}`)); 7 | } 8 | 9 | render() { 10 | return ( 11 |
12 |
13 |

{this.props.name}

14 |
15 |
16 | ); 17 | } 18 | } 19 | 20 | BoardCard.propTypes = { 21 | }; 22 | -------------------------------------------------------------------------------- /web/static/js/components/boards/form.js: -------------------------------------------------------------------------------- 1 | import React, { PropTypes } from 'react'; 2 | import Actions from '../../actions/boards'; 3 | import PageClick from 'react-page-click'; 4 | import {renderErrorsFor} from '../../utils'; 5 | 6 | export default class BoardForm extends React.Component { 7 | componentDidMount() { 8 | this.refs.name.focus(); 9 | } 10 | 11 | _handleSubmit(e) { 12 | e.preventDefault(); 13 | 14 | const { dispatch } = this.props; 15 | const { name } = this.refs; 16 | 17 | const data = { 18 | name: name.value, 19 | }; 20 | 21 | dispatch(Actions.create(data)); 22 | } 23 | 24 | _handleCancelClick(e) { 25 | e.preventDefault(); 26 | 27 | this.props.onCancelClick(); 28 | } 29 | 30 | render() { 31 | const { errors } = this.props; 32 | 33 | return ( 34 | 35 |
36 |
37 |

New board

38 |
39 | 40 | {renderErrorsFor(errors, 'name')} 41 | or cancel 42 |
43 |
44 |
45 |
46 | ); 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /web/static/js/components/boards/members.js: -------------------------------------------------------------------------------- 1 | import React, {PropTypes} from 'react'; 2 | import ReactGravatar from 'react-gravatar'; 3 | import ReactCSSTransitionGroup from 'react-addons-css-transition-group'; 4 | import classnames from 'classnames'; 5 | import PageClick from 'react-page-click'; 6 | import Actions from '../../actions/current_board'; 7 | 8 | export default class BoardMembers extends React.Component { 9 | _renderUsers() { 10 | return this.props.members.map((member) => { 11 | const index = this.props.connectedUsers.findIndex((cu) => { 12 | return cu === member.id; 13 | }); 14 | 15 | const classes = classnames({ connected: index != -1 }); 16 | 17 | return ( 18 |
  • 19 | 20 |
  • 21 | ); 22 | }); 23 | } 24 | 25 | _renderAddNewUser() { 26 | if (!this.props.currentUserIsOwner) return false; 27 | 28 | return ( 29 |
  • 30 | 31 | {::this._renderForm()} 32 |
  • 33 | ); 34 | } 35 | 36 | _renderForm() { 37 | if (!this.props.show) return false; 38 | 39 | return ( 40 | 41 |
      42 |
    • 43 |
      44 |

      Add new members

      45 | {::this._renderError()} 46 | 47 | or cancel 48 |
      49 |
    • 50 |
    51 |
    52 | ); 53 | } 54 | 55 | _renderError() { 56 | const { error } = this.props; 57 | 58 | if (!error) return false; 59 | 60 | return ( 61 |
    62 | {error} 63 |
    64 | ); 65 | } 66 | 67 | _handleAddNewClick(e) { 68 | e.preventDefault(); 69 | 70 | this.props.dispatch(Actions.showMembersForm(true)); 71 | } 72 | 73 | _handleCancelClick(e) { 74 | e.preventDefault(); 75 | 76 | this.props.dispatch(Actions.showMembersForm(false)); 77 | } 78 | 79 | _handleSubmit(e) { 80 | e.preventDefault(); 81 | 82 | const { email } = this.refs; 83 | const { dispatch, channel } = this.props; 84 | 85 | dispatch(Actions.addNewMember(channel, email.value)); 86 | } 87 | 88 | render() { 89 | return ( 90 | 101 | ); 102 | } 103 | } 104 | 105 | BoardMembers.propTypes = { 106 | }; 107 | -------------------------------------------------------------------------------- /web/static/js/components/cards/card.js: -------------------------------------------------------------------------------- 1 | import React, {PropTypes} from 'react'; 2 | import {DragSource, DropTarget} from 'react-dnd'; 3 | import { push } from 'react-router-redux'; 4 | import ReactGravatar from 'react-gravatar'; 5 | import classnames from 'classnames'; 6 | 7 | import ItemTypes from '../../constants/item_types'; 8 | import Actions from '../../actions/current_board'; 9 | import CardActions from '../../actions/current_card'; 10 | 11 | const cardSource = { 12 | beginDrag(props) { 13 | return { 14 | id: props.id, 15 | list_id: props.list_id, 16 | name: props.name, 17 | position: props.position, 18 | }; 19 | }, 20 | 21 | isDragging(props, monitor) { 22 | return props.id === monitor.getItem().id; 23 | }, 24 | }; 25 | 26 | const cardTarget = { 27 | drop(targetProps, monitor) { 28 | const source = monitor.getItem(); 29 | 30 | if (source.id !== targetProps.id) { 31 | const target = { 32 | id: targetProps.id, 33 | list_id: targetProps.list_id, 34 | name: targetProps.name, 35 | position: targetProps.position, 36 | }; 37 | 38 | targetProps.onDrop({ source, target }); 39 | } 40 | }, 41 | }; 42 | 43 | @DragSource(ItemTypes.CARD, cardSource, (connect, monitor) => ({ 44 | connectDragSource: connect.dragSource(), 45 | isDragging: monitor.isDragging() 46 | })) 47 | 48 | @DropTarget(ItemTypes.CARD, cardTarget, (connect, monitor) => ({ 49 | connectDropTarget: connect.dropTarget(), 50 | isOver: monitor.isOver() 51 | })) 52 | 53 | export default class Card extends React.Component { 54 | _handleClick(e) { 55 | const { dispatch, id, boardId } = this.props; 56 | 57 | dispatch(push(`/boards/${boardId}/cards/${id}`)); 58 | } 59 | 60 | _renderFooter() { 61 | let commentIcon = null; 62 | const { comments, members } = this.props; 63 | 64 | if (comments.length > 0) { 65 | commentIcon = 66 | {comments.length} 67 | ; 68 | } 69 | 70 | const memberNodes = members.map((member) => { 71 | return ; 72 | }); 73 | 74 | return ( 75 |
    76 | {commentIcon} 77 | {memberNodes} 78 |
    79 | ); 80 | } 81 | 82 | _renderTags() { 83 | const { tags } = this.props; 84 | 85 | const tagsNodes = tags.map((tag) => { 86 | return ( 87 | 88 | ); 89 | }); 90 | 91 | return ( 92 |
    93 | {tagsNodes} 94 |
    95 | ); 96 | } 97 | 98 | render() { 99 | const { id, connectDragSource, connectDropTarget, isDragging, isOver, name } = this.props; 100 | 101 | const styles = { 102 | display: isDragging ? 'none' : 'block', 103 | }; 104 | 105 | const classes = classnames({ 106 | 'card': true, 107 | 'is-over': isOver 108 | }); 109 | 110 | return connectDragSource( 111 | connectDropTarget( 112 |
    113 |
    114 | {::this._renderTags()} 115 | {name} 116 | {::this._renderFooter()} 117 |
    118 |
    119 | ) 120 | ); 121 | } 122 | } 123 | 124 | Card.propTypes = { 125 | }; 126 | -------------------------------------------------------------------------------- /web/static/js/components/cards/form.js: -------------------------------------------------------------------------------- 1 | import React, { PropTypes } from 'react'; 2 | import Actions from '../../actions/lists'; 3 | import PageClick from 'react-page-click'; 4 | 5 | export default class CardForm extends React.Component { 6 | _handleSubmit(e) { 7 | e.preventDefault(); 8 | 9 | let { dispatch, channel } = this.props; 10 | let { name } = this.refs; 11 | 12 | let data = { 13 | list_id: this.props.listId, 14 | name: name.value, 15 | }; 16 | 17 | dispatch(Actions.createCard(channel, data)); 18 | this.props.onSubmit(); 19 | } 20 | 21 | _renderErrors(field) { 22 | const { errors } = this.props; 23 | 24 | if (!errors) return false; 25 | 26 | return errors.map((error, i) => { 27 | if (error[field]) { 28 | return ( 29 |
    30 | {error[field]} 31 |
    32 | ); 33 | } 34 | }); 35 | } 36 | 37 | componentDidMount() { 38 | this.refs.name.focus(); 39 | } 40 | 41 | _handleCancelClick(e) { 42 | e.preventDefault(); 43 | 44 | this.props.onCancelClick(); 45 | } 46 | 47 | render() { 48 | return ( 49 | 50 |
    51 |
    52 |