From aa16d5ca27c479910b26bb49005782dedc6e9426 Mon Sep 17 00:00:00 2001 From: Yuchi Hsueh Date: Wed, 29 Jan 2025 19:55:46 -0300 Subject: [PATCH 1/7] Api completed, added kaminari for pagination --- .ruby-version | 1 + Gemfile | 3 + Gemfile.lock | 20 ++++++ app/controllers/api/items_controller.rb | 64 ++++++++++++++++++ app/controllers/api/todo_lists_controller.rb | 69 ++++++++++++++++++-- app/controllers/items_controller.rb | 22 +++++++ app/helpers/items_helper.rb | 2 + app/models/item.rb | 4 ++ app/models/todo_list.rb | 4 +- config/routes.rb | 4 +- db/migrate/20250129222127_create_items.rb | 12 ++++ db/schema.rb | 13 +++- spec/helpers/items_helper_spec.rb | 15 +++++ spec/models/item_spec.rb | 5 ++ spec/requests/items_spec.rb | 7 ++ 15 files changed, 236 insertions(+), 9 deletions(-) create mode 100644 .ruby-version create mode 100644 app/controllers/api/items_controller.rb create mode 100644 app/controllers/items_controller.rb create mode 100644 app/helpers/items_helper.rb create mode 100644 app/models/item.rb create mode 100644 db/migrate/20250129222127_create_items.rb create mode 100644 spec/helpers/items_helper_spec.rb create mode 100644 spec/models/item_spec.rb create mode 100644 spec/requests/items_spec.rb diff --git a/.ruby-version b/.ruby-version new file mode 100644 index 00000000..15a27998 --- /dev/null +++ b/.ruby-version @@ -0,0 +1 @@ +3.3.0 diff --git a/Gemfile b/Gemfile index 1b8beb13..5660709c 100644 --- a/Gemfile +++ b/Gemfile @@ -25,6 +25,9 @@ gem "stimulus-rails" # Build JSON APIs with ease [https://github.com/rails/jbuilder] gem "jbuilder" +#A Scope & Engine based, clean, powerful, customizable and sophisticated paginator for modern web app frameworks and ORMs [https://github.com/kaminari/kaminari] +gem 'kaminari', '~> 1.2', '>= 1.2.2' + # Use Redis adapter to run Action Cable in production # gem "redis", "~> 4.0" diff --git a/Gemfile.lock b/Gemfile.lock index a68aabd6..ef26b178 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -102,6 +102,18 @@ GEM jbuilder (2.11.5) actionview (>= 5.0.0) activesupport (>= 5.0.0) + kaminari (1.2.2) + activesupport (>= 4.1.0) + kaminari-actionview (= 1.2.2) + kaminari-activerecord (= 1.2.2) + kaminari-core (= 1.2.2) + kaminari-actionview (1.2.2) + actionview + kaminari-core (= 1.2.2) + kaminari-activerecord (1.2.2) + activerecord + kaminari-core (= 1.2.2) + kaminari-core (1.2.2) loofah (2.20.0) crass (~> 1.0.2) nokogiri (>= 1.5.9) @@ -114,6 +126,7 @@ GEM matrix (0.4.2) method_source (1.0.0) mini_mime (1.1.2) + mini_portile2 (2.8.8) minitest (5.18.0) msgpack (1.7.0) net-imap (0.3.4) @@ -126,6 +139,9 @@ GEM net-smtp (0.3.3) net-protocol nio4r (2.5.9) + nokogiri (1.15.3) + mini_portile2 (~> 2.8.2) + racc (~> 1.4) nokogiri (1.15.3-x86_64-darwin) racc (~> 1.4) nokogiri (1.15.3-x86_64-linux) @@ -201,6 +217,8 @@ GEM actionpack (>= 5.2) activesupport (>= 5.2) sprockets (>= 3.0.0) + sqlite3 (1.6.2) + mini_portile2 (~> 2.8.0) sqlite3 (1.6.2-x86_64-darwin) sqlite3 (1.6.2-x86_64-linux) stimulus-rails (1.2.1) @@ -231,6 +249,7 @@ GEM zeitwerk (2.6.7) PLATFORMS + arm64-darwin-24 x86_64-darwin-22 x86_64-linux @@ -240,6 +259,7 @@ DEPENDENCIES debug importmap-rails jbuilder + kaminari (~> 1.2, >= 1.2.2) puma (~> 5.0) rails (~> 7.0.4, >= 7.0.4.3) rspec diff --git a/app/controllers/api/items_controller.rb b/app/controllers/api/items_controller.rb new file mode 100644 index 00000000..7154d3e7 --- /dev/null +++ b/app/controllers/api/items_controller.rb @@ -0,0 +1,64 @@ +class Api::ItemsController < ApplicationController + skip_before_action :verify_authenticity_token + rescue_from ActiveRecord::RecordNotFound, with: :item_not_found + before_action :set_todo_list, only: [:index, :create] + before_action :set_item, only: [:update, :destroy] + + def index + @items = @todo_list.items.page(params[:page]).per(params[:per_page] || 10) + render json: { + items: @items, + total_pages: @items.total_pages, + current_page: @items.current_page, + total_count: @items.total_count + }, status: :ok + end + + def create + @item = @todo_list.items.build(item_params) + + if @item.save + render json: @item, status: :ok + else + error_handler(@item.errors) + end + end + + def update + @item = Item.find(params[:id]) + + if @item.update(item_params) + render json: @item, status: :ok + else + error_handler(@item.errors) + end + end + + def destroy + @item = Item.find(params[:id]) + @item.destroy + head :ok + end + + private + + def set_todo_list + @todo_list = TodoList.find(params[:todo_list_id]) + end + + def set_item + @item = Item.find(params[:id]) + end + + def item_params + params.require(:item).permit(:title, :description) + end + + def error_handler(errors) + render json: { errors: errors.full_messages }, status: :unprocessable_entity + end + + def item_not_found + render json: { error: "Item not found" }, status: :not_found + end +end diff --git a/app/controllers/api/todo_lists_controller.rb b/app/controllers/api/todo_lists_controller.rb index 819f0777..d2a4f0d3 100644 --- a/app/controllers/api/todo_lists_controller.rb +++ b/app/controllers/api/todo_lists_controller.rb @@ -1,10 +1,67 @@ -module Api - class TodoListsController < ApplicationController - # GET /api/todolists + class Api::TodoListsController < ApplicationController + skip_before_action :verify_authenticity_token + rescue_from ActiveRecord::RecordNotFound, with: :todo_list_not_found + before_action :set_todo_list, only: [:show, :update, :destroy] + def index - @todo_lists = TodoList.all + @todo_lists = TodoList.page(params[:page]).per(params[:per_page] || 10) + render json: { + todo_lists: @todo_lists, + total_pages: @todo_lists.total_pages, + current_page: @todo_lists.current_page, + total_count: @todo_lists.total_count + }, status: :ok + end + + def show + render json: @todo_list, status: :ok + end + + def create + @todo_list = TodoList.new(todo_list_params) + if @todo_list.save + render json: { + msg: "Todo list created successfully", + todo_list: @todo_list + }, status: :created + else + error_handler(@todo_list.errors) + end + end + + def update + if @todo_list.update(todo_list_params) + render json: { + msg: "Todo list updated successfully", + todo_list: @todo_list + }, status: :ok + else + error_handler(@todo_list.errors) + end + end + + def destroy + @todo_list.destroy + render json: { + msg: "Todo list deleted successfully" + }, status: :ok + end + + private + + def set_todo_list + @todo_list = TodoList.find(params[:id]) + end + + def todo_list_params + params.require(:todo_list).permit(:name) + end + + def error_handler(errors) + render json: { errors: errors.full_messages }, status: :unprocessable_entity + end - respond_to :json + def todo_list_not_found + render json: { error: "Todo list not found" }, status: :not_found end end -end diff --git a/app/controllers/items_controller.rb b/app/controllers/items_controller.rb new file mode 100644 index 00000000..a088524f --- /dev/null +++ b/app/controllers/items_controller.rb @@ -0,0 +1,22 @@ +class ItemsController < ApplicationController + before_action :set_todo_list + + def create + @item = @todo_list.item.new(item_params) + if @item.save + redirect_to api_todo_list(@todo_list) + else + redirect_to api_todo_list(@todo_list), status: :unprocessable_entity, notice: "Couldn't create the item" + end + end + + private + + def todo_list + @todo_list = TodoList.find(params[:id]) + end + + def item_params + params.require(:item).permit(:title, :description) + end +end diff --git a/app/helpers/items_helper.rb b/app/helpers/items_helper.rb new file mode 100644 index 00000000..cff0c9fe --- /dev/null +++ b/app/helpers/items_helper.rb @@ -0,0 +1,2 @@ +module ItemsHelper +end diff --git a/app/models/item.rb b/app/models/item.rb new file mode 100644 index 00000000..5a2a151f --- /dev/null +++ b/app/models/item.rb @@ -0,0 +1,4 @@ +class Item < ApplicationRecord + belongs_to :todo_list + validates :title, presence: true +end diff --git a/app/models/todo_list.rb b/app/models/todo_list.rb index d2030786..6fa94ead 100644 --- a/app/models/todo_list.rb +++ b/app/models/todo_list.rb @@ -1,2 +1,4 @@ class TodoList < ApplicationRecord -end \ No newline at end of file + validates :name, presence: true + has_many :items +end diff --git a/config/routes.rb b/config/routes.rb index d1dad462..a715d911 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -1,6 +1,8 @@ Rails.application.routes.draw do namespace :api do - resources :todo_lists, only: %i[index], path: :todolists + resources :todo_lists, path: :todolists do + resources :items + end end resources :todo_lists, only: %i[index new], path: :todolists diff --git a/db/migrate/20250129222127_create_items.rb b/db/migrate/20250129222127_create_items.rb new file mode 100644 index 00000000..5361177c --- /dev/null +++ b/db/migrate/20250129222127_create_items.rb @@ -0,0 +1,12 @@ +class CreateItems < ActiveRecord::Migration[7.0] + def change + create_table :items do |t| + t.string :title + t.string :description + t.boolean :completed, default: false + t.references :todo_list, null: false, foreign_key: true + + t.timestamps + end + end +end diff --git a/db/schema.rb b/db/schema.rb index 7da1ae17..3eac164c 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,9 +10,20 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[7.0].define(version: 2023_04_04_162028) do +ActiveRecord::Schema[7.0].define(version: 2025_01_29_222127) do + create_table "items", force: :cascade do |t| + t.string "title" + t.string "description" + t.boolean "completed", default: false + t.integer "todo_list_id", null: false + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["todo_list_id"], name: "index_items_on_todo_list_id" + end + create_table "todo_lists", force: :cascade do |t| t.string "name", null: false end + add_foreign_key "items", "todo_lists" end diff --git a/spec/helpers/items_helper_spec.rb b/spec/helpers/items_helper_spec.rb new file mode 100644 index 00000000..c1652491 --- /dev/null +++ b/spec/helpers/items_helper_spec.rb @@ -0,0 +1,15 @@ +require 'rails_helper' + +# Specs in this file have access to a helper object that includes +# the ItemsHelper. For example: +# +# describe ItemsHelper do +# describe "string concat" do +# it "concats two strings with spaces" do +# expect(helper.concat_strings("this","that")).to eq("this that") +# end +# end +# end +RSpec.describe ItemsHelper, type: :helper do + pending "add some examples to (or delete) #{__FILE__}" +end diff --git a/spec/models/item_spec.rb b/spec/models/item_spec.rb new file mode 100644 index 00000000..9580acfa --- /dev/null +++ b/spec/models/item_spec.rb @@ -0,0 +1,5 @@ +require 'rails_helper' + +RSpec.describe Item, type: :model do + pending "add some examples to (or delete) #{__FILE__}" +end diff --git a/spec/requests/items_spec.rb b/spec/requests/items_spec.rb new file mode 100644 index 00000000..063e6325 --- /dev/null +++ b/spec/requests/items_spec.rb @@ -0,0 +1,7 @@ +require 'rails_helper' + +RSpec.describe "Items", type: :request do + describe "GET /index" do + pending "add some examples (or delete) #{__FILE__}" + end +end From 74bb9e428c81951ea06ca27fa20736ad7ef58e66 Mon Sep 17 00:00:00 2001 From: Yuchi Hsueh Date: Wed, 29 Jan 2025 20:04:27 -0300 Subject: [PATCH 2/7] adds msg for responses, add completed reader and accessor for item model --- app/controllers/api/items_controller.rb | 19 ++++++++++++------- app/models/item.rb | 8 ++++++++ 2 files changed, 20 insertions(+), 7 deletions(-) diff --git a/app/controllers/api/items_controller.rb b/app/controllers/api/items_controller.rb index 7154d3e7..8ce374bc 100644 --- a/app/controllers/api/items_controller.rb +++ b/app/controllers/api/items_controller.rb @@ -18,26 +18,31 @@ def create @item = @todo_list.items.build(item_params) if @item.save - render json: @item, status: :ok + render json: { + msg: "Item created successfully", + item: @item + }, status: :created else error_handler(@item.errors) end end def update - @item = Item.find(params[:id]) - if @item.update(item_params) - render json: @item, status: :ok + render json: { + msg: "Item updated successfully", + item: @item + }, status: :ok else error_handler(@item.errors) end end def destroy - @item = Item.find(params[:id]) @item.destroy - head :ok + render json: { + msg: "Item deleted successfully" + }, status: :ok end private @@ -47,7 +52,7 @@ def set_todo_list end def set_item - @item = Item.find(params[:id]) + @item = @todo_list.items.find(params[:id]) end def item_params diff --git a/app/models/item.rb b/app/models/item.rb index 5a2a151f..f76516cd 100644 --- a/app/models/item.rb +++ b/app/models/item.rb @@ -1,4 +1,12 @@ class Item < ApplicationRecord belongs_to :todo_list validates :title, presence: true + + def completed? + completed + end + + def complete! + update(completed: true) + end end From 1bf1fc16f39dedba7fe48bc59581a086b9d74778 Mon Sep 17 00:00:00 2001 From: Yuchi Hsueh Date: Thu, 30 Jan 2025 00:00:30 -0300 Subject: [PATCH 3/7] adds views for todos list and items, adds turbo streams, bootstrap and fontawesome through cdn, fixes for api --- Gemfile.lock | 10 +- app/assets/stylesheets/application.css | 1 + app/controllers/api/items_controller.rb | 10 +- app/controllers/api/todo_lists_controller.rb | 2 +- app/controllers/items_controller.rb | 43 +- app/controllers/todo_lists_controller.rb | 64 ++- .../controllers/reset_form_controller.js | 13 + app/models/todo_list.rb | 2 +- app/views/items/_form.html.erb | 14 + app/views/items/_item.html.erb | 32 ++ app/views/items/_list.html.erb | 5 + app/views/layouts/application.html.erb | 2 + app/views/todo_lists/_form.html.erb | 23 + app/views/todo_lists/_todo_list.html.erb | 25 + app/views/todo_lists/edit.html.erb | 14 + app/views/todo_lists/index.html.erb | 27 +- app/views/todo_lists/new.html.erb | 0 app/views/todo_lists/show.html.erb | 27 ++ config/importmap.rb | 438 ++++++++++++++++++ config/routes.rb | 6 +- 20 files changed, 725 insertions(+), 33 deletions(-) create mode 100644 app/javascript/controllers/reset_form_controller.js create mode 100644 app/views/items/_form.html.erb create mode 100644 app/views/items/_item.html.erb create mode 100644 app/views/items/_list.html.erb create mode 100644 app/views/todo_lists/_form.html.erb create mode 100644 app/views/todo_lists/_todo_list.html.erb create mode 100644 app/views/todo_lists/edit.html.erb delete mode 100644 app/views/todo_lists/new.html.erb create mode 100644 app/views/todo_lists/show.html.erb diff --git a/Gemfile.lock b/Gemfile.lock index ef26b178..9a1bacdb 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -71,6 +71,9 @@ GEM bindex (0.8.1) bootsnap (1.16.0) msgpack (~> 1.2) + bootstrap-kaminari-views (0.0.5) + kaminari (>= 0.13) + rails (>= 3.1) builder (3.2.4) capybara (3.39.0) addressable @@ -142,10 +145,6 @@ GEM nokogiri (1.15.3) mini_portile2 (~> 2.8.2) racc (~> 1.4) - nokogiri (1.15.3-x86_64-darwin) - racc (~> 1.4) - nokogiri (1.15.3-x86_64-linux) - racc (~> 1.4) public_suffix (5.0.1) puma (5.6.5) nio4r (~> 2.0) @@ -219,8 +218,6 @@ GEM sprockets (>= 3.0.0) sqlite3 (1.6.2) mini_portile2 (~> 2.8.0) - sqlite3 (1.6.2-x86_64-darwin) - sqlite3 (1.6.2-x86_64-linux) stimulus-rails (1.2.1) railties (>= 6.0.0) thor (1.2.1) @@ -255,6 +252,7 @@ PLATFORMS DEPENDENCIES bootsnap + bootstrap-kaminari-views (~> 0.0.5) capybara debug importmap-rails diff --git a/app/assets/stylesheets/application.css b/app/assets/stylesheets/application.css index 288b9ab7..a4705dcf 100644 --- a/app/assets/stylesheets/application.css +++ b/app/assets/stylesheets/application.css @@ -13,3 +13,4 @@ *= require_tree . *= require_self */ +@import "bootstrap"; diff --git a/app/controllers/api/items_controller.rb b/app/controllers/api/items_controller.rb index 8ce374bc..dfcacb77 100644 --- a/app/controllers/api/items_controller.rb +++ b/app/controllers/api/items_controller.rb @@ -1,8 +1,8 @@ class Api::ItemsController < ApplicationController skip_before_action :verify_authenticity_token rescue_from ActiveRecord::RecordNotFound, with: :item_not_found - before_action :set_todo_list, only: [:index, :create] - before_action :set_item, only: [:update, :destroy] + before_action :set_todo_list + before_action :set_item, only: %i[ show update destroy ] def index @items = @todo_list.items.page(params[:page]).per(params[:per_page] || 10) @@ -14,6 +14,12 @@ def index }, status: :ok end + def show + render json: { + item: @item + }, status: :ok + end + def create @item = @todo_list.items.build(item_params) diff --git a/app/controllers/api/todo_lists_controller.rb b/app/controllers/api/todo_lists_controller.rb index d2a4f0d3..69d49ca4 100644 --- a/app/controllers/api/todo_lists_controller.rb +++ b/app/controllers/api/todo_lists_controller.rb @@ -1,7 +1,7 @@ class Api::TodoListsController < ApplicationController skip_before_action :verify_authenticity_token rescue_from ActiveRecord::RecordNotFound, with: :todo_list_not_found - before_action :set_todo_list, only: [:show, :update, :destroy] + before_action :set_todo_list, only: %i[ show update destroy ] def index @todo_lists = TodoList.page(params[:page]).per(params[:per_page] || 10) diff --git a/app/controllers/items_controller.rb b/app/controllers/items_controller.rb index a088524f..78ea0f15 100644 --- a/app/controllers/items_controller.rb +++ b/app/controllers/items_controller.rb @@ -1,22 +1,49 @@ class ItemsController < ApplicationController before_action :set_todo_list + before_action :set_item, only: %i[ destroy ] def create - @item = @todo_list.item.new(item_params) - if @item.save - redirect_to api_todo_list(@todo_list) - else - redirect_to api_todo_list(@todo_list), status: :unprocessable_entity, notice: "Couldn't create the item" + @item = @todo_list.items.build(item_params) + + respond_to do |format| + if @item.save + format.html { redirect_to @todo_list, notice: 'Item was successfully created.' } + format.turbo_stream do + render turbo_stream: [ + turbo_stream.update("items_list", partial: "items/list", locals: { todo_list: @todo_list }), + turbo_stream.update("new_item", partial: "items/form", locals: { todo_list: @todo_list, item: Item.new }) + ] + end + else + format.html { redirect_to @todo_list, alert: 'Error creating item.' } + format.turbo_stream do + render turbo_stream.update("new_item", + partial: "items/form", + locals: { todo_list: @todo_list, item: @item }) + end + end + end + end + + def destroy + @item.destroy + respond_to do |format| + format.html { redirect_to @todo_list, notice: 'Item was successfully deleted.' } + format.turbo_stream { render turbo_stream: turbo_stream.remove(@item) } end end private - def todo_list - @todo_list = TodoList.find(params[:id]) + def set_todo_list + @todo_list = TodoList.find(params[:todo_list_id]) + end + + def set_item + @item = @todo_list.items.find(params[:id]) end def item_params - params.require(:item).permit(:title, :description) + params.require(:item).permit(:title, :description, :completed) end end diff --git a/app/controllers/todo_lists_controller.rb b/app/controllers/todo_lists_controller.rb index ad40d55d..30dbb4f2 100644 --- a/app/controllers/todo_lists_controller.rb +++ b/app/controllers/todo_lists_controller.rb @@ -1,15 +1,65 @@ class TodoListsController < ApplicationController - # GET /todolists + before_action :set_todo_list, only: %i[ show edit update destroy ] + def index - @todo_lists = TodoList.all + @todo_lists = TodoList.page(params[:page]).per(10) + end + + def show + end + + def edit + end + + def create + @todo_list = TodoList.new(todo_list_params) - respond_to :html + respond_to do |format| + if @todo_list.save + format.html { redirect_to @todo_list, notice: "Todo list created successfully" } + format.turbo_stream do + render turbo_stream: [ + turbo_stream.append("todo_lists", partial: "todo_list", locals: { todo_list: @todo_list }), + turbo_stream.update("new_todo_list", partial: "form", locals: { todo_list: TodoList.new }) + ] + end + else + format.html { render :new, status: :unprocessable_entity } + format.turbo_stream do + render turbo_stream.update("new_todo_list", + partial: "form", + locals: { todo_list: @todo_list }) + end + end + end end - # GET /todolists/new - def new - @todo_list = TodoList.new + def update + if @todo_list.update(todo_list_params) + redirect_to @todo_list, notice: "Todo list updated successfully" + else + render :edit, status: :unprocessable_entity + end + end + + def destroy + @todo_list.destroy + + respond_to do |format| + format.html { redirect_to todo_lists_url, notice: "Todo list deleted successfully" } + format.turbo_stream do + render turbo_stream: turbo_stream.remove(@todo_list) + end + end + end + + private + + def set_todo_list + @todo_list = TodoList.find(params[:id]) + end - respond_to :html + def todo_list_params + params.require(:todo_list).permit(:name) end end diff --git a/app/javascript/controllers/reset_form_controller.js b/app/javascript/controllers/reset_form_controller.js new file mode 100644 index 00000000..31af1a3d --- /dev/null +++ b/app/javascript/controllers/reset_form_controller.js @@ -0,0 +1,13 @@ +import { Controller } from "@hotwired/stimulus" + +export default class extends Controller { + clearOnEscape(event) { + if (event.key === "Escape") { + event.target.value = "" + } + } + + reset() { + this.element.reset() + } +} diff --git a/app/models/todo_list.rb b/app/models/todo_list.rb index 6fa94ead..d9f0539b 100644 --- a/app/models/todo_list.rb +++ b/app/models/todo_list.rb @@ -1,4 +1,4 @@ class TodoList < ApplicationRecord validates :name, presence: true - has_many :items + has_many :items, dependent: :destroy end diff --git a/app/views/items/_form.html.erb b/app/views/items/_form.html.erb new file mode 100644 index 00000000..8b7509eb --- /dev/null +++ b/app/views/items/_form.html.erb @@ -0,0 +1,14 @@ +<%= form_with(model: [todo_list, item], class: "mb-3") do |f| %> +
+ <%= f.text_field :title, + class: "form-control", + placeholder: "Enter new item title...", + required: true %> +
+
+ <%= f.text_field :description, + class: "form-control", + placeholder: "Enter new item description..." %> + <%= f.submit "+ Add new Item", class: "btn btn-primary" %> +
+<% end %> diff --git a/app/views/items/_item.html.erb b/app/views/items/_item.html.erb new file mode 100644 index 00000000..0fb4af07 --- /dev/null +++ b/app/views/items/_item.html.erb @@ -0,0 +1,32 @@ +<%= turbo_frame_tag dom_id(item) do %> +
+
+
+ <%= form_with(model: [todo_list, item], class: "d-inline") do |f| %> + <%= f.check_box :completed, + class: "form-check-input", + data: { turbo_frame: dom_id(item) }, + onchange: "this.form.requestSubmit()" %> +
+ + <%= item.title %> + + <% if item.description.present? %> +

+ <%= item.description %> +

+ <% end %> +
+ <% end %> +
+
+ <%= button_to todo_list_item_path(todo_list, item), + method: :delete, + class: "btn btn-sm btn-danger", + form: { class: 'd-inline' } do %> + + <% end %> +
+
+
+<% end %> diff --git a/app/views/items/_list.html.erb b/app/views/items/_list.html.erb new file mode 100644 index 00000000..e971a406 --- /dev/null +++ b/app/views/items/_list.html.erb @@ -0,0 +1,5 @@ +
+ <% todo_list.items.order(created_at: :desc).each do |item| %> + <%= render "items/item", todo_list: todo_list, item: item %> + <% end %> +
diff --git a/app/views/layouts/application.html.erb b/app/views/layouts/application.html.erb index 9ce52441..51f30849 100644 --- a/app/views/layouts/application.html.erb +++ b/app/views/layouts/application.html.erb @@ -8,6 +8,8 @@ <%= stylesheet_link_tag "application", "data-turbo-track": "reload" %> <%= javascript_importmap_tags %> + + diff --git a/app/views/todo_lists/_form.html.erb b/app/views/todo_lists/_form.html.erb new file mode 100644 index 00000000..96d38833 --- /dev/null +++ b/app/views/todo_lists/_form.html.erb @@ -0,0 +1,23 @@ +<%= form_with(model: todo_list, data: { turbo: true }) do |f| %> + <% if todo_list.errors.any? %> +
+
    + <% todo_list.errors.full_messages.each do |message| %> +
  • <%= message %>
  • + <% end %> +
+
+ <% end %> + +
+ <%= f.text_field :name, + class: "form-control", + placeholder: "Enter new todo list name...", + required: true, + data: { + controller: "reset-form", + action: "keyup->reset-form#clearOnEscape" + } %> + <%= f.submit class: "btn btn-primary" %> +
+<% end %> diff --git a/app/views/todo_lists/_todo_list.html.erb b/app/views/todo_lists/_todo_list.html.erb new file mode 100644 index 00000000..9939d8fb --- /dev/null +++ b/app/views/todo_lists/_todo_list.html.erb @@ -0,0 +1,25 @@ +<%= turbo_frame_tag dom_id(todo_list) do %> +
+
+
+ <%= link_to todo_list.name, + todo_list_path(todo_list), + class: "text-decoration-none", + data: { turbo_frame: "_top" } %> +
+
+ <%= link_to edit_todo_list_path(todo_list), + class: "btn btn-sm btn-warning me-2", + data: { turbo_frame: "_top" } do %> + + <% end %> + <%= button_to todo_list_path(todo_list), + method: :delete, + class: "btn btn-sm btn-danger", + form: { class: 'd-inline', data: { turbo_confirm: 'Are you sure?' } } do %> + + <% end %> +
+
+
+<% end %> diff --git a/app/views/todo_lists/edit.html.erb b/app/views/todo_lists/edit.html.erb new file mode 100644 index 00000000..39894e5e --- /dev/null +++ b/app/views/todo_lists/edit.html.erb @@ -0,0 +1,14 @@ +
+
+
+

Edit Todo List

+ <%= link_to todo_lists_path, class: "btn btn-secondary" do %> + + <% end %> +
+
+ <%= render 'form', todo_list: @todo_list %> +
+
+ +
diff --git a/app/views/todo_lists/index.html.erb b/app/views/todo_lists/index.html.erb index 39745aef..b3752c0f 100644 --- a/app/views/todo_lists/index.html.erb +++ b/app/views/todo_lists/index.html.erb @@ -1,9 +1,24 @@ -<%= link_to 'Add Todo List', new_todo_list_path %> +
+
+
+

Todo Lists

+
+
+ <%= turbo_frame_tag "new_todo_list" do %> + <%= render "form", todo_list: TodoList.new %> + <% end %> + +
+ <%= turbo_frame_tag "todo_lists" do %> + <% @todo_lists.each do |todo_list| %> + <%= render "todo_list", todo_list: todo_list %> + <% end %> + <% end %> +
-
- <% @todo_lists.each do |todo_list| %> -
- #<%= todo_list.id %> | <%= todo_list.name %> +
+ <%= paginate @todo_lists %> +
- <% end %> +
diff --git a/app/views/todo_lists/new.html.erb b/app/views/todo_lists/new.html.erb deleted file mode 100644 index e69de29b..00000000 diff --git a/app/views/todo_lists/show.html.erb b/app/views/todo_lists/show.html.erb new file mode 100644 index 00000000..d6fbf838 --- /dev/null +++ b/app/views/todo_lists/show.html.erb @@ -0,0 +1,27 @@ +
+
+
+

<%= @todo_list.name %>

+
+ <%= link_to edit_todo_list_path(@todo_list), class: "btn btn-warning" do %> + + <% end %> + <%= link_to todo_lists_path, class: "btn btn-secondary" do %> + + <% end %> +
+
+ +
+
+ <%= turbo_frame_tag "new_item" do %> + <%= render "items/form", todo_list: @todo_list, item: Item.new %> + <% end %> +
+ + <%= turbo_frame_tag "items_list" do %> + <%= render partial: 'items/list', locals: { todo_list: @todo_list } %> + <% end %> +
+
+
diff --git a/config/importmap.rb b/config/importmap.rb index 8dce42d4..7e9f7e67 100644 --- a/config/importmap.rb +++ b/config/importmap.rb @@ -5,3 +5,441 @@ pin "@hotwired/stimulus", to: "stimulus.min.js", preload: true pin "@hotwired/stimulus-loading", to: "stimulus-loading.js", preload: true pin_all_from "app/javascript/controllers", under: "controllers" +pin "bootstrap", to: "https://ga.jspm.io/npm:bootstrap@5.3.3/dist/js/bootstrap.esm.js" +pin "popper", to: "https://ga.jspm.io/npm:popper@1.0.1/index.js" +pin "#lib/internal/streams/from.js", to: "https://ga.jspm.io/npm:readable-stream@3.6.2/lib/internal/streams/from-browser.js" +pin "#lib/internal/streams/stream.js", to: "https://ga.jspm.io/npm:readable-stream@2.3.8/lib/internal/streams/stream-browser.js" +pin "#readable.js", to: "https://ga.jspm.io/npm:readable-stream@2.3.8/readable-browser.js" +pin "#util.inspect.js", to: "https://ga.jspm.io/npm:@jspm/core@2.1.0/nodelibs/@empty.js" +pin "@compone/class", to: "https://ga.jspm.io/npm:@compone/class@1.1.1/index.js" +pin "@compone/define", to: "https://ga.jspm.io/npm:@compone/define@1.2.4/index.js" +pin "@compone/event", to: "https://ga.jspm.io/npm:@compone/event@1.1.2/index.js" +pin "@popperjs/core", to: "https://ga.jspm.io/npm:@popperjs/core@2.11.8/lib/index.js" +pin "@sindresorhus/is", to: "https://ga.jspm.io/npm:@sindresorhus/is@4.6.0/dist/index.js" +pin "@szmarczak/http-timer", to: "https://ga.jspm.io/npm:@szmarczak/http-timer@4.0.5/dist/source/index.js" +pin "JSONStream", to: "https://ga.jspm.io/npm:JSONStream@1.3.5/index.js" +pin "accepts", to: "https://ga.jspm.io/npm:accepts@1.3.8/index.js" +pin "acorn", to: "https://ga.jspm.io/npm:acorn@7.4.1/dist/acorn.js" +pin "acorn-node", to: "https://ga.jspm.io/npm:acorn-node@1.8.2/index.js" +pin "acorn/dist/walk", to: "https://ga.jspm.io/npm:acorn@5.7.4/dist/walk.js" +pin "ajv", to: "https://ga.jspm.io/npm:ajv@6.12.6/lib/ajv.js" +pin "ajv/lib/refs/json-schema-draft-06.json", to: "https://ga.jspm.io/npm:ajv@6.12.6/lib/refs/json-schema-draft-06.json.js" +pin "anymatch", to: "https://ga.jspm.io/npm:anymatch@1.3.2/index.js" +pin "archiver", to: "https://ga.jspm.io/npm:archiver@3.1.1/index.js" +pin "archiver-utils", to: "https://ga.jspm.io/npm:archiver-utils@2.1.0/index.js" +pin "arr-diff", to: "https://ga.jspm.io/npm:arr-diff@2.0.0/index.js" +pin "arr-flatten", to: "https://ga.jspm.io/npm:arr-flatten@1.1.0/index.js" +pin "arr-union", to: "https://ga.jspm.io/npm:arr-union@3.1.0/index.js" +pin "array-flatten", to: "https://ga.jspm.io/npm:array-flatten@1.1.1/array-flatten.js" +pin "array-unique", to: "https://ga.jspm.io/npm:array-unique@0.2.1/index.js" +pin "asn1", to: "https://ga.jspm.io/npm:asn1@0.2.6/lib/index.js" +pin "assert", to: "https://ga.jspm.io/npm:@jspm/core@2.1.0/nodelibs/browser/assert.js" +pin "assert-plus", to: "https://ga.jspm.io/npm:assert-plus@1.0.0/assert.js" +pin "assign-symbols", to: "https://ga.jspm.io/npm:assign-symbols@1.0.0/index.js" +pin "async", to: "https://ga.jspm.io/npm:async@2.6.4/dist/async.js" +pin "async-each", to: "https://ga.jspm.io/npm:async-each@1.0.6/index.js" +pin "async_hooks", to: "https://ga.jspm.io/npm:@jspm/core@2.1.0/nodelibs/browser/async_hooks.js" +pin "aws-sign2", to: "https://ga.jspm.io/npm:aws-sign2@0.7.0/index.js" +pin "aws4", to: "https://ga.jspm.io/npm:aws4@1.13.2/aws4.js" +pin "balanced-match", to: "https://ga.jspm.io/npm:balanced-match@1.0.2/index.js" +pin "base", to: "https://ga.jspm.io/npm:base@0.11.2/index.js" +pin "base64-js", to: "https://ga.jspm.io/npm:base64-js@1.5.1/index.js" +pin "bcrypt-pbkdf", to: "https://ga.jspm.io/npm:bcrypt-pbkdf@1.0.2/index.js" +pin "binary-extensions", to: "https://ga.jspm.io/npm:binary-extensions@1.13.1/binary-extensions.json.js" +pin "bindings", to: "https://ga.jspm.io/npm:bindings@1.5.0/bindings.js" +pin "bl", to: "https://ga.jspm.io/npm:bl@4.1.0/bl.js" +pin "body-parser", to: "https://ga.jspm.io/npm:body-parser@1.20.3/index.js" +pin "brace-expansion", to: "https://ga.jspm.io/npm:brace-expansion@1.1.11/index.js" +pin "braces", to: "https://ga.jspm.io/npm:braces@1.8.5/index.js" +pin "browser-icons", to: "https://ga.jspm.io/npm:browser-icons@0.0.1/index.js" +pin "browser-pack", to: "https://ga.jspm.io/npm:browser-pack@6.1.0/index.js" +pin "browser-resolve", to: "https://ga.jspm.io/npm:browser-resolve@1.11.3/index.js" +pin "browserify", to: "https://ga.jspm.io/npm:browserify@12.0.2/index.js" +pin "buffer", to: "https://ga.jspm.io/npm:@jspm/core@2.1.0/nodelibs/browser/buffer.js" +pin "buffer-crc32", to: "https://ga.jspm.io/npm:buffer-crc32@0.2.13/index.js" +pin "bytes", to: "https://ga.jspm.io/npm:bytes@3.1.2/index.js" +pin "cache-base", to: "https://ga.jspm.io/npm:cache-base@1.0.1/index.js" +pin "cacheable-lookup", to: "https://ga.jspm.io/npm:cacheable-lookup@5.0.4/source/index.js" +pin "cacheable-request", to: "https://ga.jspm.io/npm:cacheable-request@7.0.4/src/index.js" +pin "cached-path-relative", to: "https://ga.jspm.io/npm:cached-path-relative@1.1.0/lib/index.js" +pin "call-bind", to: "https://ga.jspm.io/npm:call-bind@1.0.8/index.js" +pin "call-bind-apply-helpers", to: "https://ga.jspm.io/npm:call-bind-apply-helpers@1.0.1/index.js" +pin "call-bind-apply-helpers/applyBind", to: "https://ga.jspm.io/npm:call-bind-apply-helpers@1.0.1/applyBind.js" +pin "call-bind-apply-helpers/functionApply", to: "https://ga.jspm.io/npm:call-bind-apply-helpers@1.0.1/functionApply.js" +pin "call-bind-apply-helpers/functionCall", to: "https://ga.jspm.io/npm:call-bind-apply-helpers@1.0.1/functionCall.js" +pin "call-bound", to: "https://ga.jspm.io/npm:call-bound@1.0.2/index.js" +pin "caseless", to: "https://ga.jspm.io/npm:caseless@0.12.0/index.js" +pin "child_process", to: "https://ga.jspm.io/npm:@jspm/core@2.1.0/nodelibs/browser/child_process.js" +pin "chokidar", to: "https://ga.jspm.io/npm:chokidar@1.7.0/index.js" +pin "class-utils", to: "https://ga.jspm.io/npm:class-utils@0.3.6/index.js" +pin "clone-response", to: "https://ga.jspm.io/npm:clone-response@1.0.3/src/index.js" +pin "collection-visit", to: "https://ga.jspm.io/npm:collection-visit@1.0.0/index.js" +pin "colors", to: "https://ga.jspm.io/npm:colors@1.4.2/lib/index.js" +pin "combine-source-map", to: "https://ga.jspm.io/npm:combine-source-map@0.8.0/index.js" +pin "combined-stream", to: "https://ga.jspm.io/npm:combined-stream@1.0.8/lib/combined_stream.js" +pin "component-emitter", to: "https://ga.jspm.io/npm:component-emitter@1.3.0/index.js" +pin "compress-commons", to: "https://ga.jspm.io/npm:compress-commons@2.1.1/lib/compress-commons.js" +pin "compressible", to: "https://ga.jspm.io/npm:compressible@2.0.18/index.js" +pin "compression", to: "https://ga.jspm.io/npm:compression@1.7.5/index.js" +pin "concat-map", to: "https://ga.jspm.io/npm:concat-map@0.0.1/index.js" +pin "concat-stream", to: "https://ga.jspm.io/npm:concat-stream@1.5.2/index.js" +pin "constants", to: "https://ga.jspm.io/npm:@jspm/core@2.1.0/nodelibs/browser/constants.js" +pin "content-disposition", to: "https://ga.jspm.io/npm:content-disposition@0.5.4/index.js" +pin "content-type", to: "https://ga.jspm.io/npm:content-type@1.0.5/index.js" +pin "convert-source-map", to: "https://ga.jspm.io/npm:convert-source-map@1.1.3/index.js" +pin "cookie", to: "https://ga.jspm.io/npm:cookie@0.7.1/index.js" +pin "cookie-parser", to: "https://ga.jspm.io/npm:cookie-parser@1.4.7/index.js" +pin "cookie-signature", to: "https://ga.jspm.io/npm:cookie-signature@1.0.6/index.js" +pin "copy-descriptor", to: "https://ga.jspm.io/npm:copy-descriptor@0.1.1/index.js" +pin "core-util-is", to: "https://ga.jspm.io/npm:core-util-is@1.0.2/lib/util.js" +pin "crc", to: "https://ga.jspm.io/npm:crc@3.8.0/lib/index.js" +pin "crc32-stream", to: "https://ga.jspm.io/npm:crc32-stream@3.0.1/lib/index.js" +pin "cryonic", to: "https://ga.jspm.io/npm:cryonic@1.0.0/lib/cryo.js" +pin "crypto", to: "https://ga.jspm.io/npm:@jspm/core@2.1.0/nodelibs/browser/crypto.js" +pin "dash-ast", to: "https://ga.jspm.io/npm:dash-ast@1.0.0/index.js" +pin "debug", to: "https://ga.jspm.io/npm:debug@2.6.9/src/browser.js" +pin "decompress-response", to: "https://ga.jspm.io/npm:decompress-response@6.0.0/index.js" +pin "defer-to-connect", to: "https://ga.jspm.io/npm:defer-to-connect@2.0.1/dist/source/index.js" +pin "define-data-property", to: "https://ga.jspm.io/npm:define-data-property@1.1.4/index.js" +pin "define-property", to: "https://ga.jspm.io/npm:define-property@2.0.2/index.js" +pin "defined", to: "https://ga.jspm.io/npm:defined@1.0.1/index.js" +pin "delayed-stream", to: "https://ga.jspm.io/npm:delayed-stream@1.0.0/lib/delayed_stream.js" +pin "depd", to: "https://ga.jspm.io/npm:depd@2.0.0/lib/browser/index.js" +pin "deps-sort", to: "https://ga.jspm.io/npm:deps-sort@2.0.1/index.js" +pin "destroy", to: "https://ga.jspm.io/npm:destroy@1.2.0/index.js" +pin "detective", to: "https://ga.jspm.io/npm:detective@4.7.1/index.js" +pin "djbx", to: "https://ga.jspm.io/npm:djbx@1.0.3/index.js" +pin "dns", to: "https://ga.jspm.io/npm:@jspm/core@2.1.0/nodelibs/browser/dns.js" +pin "dunder-proto/get", to: "https://ga.jspm.io/npm:dunder-proto@1.0.0/get.js" +pin "duplexer2", to: "https://ga.jspm.io/npm:duplexer2@0.1.4/index.js" +pin "ecc-jsbn", to: "https://ga.jspm.io/npm:ecc-jsbn@0.1.2/index.js" +pin "ecc-jsbn/lib/ec", to: "https://ga.jspm.io/npm:ecc-jsbn@0.1.2/lib/ec.js" +pin "ee-first", to: "https://ga.jspm.io/npm:ee-first@1.1.1/index.js" +pin "encodeurl", to: "https://ga.jspm.io/npm:encodeurl@2.0.0/index.js" +pin "end-of-stream", to: "https://ga.jspm.io/npm:end-of-stream@1.4.4/index.js" +pin "es-define-property", to: "https://ga.jspm.io/npm:es-define-property@1.0.1/index.js" +pin "es-errors", to: "https://ga.jspm.io/npm:es-errors@1.3.0/index.js" +pin "es-errors/eval", to: "https://ga.jspm.io/npm:es-errors@1.3.0/eval.js" +pin "es-errors/range", to: "https://ga.jspm.io/npm:es-errors@1.3.0/range.js" +pin "es-errors/ref", to: "https://ga.jspm.io/npm:es-errors@1.3.0/ref.js" +pin "es-errors/syntax", to: "https://ga.jspm.io/npm:es-errors@1.3.0/syntax.js" +pin "es-errors/type", to: "https://ga.jspm.io/npm:es-errors@1.3.0/type.js" +pin "es-errors/uri", to: "https://ga.jspm.io/npm:es-errors@1.3.0/uri.js" +pin "es-object-atoms", to: "https://ga.jspm.io/npm:es-object-atoms@1.1.1/index.js" +pin "escape-html", to: "https://ga.jspm.io/npm:escape-html@1.0.3/index.js" +pin "etag", to: "https://ga.jspm.io/npm:etag@1.8.1/index.js" +pin "events", to: "https://ga.jspm.io/npm:events@1.1.1/events.js" +pin "expand-brackets", to: "https://ga.jspm.io/npm:expand-brackets@0.1.5/index.js" +pin "expand-range", to: "https://ga.jspm.io/npm:expand-range@1.8.2/index.js" +pin "express", to: "https://ga.jspm.io/npm:express@4.21.2/index.js" +pin "express-session", to: "https://ga.jspm.io/npm:express-session@1.18.1/index.js" +pin "extend", to: "https://ga.jspm.io/npm:extend@3.0.2/index.js" +pin "extend-shallow", to: "https://ga.jspm.io/npm:extend-shallow@3.0.2/index.js" +pin "extglob", to: "https://ga.jspm.io/npm:extglob@0.3.2/index.js" +pin "extsprintf", to: "https://ga.jspm.io/npm:extsprintf@1.3.0/lib/extsprintf.js" +pin "fast-deep-equal", to: "https://ga.jspm.io/npm:fast-deep-equal@3.1.3/index.js" +pin "fast-json-stable-stringify", to: "https://ga.jspm.io/npm:fast-json-stable-stringify@2.1.0/index.js" +pin "fast-safe-stringify", to: "https://ga.jspm.io/npm:fast-safe-stringify@2.1.1/index.js" +pin "file-uri-to-path", to: "https://ga.jspm.io/npm:file-uri-to-path@1.0.0/index.js" +pin "filename-regex", to: "https://ga.jspm.io/npm:filename-regex@2.0.1/index.js" +pin "fill-range", to: "https://ga.jspm.io/npm:fill-range@4.0.0/index.js" +pin "finalhandler", to: "https://ga.jspm.io/npm:finalhandler@1.3.1/index.js" +pin "find-package-json", to: "https://ga.jspm.io/npm:find-package-json@1.2.0/index.js" +pin "for-in", to: "https://ga.jspm.io/npm:for-in@1.0.2/index.js" +pin "for-own", to: "https://ga.jspm.io/npm:for-own@0.1.5/index.js" +pin "forever-agent", to: "https://ga.jspm.io/npm:forever-agent@0.6.1/index.js" +pin "form-data", to: "https://ga.jspm.io/npm:form-data@2.3.3/lib/browser.js" +pin "forwarded", to: "https://ga.jspm.io/npm:forwarded@0.2.0/index.js" +pin "fragment-cache", to: "https://ga.jspm.io/npm:fragment-cache@0.2.1/index.js" +pin "fresh", to: "https://ga.jspm.io/npm:fresh@0.5.2/index.js" +pin "fs", to: "https://ga.jspm.io/npm:@jspm/core@2.1.0/nodelibs/browser/fs.js" +pin "fs-constants", to: "https://ga.jspm.io/npm:fs-constants@1.0.0/browser.js" +pin "fs.realpath", to: "https://ga.jspm.io/npm:fs.realpath@1.0.0/index.js" +pin "fsevents", to: "https://ga.jspm.io/npm:fsevents@1.2.13/fsevents.js" +pin "function-bind", to: "https://ga.jspm.io/npm:function-bind@1.1.2/index.js" +pin "get-assigned-identifiers", to: "https://ga.jspm.io/npm:get-assigned-identifiers@1.2.0/index.js" +pin "get-intrinsic", to: "https://ga.jspm.io/npm:get-intrinsic@1.2.7/index.js" +pin "get-proto", to: "https://ga.jspm.io/npm:get-proto@1.0.1/index.js" +pin "get-proto/Object.getPrototypeOf", to: "https://ga.jspm.io/npm:get-proto@1.0.1/Object.getPrototypeOf.js" +pin "get-proto/Reflect.getPrototypeOf", to: "https://ga.jspm.io/npm:get-proto@1.0.1/Reflect.getPrototypeOf.js" +pin "get-stream", to: "https://ga.jspm.io/npm:get-stream@5.2.0/index.js" +pin "get-value", to: "https://ga.jspm.io/npm:get-value@2.0.6/index.js" +pin "glob", to: "https://ga.jspm.io/npm:glob@7.2.3/glob.js" +pin "glob-base", to: "https://ga.jspm.io/npm:glob-base@0.3.0/index.js" +pin "glob-parent", to: "https://ga.jspm.io/npm:glob-parent@2.0.0/index.js" +pin "gopd", to: "https://ga.jspm.io/npm:gopd@1.2.0/index.js" +pin "got", to: "https://ga.jspm.io/npm:got@11.8.6/dist/source/index.js" +pin "graceful-fs", to: "https://ga.jspm.io/npm:graceful-fs@4.2.11/graceful-fs.js" +pin "har-schema", to: "https://ga.jspm.io/npm:har-schema@2.0.0/lib/index.js" +pin "har-validator", to: "https://ga.jspm.io/npm:har-validator@5.1.5/lib/promise.js" +pin "has", to: "https://ga.jspm.io/npm:has@1.0.4/src/index.js" +pin "has-property-descriptors", to: "https://ga.jspm.io/npm:has-property-descriptors@1.0.2/index.js" +pin "has-symbols", to: "https://ga.jspm.io/npm:has-symbols@1.1.0/index.js" +pin "has-value", to: "https://ga.jspm.io/npm:has-value@1.0.0/index.js" +pin "has-values", to: "https://ga.jspm.io/npm:has-values@1.0.0/index.js" +pin "hasown", to: "https://ga.jspm.io/npm:hasown@2.0.2/index.js" +pin "htmlescape", to: "https://ga.jspm.io/npm:htmlescape@1.1.1/htmlescape.js" +pin "http", to: "https://ga.jspm.io/npm:@jspm/core@2.1.0/nodelibs/browser/http.js" +pin "http-cache-semantics", to: "https://ga.jspm.io/npm:http-cache-semantics@4.1.1/index.js" +pin "http-errors", to: "https://ga.jspm.io/npm:http-errors@2.0.0/index.js" +pin "http-signature", to: "https://ga.jspm.io/npm:http-signature@1.2.0/lib/index.js" +pin "http2", to: "https://ga.jspm.io/npm:@jspm/core@2.1.0/nodelibs/browser/http2.js" +pin "http2-wrapper", to: "https://ga.jspm.io/npm:http2-wrapper@1.0.3/source/index.js" +pin "https", to: "https://ga.jspm.io/npm:@jspm/core@2.1.0/nodelibs/browser/https.js" +pin "icon-android", to: "https://ga.jspm.io/npm:icon-android@0.0.1/index.js" +pin "icon-chrome", to: "https://ga.jspm.io/npm:icon-chrome@0.0.1/index.js" +pin "icon-firefox", to: "https://ga.jspm.io/npm:icon-firefox@0.0.1/index.js" +pin "icon-ie", to: "https://ga.jspm.io/npm:icon-ie@0.0.1/index.js" +pin "icon-ios", to: "https://ga.jspm.io/npm:icon-ios@0.0.1/index.js" +pin "icon-linux", to: "https://ga.jspm.io/npm:icon-linux@0.0.1/index.js" +pin "icon-opera", to: "https://ga.jspm.io/npm:icon-opera@0.0.1/index.js" +pin "icon-osx", to: "https://ga.jspm.io/npm:icon-osx@0.0.1/index.js" +pin "icon-safari", to: "https://ga.jspm.io/npm:icon-safari@0.0.1/index.js" +pin "icon-windows", to: "https://ga.jspm.io/npm:icon-windows@0.0.1/index.js" +pin "iconv-lite", to: "https://ga.jspm.io/npm:iconv-lite@0.4.24/lib/index.js" +pin "ieee754", to: "https://ga.jspm.io/npm:ieee754@1.2.1/index.js" +pin "inflight", to: "https://ga.jspm.io/npm:inflight@1.0.6/inflight.js" +pin "inherits", to: "https://ga.jspm.io/npm:inherits@2.0.4/inherits_browser.js" +pin "inline-source-map", to: "https://ga.jspm.io/npm:inline-source-map@0.6.3/index.js" +pin "insert-module-globals", to: "https://ga.jspm.io/npm:insert-module-globals@7.2.1/index.js" +pin "ipaddr.js", to: "https://ga.jspm.io/npm:ipaddr.js@1.9.1/lib/ipaddr.js" +pin "is-accessor-descriptor", to: "https://ga.jspm.io/npm:is-accessor-descriptor@1.0.1/index.js" +pin "is-binary-path", to: "https://ga.jspm.io/npm:is-binary-path@1.0.1/index.js" +pin "is-buffer", to: "https://ga.jspm.io/npm:is-buffer@1.1.6/index.js" +pin "is-data-descriptor", to: "https://ga.jspm.io/npm:is-data-descriptor@1.0.1/index.js" +pin "is-descriptor", to: "https://ga.jspm.io/npm:is-descriptor@1.0.3/index.js" +pin "is-dotfile", to: "https://ga.jspm.io/npm:is-dotfile@1.0.3/index.js" +pin "is-equal-shallow", to: "https://ga.jspm.io/npm:is-equal-shallow@0.1.3/index.js" +pin "is-extendable", to: "https://ga.jspm.io/npm:is-extendable@0.1.1/index.js" +pin "is-extglob", to: "https://ga.jspm.io/npm:is-extglob@1.0.0/index.js" +pin "is-glob", to: "https://ga.jspm.io/npm:is-glob@2.0.1/index.js" +pin "is-number", to: "https://ga.jspm.io/npm:is-number@3.0.0/index.js" +pin "is-plain-object", to: "https://ga.jspm.io/npm:is-plain-object@2.0.4/index.js" +pin "is-posix-bracket", to: "https://ga.jspm.io/npm:is-posix-bracket@0.1.1/index.js" +pin "is-primitive", to: "https://ga.jspm.io/npm:is-primitive@2.0.0/index.js" +pin "is-typedarray", to: "https://ga.jspm.io/npm:is-typedarray@1.0.0/index.js" +pin "is-windows", to: "https://ga.jspm.io/npm:is-windows@1.0.2/index.js" +pin "isarray", to: "https://ga.jspm.io/npm:isarray@0.0.1/index.js" +pin "isobject", to: "https://ga.jspm.io/npm:isobject@3.0.1/index.js" +pin "isstream", to: "https://ga.jspm.io/npm:isstream@0.1.2/isstream.js" +pin "jsbn", to: "https://ga.jspm.io/npm:jsbn@0.1.1/index.js" +pin "json-buffer", to: "https://ga.jspm.io/npm:json-buffer@3.0.1/index.js" +pin "json-schema", to: "https://ga.jspm.io/npm:json-schema@0.4.0/lib/validate.js" +pin "json-schema-traverse", to: "https://ga.jspm.io/npm:json-schema-traverse@0.4.1/index.js" +pin "json-stable-stringify", to: "https://ga.jspm.io/npm:json-stable-stringify@0.0.1/index.js" +pin "json-stringify-safe", to: "https://ga.jspm.io/npm:json-stringify-safe@5.0.1/stringify.js" +pin "jsonify", to: "https://ga.jspm.io/npm:jsonify@0.0.1/index.js" +pin "jsonparse", to: "https://ga.jspm.io/npm:jsonparse@1.3.1/jsonparse.js" +pin "jsprim", to: "https://ga.jspm.io/npm:jsprim@1.4.2/lib/jsprim.js" +pin "keyv", to: "https://ga.jspm.io/npm:keyv@4.5.4/src/index.js" +pin "kind-of", to: "https://ga.jspm.io/npm:kind-of@3.2.2/index.js" +pin "labeled-stream-splicer", to: "https://ga.jspm.io/npm:labeled-stream-splicer@2.0.2/index.js" +pin "lazystream", to: "https://ga.jspm.io/npm:lazystream@1.0.1/lib/lazystream.js" +pin "lodash", to: "https://ga.jspm.io/npm:lodash@4.17.21/lodash.js" +pin "lodash.clonedeep", to: "https://ga.jspm.io/npm:lodash.clonedeep@4.5.0/index.js" +pin "lodash.defaults", to: "https://ga.jspm.io/npm:lodash.defaults@4.2.0/index.js" +pin "lodash.difference", to: "https://ga.jspm.io/npm:lodash.difference@4.5.0/index.js" +pin "lodash.flatten", to: "https://ga.jspm.io/npm:lodash.flatten@4.4.0/index.js" +pin "lodash.isplainobject", to: "https://ga.jspm.io/npm:lodash.isplainobject@4.0.6/index.js" +pin "lodash.memoize", to: "https://ga.jspm.io/npm:lodash.memoize@3.0.4/index.js" +pin "lodash.union", to: "https://ga.jspm.io/npm:lodash.union@4.6.0/index.js" +pin "lowercase-keys", to: "https://ga.jspm.io/npm:lowercase-keys@2.0.0/index.js" +pin "map-cache", to: "https://ga.jspm.io/npm:map-cache@0.2.2/index.js" +pin "map-visit", to: "https://ga.jspm.io/npm:map-visit@1.0.0/index.js" +pin "math-intrinsics/abs", to: "https://ga.jspm.io/npm:math-intrinsics@1.1.0/abs.js" +pin "math-intrinsics/floor", to: "https://ga.jspm.io/npm:math-intrinsics@1.1.0/floor.js" +pin "math-intrinsics/max", to: "https://ga.jspm.io/npm:math-intrinsics@1.1.0/max.js" +pin "math-intrinsics/min", to: "https://ga.jspm.io/npm:math-intrinsics@1.1.0/min.js" +pin "math-intrinsics/pow", to: "https://ga.jspm.io/npm:math-intrinsics@1.1.0/pow.js" +pin "math-intrinsics/round", to: "https://ga.jspm.io/npm:math-intrinsics@1.1.0/round.js" +pin "math-intrinsics/sign", to: "https://ga.jspm.io/npm:math-intrinsics@1.1.0/sign.js" +pin "math-random", to: "https://ga.jspm.io/npm:math-random@1.0.4/browser.js" +pin "media-typer", to: "https://ga.jspm.io/npm:media-typer@0.3.0/index.js" +pin "merge-descriptors", to: "https://ga.jspm.io/npm:merge-descriptors@1.0.3/index.js" +pin "methods", to: "https://ga.jspm.io/npm:methods@1.1.2/index.js" +pin "micromatch", to: "https://ga.jspm.io/npm:micromatch@2.3.11/index.js" +pin "mime", to: "https://ga.jspm.io/npm:mime@1.6.0/mime.js" +pin "mime-db", to: "https://ga.jspm.io/npm:mime-db@1.52.0/index.js" +pin "mime-types", to: "https://ga.jspm.io/npm:mime-types@2.1.35/index.js" +pin "mimic-response", to: "https://ga.jspm.io/npm:mimic-response@3.1.0/index.js" +pin "minimatch", to: "https://ga.jspm.io/npm:minimatch@3.1.2/minimatch.js" +pin "minimist", to: "https://ga.jspm.io/npm:minimist@1.2.8/index.js" +pin "mixin-deep", to: "https://ga.jspm.io/npm:mixin-deep@1.3.2/index.js" +pin "module-deps", to: "https://ga.jspm.io/npm:module-deps@4.1.1/index.js" +pin "ms", to: "https://ga.jspm.io/npm:ms@2.0.0/index.js" +pin "nanomatch", to: "https://ga.jspm.io/npm:nanomatch@1.2.13/index.js" +pin "nanosocket", to: "https://ga.jspm.io/npm:nanosocket@1.1.0/index.js" +pin "negotiator", to: "https://ga.jspm.io/npm:negotiator@0.6.4/index.js" +pin "net", to: "https://ga.jspm.io/npm:@jspm/core@2.1.0/nodelibs/browser/net.js" +pin "ngrok", to: "https://ga.jspm.io/npm:ngrok@5.0.0-beta.2/index.js" +pin "normalize-path", to: "https://ga.jspm.io/npm:normalize-path@2.1.1/index.js" +pin "normalize-url", to: "https://ga.jspm.io/npm:normalize-url@6.1.0/index.js" +pin "oauth-sign", to: "https://ga.jspm.io/npm:oauth-sign@0.9.0/index.js" +pin "object-copy", to: "https://ga.jspm.io/npm:object-copy@0.1.0/index.js" +pin "object-inspect", to: "https://ga.jspm.io/npm:object-inspect@1.13.3/index.js" +pin "object-visit", to: "https://ga.jspm.io/npm:object-visit@1.0.1/index.js" +pin "object.omit", to: "https://ga.jspm.io/npm:object.omit@2.0.1/index.js" +pin "object.pick", to: "https://ga.jspm.io/npm:object.pick@1.3.0/index.js" +pin "on-finished", to: "https://ga.jspm.io/npm:on-finished@2.4.1/index.js" +pin "on-headers", to: "https://ga.jspm.io/npm:on-headers@1.0.2/index.js" +pin "once", to: "https://ga.jspm.io/npm:once@1.4.0/once.js" +pin "os", to: "https://ga.jspm.io/npm:@jspm/core@2.1.0/nodelibs/browser/os.js" +pin "p-cancelable", to: "https://ga.jspm.io/npm:p-cancelable@2.1.1/index.js" +pin "parents", to: "https://ga.jspm.io/npm:parents@1.0.1/index.js" +pin "parse-glob", to: "https://ga.jspm.io/npm:parse-glob@3.0.4/index.js" +pin "parseurl", to: "https://ga.jspm.io/npm:parseurl@1.3.3/index.js" +pin "pascalcase", to: "https://ga.jspm.io/npm:pascalcase@0.1.1/index.js" +pin "path", to: "https://ga.jspm.io/npm:@jspm/core@2.1.0/nodelibs/browser/path.js" +pin "path-is-absolute", to: "https://ga.jspm.io/npm:path-is-absolute@1.0.1/index.js" +pin "path-platform", to: "https://ga.jspm.io/npm:path-platform@0.11.15/path.js" +pin "path-to-regexp", to: "https://ga.jspm.io/npm:path-to-regexp@0.1.12/index.js" +pin "performance-now", to: "https://ga.jspm.io/npm:performance-now@2.1.0/lib/performance-now.js" +pin "platform", to: "https://ga.jspm.io/npm:platform@1.3.6/platform.js" +pin "posix-character-classes", to: "https://ga.jspm.io/npm:posix-character-classes@0.1.1/index.js" +pin "preserve", to: "https://ga.jspm.io/npm:preserve@0.2.0/index.js" +pin "process", to: "https://ga.jspm.io/npm:@jspm/core@2.1.0/nodelibs/browser/process-production.js" +pin "process-nextick-args", to: "https://ga.jspm.io/npm:process-nextick-args@2.0.1/index.js" +pin "proxy-addr", to: "https://ga.jspm.io/npm:proxy-addr@2.0.7/index.js" +pin "psl", to: "https://ga.jspm.io/npm:psl@1.15.0/dist/psl.cjs" +pin "pump", to: "https://ga.jspm.io/npm:pump@3.0.2/index.js" +pin "punycode", to: "https://ga.jspm.io/npm:punycode@1.4.1/punycode.js" +pin "q", to: "https://ga.jspm.io/npm:q@1.5.1/q.js" +pin "qs", to: "https://ga.jspm.io/npm:qs@6.13.0/lib/index.js" +pin "querystring", to: "https://ga.jspm.io/npm:@jspm/core@2.1.0/nodelibs/browser/querystring.js" +pin "quick-lru", to: "https://ga.jspm.io/npm:quick-lru@5.1.1/index.js" +pin "random-bytes", to: "https://ga.jspm.io/npm:random-bytes@1.0.0/index.js" +pin "randomatic", to: "https://ga.jspm.io/npm:randomatic@3.1.1/index.js" +pin "range-parser", to: "https://ga.jspm.io/npm:range-parser@1.2.1/index.js" +pin "raw-body", to: "https://ga.jspm.io/npm:raw-body@2.5.2/index.js" +pin "read-only-stream", to: "https://ga.jspm.io/npm:read-only-stream@2.0.0/index.js" +pin "readable-stream", to: "https://ga.jspm.io/npm:readable-stream@2.0.6/readable.js" +pin "readable-stream/passthrough", to: "https://ga.jspm.io/npm:readable-stream@2.3.8/passthrough.js" +pin "readdirp", to: "https://ga.jspm.io/npm:readdirp@2.2.1/readdirp.js" +pin "regex-cache", to: "https://ga.jspm.io/npm:regex-cache@0.4.4/index.js" +pin "regex-not", to: "https://ga.jspm.io/npm:regex-not@1.0.2/index.js" +pin "remove-trailing-separator", to: "https://ga.jspm.io/npm:remove-trailing-separator@1.1.0/index.js" +pin "repeat-element", to: "https://ga.jspm.io/npm:repeat-element@1.1.4/index.js" +pin "repeat-string", to: "https://ga.jspm.io/npm:repeat-string@1.6.1/index.js" +pin "request", to: "https://ga.jspm.io/npm:request@2.88.0/index.js" +pin "resolve", to: "https://ga.jspm.io/npm:resolve@1.1.7/index.js" +pin "resolve-alpn", to: "https://ga.jspm.io/npm:resolve-alpn@1.2.1/index.js" +pin "resolve-url", to: "https://ga.jspm.io/npm:resolve-url@0.2.1/resolve-url.js" +pin "responselike", to: "https://ga.jspm.io/npm:responselike@2.0.1/src/index.js" +pin "ret", to: "https://ga.jspm.io/npm:ret@0.1.15/lib/index.js" +pin "rijs", to: "https://ga.jspm.io/npm:rijs@0.9.1/index.js" +pin "rijs.components", to: "https://ga.jspm.io/npm:rijs.components@3.1.16/index.js" +pin "rijs.core", to: "https://ga.jspm.io/npm:rijs.core@1.2.6/index.js" +pin "rijs.css", to: "https://ga.jspm.io/npm:rijs.css@1.2.4/client.js" +pin "rijs.data", to: "https://ga.jspm.io/npm:rijs.data@2.2.4/index.js" +pin "rijs.fn", to: "https://ga.jspm.io/npm:rijs.fn@1.2.6/client.js" +pin "rijs.npm", to: "https://ga.jspm.io/npm:rijs.npm@2.0.0/index.js" +pin "rijs.pages", to: "https://ga.jspm.io/npm:rijs.pages@1.3.0/index.js" +pin "rijs.resdir", to: "https://ga.jspm.io/npm:rijs.resdir@1.4.4/index.js" +pin "rijs.serve", to: "https://ga.jspm.io/npm:rijs.serve@1.1.1/index.js" +pin "rijs.sessions", to: "https://ga.jspm.io/npm:rijs.sessions@1.1.2/index.js" +pin "rijs.singleton", to: "https://ga.jspm.io/npm:rijs.singleton@1.0.0/index.js" +pin "rijs.sync", to: "https://ga.jspm.io/npm:rijs.sync@2.3.5/client.js" +pin "safe-buffer", to: "https://ga.jspm.io/npm:safe-buffer@5.2.1/index.js" +pin "safe-regex", to: "https://ga.jspm.io/npm:safe-regex@1.1.0/index.js" +pin "safer-buffer", to: "https://ga.jspm.io/npm:safer-buffer@2.1.2/safer.js" +pin "send", to: "https://ga.jspm.io/npm:send@0.19.0/index.js" +pin "serve-static", to: "https://ga.jspm.io/npm:serve-static@1.16.2/index.js" +pin "set-function-length", to: "https://ga.jspm.io/npm:set-function-length@1.2.2/index.js" +pin "set-value", to: "https://ga.jspm.io/npm:set-value@2.0.1/index.js" +pin "setprototypeof", to: "https://ga.jspm.io/npm:setprototypeof@1.2.0/index.js" +pin "sha.js", to: "https://ga.jspm.io/npm:sha.js@2.4.11/index.js" +pin "shasum", to: "https://ga.jspm.io/npm:shasum@1.0.2/browser.js" +pin "shasum-object", to: "https://ga.jspm.io/npm:shasum-object@1.0.0/index.js" +pin "side-channel", to: "https://ga.jspm.io/npm:side-channel@1.1.0/index.js" +pin "side-channel-list", to: "https://ga.jspm.io/npm:side-channel-list@1.0.0/index.js" +pin "side-channel-map", to: "https://ga.jspm.io/npm:side-channel-map@1.0.1/index.js" +pin "side-channel-weakmap", to: "https://ga.jspm.io/npm:side-channel-weakmap@1.0.2/index.js" +pin "snapdragon", to: "https://ga.jspm.io/npm:snapdragon@0.8.2/index.js" +pin "snapdragon-node", to: "https://ga.jspm.io/npm:snapdragon-node@2.1.1/index.js" +pin "snapdragon-util", to: "https://ga.jspm.io/npm:snapdragon-util@3.0.1/index.js" +pin "source-map", to: "https://ga.jspm.io/npm:source-map@0.5.7/source-map.js" +pin "source-map-resolve", to: "https://ga.jspm.io/npm:source-map-resolve@0.5.3/source-map-resolve.js" +pin "source-map-url", to: "https://ga.jspm.io/npm:source-map-url@0.4.1/source-map-url.js" +pin "split-string", to: "https://ga.jspm.io/npm:split-string@3.1.0/index.js" +pin "sshpk", to: "https://ga.jspm.io/npm:sshpk@1.18.0/lib/index.js" +pin "static-extend", to: "https://ga.jspm.io/npm:static-extend@0.1.2/index.js" +pin "statuses", to: "https://ga.jspm.io/npm:statuses@2.0.1/index.js" +pin "stream", to: "https://ga.jspm.io/npm:@jspm/core@2.1.0/nodelibs/browser/stream.js" +pin "stream-combiner2", to: "https://ga.jspm.io/npm:stream-combiner2@1.1.1/index.js" +pin "stream-splicer", to: "https://ga.jspm.io/npm:stream-splicer@2.0.1/index.js" +pin "string_decoder", to: "https://ga.jspm.io/npm:string_decoder@1.1.1/lib/string_decoder.js" +pin "syntax-error", to: "https://ga.jspm.io/npm:syntax-error@1.4.0/index.js" +pin "tar-stream", to: "https://ga.jspm.io/npm:tar-stream@2.2.0/index.js" +pin "through", to: "https://ga.jspm.io/npm:through@2.3.8/index.js" +pin "through2", to: "https://ga.jspm.io/npm:through2@2.0.5/through2.js" +pin "tls", to: "https://ga.jspm.io/npm:@jspm/core@2.1.0/nodelibs/browser/tls.js" +pin "to-object-path", to: "https://ga.jspm.io/npm:to-object-path@0.3.0/index.js" +pin "to-regex", to: "https://ga.jspm.io/npm:to-regex@3.0.2/index.js" +pin "to-regex-range", to: "https://ga.jspm.io/npm:to-regex-range@2.1.1/index.js" +pin "toidentifier", to: "https://ga.jspm.io/npm:toidentifier@1.0.1/index.js" +pin "tough-cookie", to: "https://ga.jspm.io/npm:tough-cookie@2.4.3/lib/cookie.js" +pin "tunnel-agent", to: "https://ga.jspm.io/npm:tunnel-agent@0.6.0/index.js" +pin "tweetnacl", to: "https://ga.jspm.io/npm:tweetnacl@0.14.5/nacl-fast.js" +pin "type-is", to: "https://ga.jspm.io/npm:type-is@1.6.18/index.js" +pin "typedarray", to: "https://ga.jspm.io/npm:typedarray@0.0.7/index.js" +pin "uid-safe", to: "https://ga.jspm.io/npm:uid-safe@2.1.5/index.js" +pin "umd", to: "https://ga.jspm.io/npm:umd@3.0.3/index.js" +pin "undeclared-identifiers", to: "https://ga.jspm.io/npm:undeclared-identifiers@1.1.3/index.js" +pin "union-value", to: "https://ga.jspm.io/npm:union-value@1.0.1/index.js" +pin "unpipe", to: "https://ga.jspm.io/npm:unpipe@1.0.0/index.js" +pin "unset-value", to: "https://ga.jspm.io/npm:unset-value@1.0.0/index.js" +pin "uri-js", to: "https://ga.jspm.io/npm:uri-js@4.4.1/dist/es5/uri.all.js" +pin "url", to: "https://ga.jspm.io/npm:@jspm/core@2.1.0/nodelibs/browser/url.js" +pin "use", to: "https://ga.jspm.io/npm:use@3.1.1/index.js" +pin "util", to: "https://ga.jspm.io/npm:@jspm/core@2.1.0/nodelibs/browser/util.js" +pin "util-deprecate", to: "https://ga.jspm.io/npm:util-deprecate@1.0.2/browser.js" +pin "utilise/append", to: "https://ga.jspm.io/npm:utilise@2.3.8/append.js" +pin "utilise/attr", to: "https://ga.jspm.io/npm:utilise@2.3.8/attr.js" +pin "utilise/by", to: "https://ga.jspm.io/npm:utilise@2.3.8/by.js" +pin "utilise/client", to: "https://ga.jspm.io/npm:utilise@2.3.8/client.js" +pin "utilise/colorfill", to: "https://ga.jspm.io/npm:utilise@2.3.8/colorfill.js" +pin "utilise/deb", to: "https://ga.jspm.io/npm:utilise@2.3.8/deb.js" +pin "utilise/def", to: "https://ga.jspm.io/npm:utilise@2.3.8/def.js" +pin "utilise/emitterify", to: "https://ga.jspm.io/npm:utilise@2.3.8/emitterify.js" +pin "utilise/err", to: "https://ga.jspm.io/npm:utilise@2.3.8/err.js" +pin "utilise/extend", to: "https://ga.jspm.io/npm:utilise@2.3.8/extend.js" +pin "utilise/file", to: "https://ga.jspm.io/npm:utilise@2.3.8/file.js" +pin "utilise/flatten", to: "https://ga.jspm.io/npm:utilise@2.3.8/flatten.js" +pin "utilise/fn", to: "https://ga.jspm.io/npm:utilise@2.3.8/fn.js" +pin "utilise/header", to: "https://ga.jspm.io/npm:utilise@2.3.8/header.js" +pin "utilise/identity", to: "https://ga.jspm.io/npm:utilise@2.3.8/identity.js" +pin "utilise/includes", to: "https://ga.jspm.io/npm:utilise@2.3.8/includes.js" +pin "utilise/is", to: "https://ga.jspm.io/npm:utilise@2.3.8/is.js" +pin "utilise/key", to: "https://ga.jspm.io/npm:utilise@2.3.8/key.js" +pin "utilise/keys", to: "https://ga.jspm.io/npm:utilise@2.3.8/keys.js" +pin "utilise/lo", to: "https://ga.jspm.io/npm:utilise@2.3.8/lo.js" +pin "utilise/log", to: "https://ga.jspm.io/npm:utilise@2.3.8/log.js" +pin "utilise/merge", to: "https://ga.jspm.io/npm:utilise@2.3.8/merge.js" +pin "utilise/noop", to: "https://ga.jspm.io/npm:utilise@2.3.8/noop.js" +pin "utilise/not", to: "https://ga.jspm.io/npm:utilise@2.3.8/not.js" +pin "utilise/overwrite", to: "https://ga.jspm.io/npm:utilise@2.3.8/overwrite.js" +pin "utilise/owner", to: "https://ga.jspm.io/npm:utilise@2.3.8/owner.js" +pin "utilise/pure", to: "https://ga.jspm.io/npm:utilise@2.3.8/pure.js" +pin "utilise/ready", to: "https://ga.jspm.io/npm:utilise@2.3.8/ready.js" +pin "utilise/send", to: "https://ga.jspm.io/npm:utilise@2.3.8/send.js" +pin "utilise/set", to: "https://ga.jspm.io/npm:utilise@2.3.8/set.js" +pin "utilise/str", to: "https://ga.jspm.io/npm:utilise@2.3.8/str.js" +pin "utilise/time", to: "https://ga.jspm.io/npm:utilise@2.3.8/time.js" +pin "utilise/to", to: "https://ga.jspm.io/npm:utilise@2.3.8/to.js" +pin "utilise/update", to: "https://ga.jspm.io/npm:utilise@2.3.8/update.js" +pin "utilise/values", to: "https://ga.jspm.io/npm:utilise@2.3.8/values.js" +pin "utilise/za", to: "https://ga.jspm.io/npm:utilise@2.3.8/za.js" +pin "utils-merge", to: "https://ga.jspm.io/npm:utils-merge@1.0.1/index.js" +pin "uuid", to: "https://ga.jspm.io/npm:uuid@8.3.2/dist/esm-browser/index.js" +pin "uuid/lib/rng.js", to: "https://ga.jspm.io/npm:uuid@3.4.0/lib/rng-browser.js" +pin "uuid/v4", to: "https://ga.jspm.io/npm:uuid@3.4.0/v4.js" +pin "vargs", to: "https://ga.jspm.io/npm:vargs@0.1.0/lib/vargs.js" +pin "vary", to: "https://ga.jspm.io/npm:vary@1.1.2/index.js" +pin "verror", to: "https://ga.jspm.io/npm:verror@1.10.0/lib/verror.js" +pin "wd", to: "https://ga.jspm.io/npm:wd@1.14.0/lib/main.js" +pin "wrappy", to: "https://ga.jspm.io/npm:wrappy@1.0.2/wrappy.js" +pin "xrs/client", to: "https://ga.jspm.io/npm:xrs@1.2.2/client.js" +pin "xtend", to: "https://ga.jspm.io/npm:xtend@4.0.2/immutable.js" +pin "yaml", to: "https://ga.jspm.io/npm:yaml@2.7.0/browser/index.js" +pin "zip-stream", to: "https://ga.jspm.io/npm:zip-stream@2.1.3/index.js" +pin "zlib", to: "https://ga.jspm.io/npm:@jspm/core@2.1.0/nodelibs/browser/zlib.js" diff --git a/config/routes.rb b/config/routes.rb index a715d911..e803abe1 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -1,9 +1,11 @@ Rails.application.routes.draw do namespace :api do resources :todo_lists, path: :todolists do - resources :items + resources :items, path: :todos end end - resources :todo_lists, only: %i[index new], path: :todolists + resources :todo_lists, path: :todolists do + resources :items, only: %i[ create destroy ], path: :todos + end end From 8af52c92042a25aa27b3796aeb057ef3293a12b6 Mon Sep 17 00:00:00 2001 From: Yuchi Hsueh Date: Thu, 30 Jan 2025 00:33:36 -0300 Subject: [PATCH 4/7] adds animation for icons --- app/assets/stylesheets/application.css | 114 ++++++++++++++++++++++- app/controllers/items_controller.rb | 26 +++++- app/views/items/_form.html.erb | 7 +- app/views/items/_item.html.erb | 7 +- app/views/todo_lists/_form.html.erb | 7 +- app/views/todo_lists/_todo_list.html.erb | 14 +-- app/views/todo_lists/show.html.erb | 7 +- config/routes.rb | 2 +- 8 files changed, 168 insertions(+), 16 deletions(-) diff --git a/app/assets/stylesheets/application.css b/app/assets/stylesheets/application.css index a4705dcf..d84070e5 100644 --- a/app/assets/stylesheets/application.css +++ b/app/assets/stylesheets/application.css @@ -13,4 +13,116 @@ *= require_tree . *= require_self */ -@import "bootstrap"; +.delete-btn { + position: relative; + min-width: 2.5rem; + min-height: 2.5rem; + display: inline-flex; + align-items: center; + justify-content: center; +} + +.delete-btn i { + transition: opacity 0.3s ease; + position: absolute; + left: 50%; + top: 50%; + transform: translate(-50%, -50%); +} + +.trash-icon { + opacity: 1; +} + +.x-icon { + opacity: 0; +} + +.delete-btn:hover .trash-icon { + opacity: 0; +} + +.delete-btn:hover .x-icon { + opacity: 1; +} + +.edit-btn { + position: relative; + min-width: 2.5rem; + min-height: 2.5rem; + display: inline-flex; + align-items: center; + justify-content: center; +} + +.edit-btn i { + transition: opacity 0.3s ease; + position: absolute; + left: 50%; + top: 50%; + transform: translate(-50%, -50%); +} + +.pen-square-icon { + opacity: 1; +} + +.pen-icon { + opacity: 0; +} + +.edit-btn:hover .pen-square-icon { + opacity: 0; +} + +.edit-btn:hover .pen-icon { + opacity: 1; +} + +.submit-btn { + position: relative; + min-width: 200px; /* Adjust based on your text length */ + min-height: 38px; + display: inline-flex; + align-items: center; + justify-content: center; +} + +.submit-btn .button-text { + transition: opacity 0.3s ease; + opacity: 1; +} + +.submit-btn .plus-submit-icon { + transition: opacity 0.3s ease; + position: absolute; + left: 50%; + top: 50%; + transform: translate(-50%, -50%); + opacity: 0; +} + +.submit-btn:hover .button-text { + opacity: 0; +} + +.submit-btn:hover .plus-submit-icon { + opacity: 1; +} + +@keyframes spin-counter { + from { + transform: rotate(0deg); + } + to { + transform: rotate(-360deg); + } +} + +.fa-arrow-rotate-left { + transition: transform 0.3s ease; +} + +.fa-arrow-rotate-left:hover { + animation: spin-counter 2s linear infinite; +} diff --git a/app/controllers/items_controller.rb b/app/controllers/items_controller.rb index 78ea0f15..44746df4 100644 --- a/app/controllers/items_controller.rb +++ b/app/controllers/items_controller.rb @@ -1,6 +1,6 @@ class ItemsController < ApplicationController before_action :set_todo_list - before_action :set_item, only: %i[ destroy ] + before_action :set_item, only: %i[update destroy] def create @item = @todo_list.items.build(item_params) @@ -25,6 +25,30 @@ def create end end + def update + respond_to do |format| + if @item.update(item_params) + format.html { redirect_to @todo_list, notice: 'Item was successfully updated.' } + format.turbo_stream { + render turbo_stream: turbo_stream.replace( + @item, + partial: "items/item", + locals: { todo_list: @todo_list, item: @item } + ) + } + else + format.html { redirect_to @todo_list, alert: 'Error updating item.' } + format.turbo_stream { + render turbo_stream: turbo_stream.replace( + @item, + partial: "items/item", + locals: { todo_list: @todo_list, item: @item } + ) + } + end + end + end + def destroy @item.destroy respond_to do |format| diff --git a/app/views/items/_form.html.erb b/app/views/items/_form.html.erb index 8b7509eb..248f698c 100644 --- a/app/views/items/_form.html.erb +++ b/app/views/items/_form.html.erb @@ -9,6 +9,11 @@ <%= f.text_field :description, class: "form-control", placeholder: "Enter new item description..." %> - <%= f.submit "+ Add new Item", class: "btn btn-primary" %> + <%= button_tag(type: 'submit', class: "btn btn-primary submit-btn") do %> + + + Add new Item + + + <% end %>
<% end %> diff --git a/app/views/items/_item.html.erb b/app/views/items/_item.html.erb index 0fb4af07..be99914b 100644 --- a/app/views/items/_item.html.erb +++ b/app/views/items/_item.html.erb @@ -22,9 +22,10 @@
<%= button_to todo_list_item_path(todo_list, item), method: :delete, - class: "btn btn-sm btn-danger", - form: { class: 'd-inline' } do %> - + class: "btn btn-sm btn-danger delete-btn", + form: { class: 'd-inline' } do %> + + <% end %>
diff --git a/app/views/todo_lists/_form.html.erb b/app/views/todo_lists/_form.html.erb index 96d38833..32c2cde9 100644 --- a/app/views/todo_lists/_form.html.erb +++ b/app/views/todo_lists/_form.html.erb @@ -18,6 +18,11 @@ controller: "reset-form", action: "keyup->reset-form#clearOnEscape" } %> - <%= f.submit class: "btn btn-primary" %> + <%= button_tag(type: 'submit', class: "btn btn-primary submit-btn") do %> + + <%= todo_list.persisted? ? "Update Todo List" : "Create a new Todo List" %> + + plus-submit-icon"> + <% end %> <% end %> diff --git a/app/views/todo_lists/_todo_list.html.erb b/app/views/todo_lists/_todo_list.html.erb index 9939d8fb..1337710b 100644 --- a/app/views/todo_lists/_todo_list.html.erb +++ b/app/views/todo_lists/_todo_list.html.erb @@ -1,5 +1,5 @@ <%= turbo_frame_tag dom_id(todo_list) do %> -
+
<%= link_to todo_list.name, @@ -9,15 +9,17 @@
<%= link_to edit_todo_list_path(todo_list), - class: "btn btn-sm btn-warning me-2", + class: "btn btn-sm btn-warning me-2 edit-btn", data: { turbo_frame: "_top" } do %> - + + <% end %> <%= button_to todo_list_path(todo_list), method: :delete, - class: "btn btn-sm btn-danger", - form: { class: 'd-inline', data: { turbo_confirm: 'Are you sure?' } } do %> - + class: "btn btn-sm btn-danger delete-btn", + form: { class: 'd-inline', data: { turbo_confirm: 'Are you sure you want to delete this todo list?' } } do %> + + <% end %>
diff --git a/app/views/todo_lists/show.html.erb b/app/views/todo_lists/show.html.erb index d6fbf838..3ff77662 100644 --- a/app/views/todo_lists/show.html.erb +++ b/app/views/todo_lists/show.html.erb @@ -3,8 +3,11 @@

<%= @todo_list.name %>

- <%= link_to edit_todo_list_path(@todo_list), class: "btn btn-warning" do %> - + <%= link_to edit_todo_list_path(@todo_list), + class: "btn btn-sm btn-warning me-2 edit-btn", + data: { turbo_frame: "_top" } do %> + + <% end %> <%= link_to todo_lists_path, class: "btn btn-secondary" do %> diff --git a/config/routes.rb b/config/routes.rb index e803abe1..9325588a 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -6,6 +6,6 @@ end resources :todo_lists, path: :todolists do - resources :items, only: %i[ create destroy ], path: :todos + resources :items, only: %i[ create destroy update ], path: :todos end end From a646263167e6361019cd9157926a71249228d627 Mon Sep 17 00:00:00 2001 From: Yuchi Hsueh Date: Thu, 30 Jan 2025 00:46:47 -0300 Subject: [PATCH 5/7] adds styling for card headers and app background --- app/assets/stylesheets/application.css | 38 ++++++++++++++++++++++++++ app/views/todo_lists/show.html.erb | 2 +- 2 files changed, 39 insertions(+), 1 deletion(-) diff --git a/app/assets/stylesheets/application.css b/app/assets/stylesheets/application.css index d84070e5..5cd76905 100644 --- a/app/assets/stylesheets/application.css +++ b/app/assets/stylesheets/application.css @@ -13,6 +13,44 @@ *= require_tree . *= require_self */ + +html, body { + background-color: #83b5d1 !important; + min-height: 100vh; +} + +/* Card header styles */ +.card-header { + border-bottom: none !important; + padding: 1.5rem !important; +} + +.card-header h1 { + color: white !important; + font-weight: bold !important; + font-size: 2.5rem !important; + margin: 0 !important; + text-shadow: 1px 1px 2px rgba(0, 0, 0, 0.1); +} + +/* Index page card header - blue monochromatic gradient */ +.card:has(h1:not([class*="edit"]):not([data-todo-title])) .card-header { + background-image: linear-gradient(45deg, #4a90e2, #357abd) !important; + background-color: transparent !important; +} + +/* Show page card header - teal monochromatic gradient */ +.card:has(h1[data-todo-title]) .card-header { + background-image: linear-gradient(45deg, #40b9b0, #2d8c85) !important; + background-color: transparent !important; +} + +/* Edit page card header - purple monochromatic gradient */ +.card:has(h1[class*="text-3xl"]) .card-header { + background-image: linear-gradient(45deg, #8e44ad, #6c3483) !important; + background-color: transparent !important; +} + .delete-btn { position: relative; min-width: 2.5rem; diff --git a/app/views/todo_lists/show.html.erb b/app/views/todo_lists/show.html.erb index 3ff77662..e80a9c54 100644 --- a/app/views/todo_lists/show.html.erb +++ b/app/views/todo_lists/show.html.erb @@ -1,7 +1,7 @@
-

<%= @todo_list.name %>

+

<%= @todo_list.name %>

<%= link_to edit_todo_list_path(@todo_list), class: "btn btn-sm btn-warning me-2 edit-btn", From 07f8ab0cf819bcbf59e462672463c5aa97cea971 Mon Sep 17 00:00:00 2001 From: Yuchi Hsueh Date: Thu, 30 Jan 2025 01:20:19 -0300 Subject: [PATCH 6/7] adds devise and new seed --- Gemfile | 3 + Gemfile.lock | 18 +- app/controllers/todo_lists_controller.rb | 30 +- app/models/item.rb | 10 +- app/models/todo_list.rb | 4 +- app/models/user.rb | 8 + app/views/devise/confirmations/new.html.erb | 16 + .../mailer/confirmation_instructions.html.erb | 5 + .../devise/mailer/email_changed.html.erb | 7 + .../devise/mailer/password_change.html.erb | 3 + .../reset_password_instructions.html.erb | 8 + .../mailer/unlock_instructions.html.erb | 7 + app/views/devise/passwords/edit.html.erb | 25 ++ app/views/devise/passwords/new.html.erb | 16 + app/views/devise/registrations/edit.html.erb | 43 +++ app/views/devise/registrations/new.html.erb | 43 +++ app/views/devise/sessions/new.html.erb | 40 +++ .../devise/shared/_error_messages.html.erb | 15 + app/views/devise/shared/_links.html.erb | 25 ++ app/views/devise/unlocks/new.html.erb | 16 + app/views/layouts/application.html.erb | 33 ++ app/views/todo_lists/create.turbo_stream.erb | 7 + config/environments/development.rb | 2 + config/initializers/devise.rb | 313 ++++++++++++++++++ config/locales/devise.en.yml | 65 ++++ config/routes.rb | 15 +- db/migrate/20230404162028_add_todo_lists.rb | 7 - .../20250130040527_add_devise_to_users.rb | 20 ++ .../20250130040549_create_todo_lists.rb | 10 + ...tems.rb => 20250130040610_create_items.rb} | 6 +- db/schema.rb | 25 +- db/seeds.rb | 32 +- spec/models/todo_list_spec.rb | 5 + spec/models/user_spec.rb | 5 + 34 files changed, 835 insertions(+), 52 deletions(-) create mode 100644 app/models/user.rb create mode 100644 app/views/devise/confirmations/new.html.erb create mode 100644 app/views/devise/mailer/confirmation_instructions.html.erb create mode 100644 app/views/devise/mailer/email_changed.html.erb create mode 100644 app/views/devise/mailer/password_change.html.erb create mode 100644 app/views/devise/mailer/reset_password_instructions.html.erb create mode 100644 app/views/devise/mailer/unlock_instructions.html.erb create mode 100644 app/views/devise/passwords/edit.html.erb create mode 100644 app/views/devise/passwords/new.html.erb create mode 100644 app/views/devise/registrations/edit.html.erb create mode 100644 app/views/devise/registrations/new.html.erb create mode 100644 app/views/devise/sessions/new.html.erb create mode 100644 app/views/devise/shared/_error_messages.html.erb create mode 100644 app/views/devise/shared/_links.html.erb create mode 100644 app/views/devise/unlocks/new.html.erb create mode 100644 app/views/todo_lists/create.turbo_stream.erb create mode 100644 config/initializers/devise.rb create mode 100644 config/locales/devise.en.yml delete mode 100644 db/migrate/20230404162028_add_todo_lists.rb create mode 100644 db/migrate/20250130040527_add_devise_to_users.rb create mode 100644 db/migrate/20250130040549_create_todo_lists.rb rename db/migrate/{20250129222127_create_items.rb => 20250130040610_create_items.rb} (62%) create mode 100644 spec/models/todo_list_spec.rb create mode 100644 spec/models/user_spec.rb diff --git a/Gemfile b/Gemfile index 5660709c..aecf0356 100644 --- a/Gemfile +++ b/Gemfile @@ -49,6 +49,9 @@ gem "bootsnap", require: false # Use Active Storage variants [https://guides.rubyonrails.org/active_storage_overview.html#transforming-images] # gem "image_processing", "~> 1.2" +# Use devise for authentication +gem "devise" + group :development, :test do # See https://guides.rubyonrails.org/debugging_rails_applications.html#debugging-with-the-debug-gem gem "debug", platforms: %i[ mri mingw x64_mingw ] diff --git a/Gemfile.lock b/Gemfile.lock index 9a1bacdb..86532ae3 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -68,12 +68,10 @@ GEM tzinfo (~> 2.0) addressable (2.8.2) public_suffix (>= 2.0.2, < 6.0) + bcrypt (3.1.20) bindex (0.8.1) bootsnap (1.16.0) msgpack (~> 1.2) - bootstrap-kaminari-views (0.0.5) - kaminari (>= 0.13) - rails (>= 3.1) builder (3.2.4) capybara (3.39.0) addressable @@ -90,6 +88,12 @@ GEM debug (1.7.2) irb (>= 1.5.0) reline (>= 0.3.1) + devise (4.9.4) + bcrypt (~> 3.0) + orm_adapter (~> 0.1) + railties (>= 4.1.0) + responders + warden (~> 1.2.3) diff-lcs (1.5.0) erubi (1.12.0) globalid (1.1.0) @@ -145,6 +149,7 @@ GEM nokogiri (1.15.3) mini_portile2 (~> 2.8.2) racc (~> 1.4) + orm_adapter (0.5.0) public_suffix (5.0.1) puma (5.6.5) nio4r (~> 2.0) @@ -182,6 +187,9 @@ GEM regexp_parser (2.7.0) reline (0.3.3) io-console (~> 0.5) + responders (3.1.1) + actionpack (>= 5.2) + railties (>= 5.2) rexml (3.2.5) rspec (3.12.0) rspec-core (~> 3.12.0) @@ -228,6 +236,8 @@ GEM railties (>= 6.0.0) tzinfo (2.0.6) concurrent-ruby (~> 1.0) + warden (1.2.9) + rack (>= 2.0.9) web-console (4.2.0) actionview (>= 6.0.0) activemodel (>= 6.0.0) @@ -252,9 +262,9 @@ PLATFORMS DEPENDENCIES bootsnap - bootstrap-kaminari-views (~> 0.0.5) capybara debug + devise importmap-rails jbuilder kaminari (~> 1.2, >= 1.2.2) diff --git a/app/controllers/todo_lists_controller.rb b/app/controllers/todo_lists_controller.rb index 30dbb4f2..cbeb4a96 100644 --- a/app/controllers/todo_lists_controller.rb +++ b/app/controllers/todo_lists_controller.rb @@ -1,8 +1,9 @@ class TodoListsController < ApplicationController + before_action :authenticate_user! before_action :set_todo_list, only: %i[ show edit update destroy ] def index - @todo_lists = TodoList.page(params[:page]).per(10) + @todo_lists = current_user.todo_lists.page(params[:page]).per(10) end def show @@ -12,31 +13,26 @@ def edit end def create - @todo_list = TodoList.new(todo_list_params) + @todo_list = current_user.todo_lists.build(todo_list_params) respond_to do |format| if @todo_list.save - format.html { redirect_to @todo_list, notice: "Todo list created successfully" } - format.turbo_stream do - render turbo_stream: [ - turbo_stream.append("todo_lists", partial: "todo_list", locals: { todo_list: @todo_list }), - turbo_stream.update("new_todo_list", partial: "form", locals: { todo_list: TodoList.new }) - ] - end + format.turbo_stream + format.html { redirect_to todo_lists_path, notice: 'Todo list was successfully created.' } else - format.html { render :new, status: :unprocessable_entity } format.turbo_stream do - render turbo_stream.update("new_todo_list", - partial: "form", + render turbo_stream: turbo_stream.update('new_todo_list', + partial: 'form', locals: { todo_list: @todo_list }) end + format.html { render :new, status: :unprocessable_entity } end end end def update if @todo_list.update(todo_list_params) - redirect_to @todo_list, notice: "Todo list updated successfully" + redirect_to todo_lists_path, notice: 'Todo list was successfully updated.' else render :edit, status: :unprocessable_entity end @@ -46,17 +42,15 @@ def destroy @todo_list.destroy respond_to do |format| - format.html { redirect_to todo_lists_url, notice: "Todo list deleted successfully" } - format.turbo_stream do - render turbo_stream: turbo_stream.remove(@todo_list) - end + format.turbo_stream { render turbo_stream: turbo_stream.remove(@todo_list) } + format.html { redirect_to todo_lists_url, notice: 'Todo list was successfully destroyed.' } end end private def set_todo_list - @todo_list = TodoList.find(params[:id]) + @todo_list = current_user.todo_lists.find(params[:id]) end def todo_list_params diff --git a/app/models/item.rb b/app/models/item.rb index f76516cd..61c63ac1 100644 --- a/app/models/item.rb +++ b/app/models/item.rb @@ -1,12 +1,6 @@ class Item < ApplicationRecord belongs_to :todo_list - validates :title, presence: true - - def completed? - completed - end - def complete! - update(completed: true) - end + validates :title, presence: true + validates :completed, inclusion: { in: [true, false] } end diff --git a/app/models/todo_list.rb b/app/models/todo_list.rb index d9f0539b..da38afc0 100644 --- a/app/models/todo_list.rb +++ b/app/models/todo_list.rb @@ -1,4 +1,6 @@ class TodoList < ApplicationRecord - validates :name, presence: true + belongs_to :user has_many :items, dependent: :destroy + + validates :name, presence: true end diff --git a/app/models/user.rb b/app/models/user.rb new file mode 100644 index 00000000..07f30310 --- /dev/null +++ b/app/models/user.rb @@ -0,0 +1,8 @@ +class User < ApplicationRecord + # Include default devise modules. Others available are: + # :confirmable, :lockable, :timeoutable, :trackable and :omniauthable + devise :database_authenticatable, :registerable, + :recoverable, :rememberable, :validatable + + has_many :todo_lists, dependent: :destroy +end diff --git a/app/views/devise/confirmations/new.html.erb b/app/views/devise/confirmations/new.html.erb new file mode 100644 index 00000000..b12dd0cb --- /dev/null +++ b/app/views/devise/confirmations/new.html.erb @@ -0,0 +1,16 @@ +

Resend confirmation instructions

+ +<%= form_for(resource, as: resource_name, url: confirmation_path(resource_name), html: { method: :post }) do |f| %> + <%= render "devise/shared/error_messages", resource: resource %> + +
+ <%= f.label :email %>
+ <%= f.email_field :email, autofocus: true, autocomplete: "email", value: (resource.pending_reconfirmation? ? resource.unconfirmed_email : resource.email) %> +
+ +
+ <%= f.submit "Resend confirmation instructions" %> +
+<% end %> + +<%= render "devise/shared/links" %> diff --git a/app/views/devise/mailer/confirmation_instructions.html.erb b/app/views/devise/mailer/confirmation_instructions.html.erb new file mode 100644 index 00000000..dc55f64f --- /dev/null +++ b/app/views/devise/mailer/confirmation_instructions.html.erb @@ -0,0 +1,5 @@ +

Welcome <%= @email %>!

+ +

You can confirm your account email through the link below:

+ +

<%= link_to 'Confirm my account', confirmation_url(@resource, confirmation_token: @token) %>

diff --git a/app/views/devise/mailer/email_changed.html.erb b/app/views/devise/mailer/email_changed.html.erb new file mode 100644 index 00000000..32f4ba80 --- /dev/null +++ b/app/views/devise/mailer/email_changed.html.erb @@ -0,0 +1,7 @@ +

Hello <%= @email %>!

+ +<% if @resource.try(:unconfirmed_email?) %> +

We're contacting you to notify you that your email is being changed to <%= @resource.unconfirmed_email %>.

+<% else %> +

We're contacting you to notify you that your email has been changed to <%= @resource.email %>.

+<% end %> diff --git a/app/views/devise/mailer/password_change.html.erb b/app/views/devise/mailer/password_change.html.erb new file mode 100644 index 00000000..b41daf47 --- /dev/null +++ b/app/views/devise/mailer/password_change.html.erb @@ -0,0 +1,3 @@ +

Hello <%= @resource.email %>!

+ +

We're contacting you to notify you that your password has been changed.

diff --git a/app/views/devise/mailer/reset_password_instructions.html.erb b/app/views/devise/mailer/reset_password_instructions.html.erb new file mode 100644 index 00000000..f667dc12 --- /dev/null +++ b/app/views/devise/mailer/reset_password_instructions.html.erb @@ -0,0 +1,8 @@ +

Hello <%= @resource.email %>!

+ +

Someone has requested a link to change your password. You can do this through the link below.

+ +

<%= link_to 'Change my password', edit_password_url(@resource, reset_password_token: @token) %>

+ +

If you didn't request this, please ignore this email.

+

Your password won't change until you access the link above and create a new one.

diff --git a/app/views/devise/mailer/unlock_instructions.html.erb b/app/views/devise/mailer/unlock_instructions.html.erb new file mode 100644 index 00000000..41e148bf --- /dev/null +++ b/app/views/devise/mailer/unlock_instructions.html.erb @@ -0,0 +1,7 @@ +

Hello <%= @resource.email %>!

+ +

Your account has been locked due to an excessive number of unsuccessful sign in attempts.

+ +

Click the link below to unlock your account:

+ +

<%= link_to 'Unlock my account', unlock_url(@resource, unlock_token: @token) %>

diff --git a/app/views/devise/passwords/edit.html.erb b/app/views/devise/passwords/edit.html.erb new file mode 100644 index 00000000..5fbb9ff0 --- /dev/null +++ b/app/views/devise/passwords/edit.html.erb @@ -0,0 +1,25 @@ +

Change your password

+ +<%= form_for(resource, as: resource_name, url: password_path(resource_name), html: { method: :put }) do |f| %> + <%= render "devise/shared/error_messages", resource: resource %> + <%= f.hidden_field :reset_password_token %> + +
+ <%= f.label :password, "New password" %>
+ <% if @minimum_password_length %> + (<%= @minimum_password_length %> characters minimum)
+ <% end %> + <%= f.password_field :password, autofocus: true, autocomplete: "new-password" %> +
+ +
+ <%= f.label :password_confirmation, "Confirm new password" %>
+ <%= f.password_field :password_confirmation, autocomplete: "new-password" %> +
+ +
+ <%= f.submit "Change my password" %> +
+<% end %> + +<%= render "devise/shared/links" %> diff --git a/app/views/devise/passwords/new.html.erb b/app/views/devise/passwords/new.html.erb new file mode 100644 index 00000000..9b486b81 --- /dev/null +++ b/app/views/devise/passwords/new.html.erb @@ -0,0 +1,16 @@ +

Forgot your password?

+ +<%= form_for(resource, as: resource_name, url: password_path(resource_name), html: { method: :post }) do |f| %> + <%= render "devise/shared/error_messages", resource: resource %> + +
+ <%= f.label :email %>
+ <%= f.email_field :email, autofocus: true, autocomplete: "email" %> +
+ +
+ <%= f.submit "Send me reset password instructions" %> +
+<% end %> + +<%= render "devise/shared/links" %> diff --git a/app/views/devise/registrations/edit.html.erb b/app/views/devise/registrations/edit.html.erb new file mode 100644 index 00000000..b82e3365 --- /dev/null +++ b/app/views/devise/registrations/edit.html.erb @@ -0,0 +1,43 @@ +

Edit <%= resource_name.to_s.humanize %>

+ +<%= form_for(resource, as: resource_name, url: registration_path(resource_name), html: { method: :put }) do |f| %> + <%= render "devise/shared/error_messages", resource: resource %> + +
+ <%= f.label :email %>
+ <%= f.email_field :email, autofocus: true, autocomplete: "email" %> +
+ + <% if devise_mapping.confirmable? && resource.pending_reconfirmation? %> +
Currently waiting confirmation for: <%= resource.unconfirmed_email %>
+ <% end %> + +
+ <%= f.label :password %> (leave blank if you don't want to change it)
+ <%= f.password_field :password, autocomplete: "new-password" %> + <% if @minimum_password_length %> +
+ <%= @minimum_password_length %> characters minimum + <% end %> +
+ +
+ <%= f.label :password_confirmation %>
+ <%= f.password_field :password_confirmation, autocomplete: "new-password" %> +
+ +
+ <%= f.label :current_password %> (we need your current password to confirm your changes)
+ <%= f.password_field :current_password, autocomplete: "current-password" %> +
+ +
+ <%= f.submit "Update" %> +
+<% end %> + +

Cancel my account

+ +
Unhappy? <%= button_to "Cancel my account", registration_path(resource_name), data: { confirm: "Are you sure?", turbo_confirm: "Are you sure?" }, method: :delete %>
+ +<%= link_to "Back", :back %> diff --git a/app/views/devise/registrations/new.html.erb b/app/views/devise/registrations/new.html.erb new file mode 100644 index 00000000..e6f54de6 --- /dev/null +++ b/app/views/devise/registrations/new.html.erb @@ -0,0 +1,43 @@ +
+
+
+
+
+

Sign up

+
+ +
+ <%= form_for(resource, as: resource_name, url: registration_path(resource_name), html: { class: "mb-4" }) do |f| %> + <%= render "devise/shared/error_messages", resource: resource %> + +
+ <%= f.label :email, class: "form-label" %> + <%= f.email_field :email, autofocus: true, autocomplete: "email", class: "form-control" %> +
+ +
+ <%= f.label :password, class: "form-label" %> + <% if @minimum_password_length %> + (<%= @minimum_password_length %> characters minimum) + <% end %> + <%= f.password_field :password, autocomplete: "new-password", class: "form-control" %> +
+ +
+ <%= f.label :password_confirmation, class: "form-label" %> + <%= f.password_field :password_confirmation, autocomplete: "new-password", class: "form-control" %> +
+ +
+ <%= f.submit "Sign up", class: "btn btn-primary", style: "background-image: linear-gradient(45deg, #4a90e2, #357abd); border: none;" %> +
+ <% end %> + +
+ <%= render "devise/shared/links" %> +
+
+
+
+
+
diff --git a/app/views/devise/sessions/new.html.erb b/app/views/devise/sessions/new.html.erb new file mode 100644 index 00000000..cbff2f30 --- /dev/null +++ b/app/views/devise/sessions/new.html.erb @@ -0,0 +1,40 @@ +
+
+
+
+
+

Sign in

+
+ +
+ <%= form_for(resource, as: resource_name, url: session_path(resource_name), html: { class: "mb-4" }) do |f| %> +
+ <%= f.label :email, class: "form-label" %> + <%= f.email_field :email, autofocus: true, autocomplete: "email", class: "form-control" %> +
+ +
+ <%= f.label :password, class: "form-label" %> + <%= f.password_field :password, autocomplete: "current-password", class: "form-control" %> +
+ + <% if devise_mapping.rememberable? %> +
+ <%= f.check_box :remember_me, class: "form-check-input" %> + <%= f.label :remember_me, class: "form-check-label" %> +
+ <% end %> + +
+ <%= f.submit "Sign in", class: "btn btn-primary", style: "background-image: linear-gradient(45deg, #4a90e2, #357abd); border: none;" %> +
+ <% end %> + +
+ <%= render "devise/shared/links" %> +
+
+
+
+
+
diff --git a/app/views/devise/shared/_error_messages.html.erb b/app/views/devise/shared/_error_messages.html.erb new file mode 100644 index 00000000..cabfe307 --- /dev/null +++ b/app/views/devise/shared/_error_messages.html.erb @@ -0,0 +1,15 @@ +<% if resource.errors.any? %> +
+

+ <%= I18n.t("errors.messages.not_saved", + count: resource.errors.count, + resource: resource.class.model_name.human.downcase) + %> +

+
    + <% resource.errors.full_messages.each do |message| %> +
  • <%= message %>
  • + <% end %> +
+
+<% end %> diff --git a/app/views/devise/shared/_links.html.erb b/app/views/devise/shared/_links.html.erb new file mode 100644 index 00000000..f40ea6a6 --- /dev/null +++ b/app/views/devise/shared/_links.html.erb @@ -0,0 +1,25 @@ +<%- if controller_name != 'sessions' %> + <%= link_to "Sign in", new_session_path(resource_name), class: "text-decoration-none" %>
+<% end %> + +<%- if devise_mapping.registerable? && controller_name != 'registrations' %> + <%= link_to "Sign up", new_registration_path(resource_name), class: "text-decoration-none" %>
+<% end %> + +<%- if devise_mapping.recoverable? && controller_name != 'passwords' && controller_name != 'registrations' %> + <%= link_to "Forgot your password?", new_password_path(resource_name), class: "text-decoration-none" %>
+<% end %> + +<%- if devise_mapping.confirmable? && controller_name != 'confirmations' %> + <%= link_to "Didn't receive confirmation instructions?", new_confirmation_path(resource_name), class: "text-decoration-none" %>
+<% end %> + +<%- if devise_mapping.lockable? && resource_class.unlock_strategy_enabled?(:email) && controller_name != 'unlocks' %> + <%= link_to "Didn't receive unlock instructions?", new_unlock_path(resource_name), class: "text-decoration-none" %>
+<% end %> + +<%- if devise_mapping.omniauthable? %> + <%- resource_class.omniauth_providers.each do |provider| %> + <%= button_to "Sign in with #{OmniAuth::Utils.camelize(provider)}", omniauth_authorize_path(resource_name, provider), data: { turbo: false }, class: "text-decoration-none" %>
+ <% end %> +<% end %> diff --git a/app/views/devise/unlocks/new.html.erb b/app/views/devise/unlocks/new.html.erb new file mode 100644 index 00000000..ffc34de8 --- /dev/null +++ b/app/views/devise/unlocks/new.html.erb @@ -0,0 +1,16 @@ +

Resend unlock instructions

+ +<%= form_for(resource, as: resource_name, url: unlock_path(resource_name), html: { method: :post }) do |f| %> + <%= render "devise/shared/error_messages", resource: resource %> + +
+ <%= f.label :email %>
+ <%= f.email_field :email, autofocus: true, autocomplete: "email" %> +
+ +
+ <%= f.submit "Resend unlock instructions" %> +
+<% end %> + +<%= render "devise/shared/links" %> diff --git a/app/views/layouts/application.html.erb b/app/views/layouts/application.html.erb index 51f30849..05859a5a 100644 --- a/app/views/layouts/application.html.erb +++ b/app/views/layouts/application.html.erb @@ -13,6 +13,39 @@ + + + <% if notice %> + + <% end %> + + <% if alert %> + + <% end %> + <%= yield %> + + diff --git a/app/views/todo_lists/create.turbo_stream.erb b/app/views/todo_lists/create.turbo_stream.erb new file mode 100644 index 00000000..955d3309 --- /dev/null +++ b/app/views/todo_lists/create.turbo_stream.erb @@ -0,0 +1,7 @@ +<%= turbo_stream.append "todo_lists" do %> + <%= render @todo_list %> +<% end %> + +<%= turbo_stream.update "new_todo_list" do %> + <%= render "form", todo_list: TodoList.new %> +<% end %> diff --git a/config/environments/development.rb b/config/environments/development.rb index 5fe290d0..d36209ee 100644 --- a/config/environments/development.rb +++ b/config/environments/development.rb @@ -74,4 +74,6 @@ # Uncomment if you wish to allow Action Cable access from any origin. # config.action_cable.disable_request_forgery_protection = true + + config.action_mailer.default_url_options = { host: 'localhost', port: 3000 } end diff --git a/config/initializers/devise.rb b/config/initializers/devise.rb new file mode 100644 index 00000000..8c482748 --- /dev/null +++ b/config/initializers/devise.rb @@ -0,0 +1,313 @@ +# frozen_string_literal: true + +# Assuming you have not yet modified this file, each configuration option below +# is set to its default value. Note that some are commented out while others +# are not: uncommented lines are intended to protect your configuration from +# breaking changes in upgrades (i.e., in the event that future versions of +# Devise change the default values for those options). +# +# Use this hook to configure devise mailer, warden hooks and so forth. +# Many of these configuration options can be set straight in your model. +Devise.setup do |config| + # The secret key used by Devise. Devise uses this key to generate + # random tokens. Changing this key will render invalid all existing + # confirmation, reset password and unlock tokens in the database. + # Devise will use the `secret_key_base` as its `secret_key` + # by default. You can change it below and use your own secret key. + # config.secret_key = '8cea4e63cc25675d1464f22ddaccf66c54a289d8244fdd1644eb187b062672131d79054cda4c01ce8ce48850bfbb4376ea10ee9776bd6e85fb914256e3c6442c' + + # ==> Controller configuration + # Configure the parent class to the devise controllers. + # config.parent_controller = 'DeviseController' + + # ==> Mailer Configuration + # Configure the e-mail address which will be shown in Devise::Mailer, + # note that it will be overwritten if you use your own mailer class + # with default "from" parameter. + config.mailer_sender = 'please-change-me-at-config-initializers-devise@example.com' + + # Configure the class responsible to send e-mails. + # config.mailer = 'Devise::Mailer' + + # Configure the parent class responsible to send e-mails. + # config.parent_mailer = 'ActionMailer::Base' + + # ==> ORM configuration + # Load and configure the ORM. Supports :active_record (default) and + # :mongoid (bson_ext recommended) by default. Other ORMs may be + # available as additional gems. + require 'devise/orm/active_record' + + # ==> Configuration for any authentication mechanism + # Configure which keys are used when authenticating a user. The default is + # just :email. You can configure it to use [:username, :subdomain], so for + # authenticating a user, both parameters are required. Remember that those + # parameters are used only when authenticating and not when retrieving from + # session. If you need permissions, you should implement that in a before filter. + # You can also supply a hash where the value is a boolean determining whether + # or not authentication should be aborted when the value is not present. + # config.authentication_keys = [:email] + + # Configure parameters from the request object used for authentication. Each entry + # given should be a request method and it will automatically be passed to the + # find_for_authentication method and considered in your model lookup. For instance, + # if you set :request_keys to [:subdomain], :subdomain will be used on authentication. + # The same considerations mentioned for authentication_keys also apply to request_keys. + # config.request_keys = [] + + # Configure which authentication keys should be case-insensitive. + # These keys will be downcased upon creating or modifying a user and when used + # to authenticate or find a user. Default is :email. + config.case_insensitive_keys = [:email] + + # Configure which authentication keys should have whitespace stripped. + # These keys will have whitespace before and after removed upon creating or + # modifying a user and when used to authenticate or find a user. Default is :email. + config.strip_whitespace_keys = [:email] + + # Tell if authentication through request.params is enabled. True by default. + # It can be set to an array that will enable params authentication only for the + # given strategies, for example, `config.params_authenticatable = [:database]` will + # enable it only for database (email + password) authentication. + # config.params_authenticatable = true + + # Tell if authentication through HTTP Auth is enabled. False by default. + # It can be set to an array that will enable http authentication only for the + # given strategies, for example, `config.http_authenticatable = [:database]` will + # enable it only for database authentication. + # For API-only applications to support authentication "out-of-the-box", you will likely want to + # enable this with :database unless you are using a custom strategy. + # The supported strategies are: + # :database = Support basic authentication with authentication key + password + # config.http_authenticatable = false + + # If 401 status code should be returned for AJAX requests. True by default. + # config.http_authenticatable_on_xhr = true + + # The realm used in Http Basic Authentication. 'Application' by default. + # config.http_authentication_realm = 'Application' + + # It will change confirmation, password recovery and other workflows + # to behave the same regardless if the e-mail provided was right or wrong. + # Does not affect registerable. + # config.paranoid = true + + # By default Devise will store the user in session. You can skip storage for + # particular strategies by setting this option. + # Notice that if you are skipping storage for all authentication paths, you + # may want to disable generating routes to Devise's sessions controller by + # passing skip: :sessions to `devise_for` in your config/routes.rb + config.skip_session_storage = [:http_auth] + + # By default, Devise cleans up the CSRF token on authentication to + # avoid CSRF token fixation attacks. This means that, when using AJAX + # requests for sign in and sign up, you need to get a new CSRF token + # from the server. You can disable this option at your own risk. + # config.clean_up_csrf_token_on_authentication = true + + # When false, Devise will not attempt to reload routes on eager load. + # This can reduce the time taken to boot the app but if your application + # requires the Devise mappings to be loaded during boot time the application + # won't boot properly. + # config.reload_routes = true + + # ==> Configuration for :database_authenticatable + # For bcrypt, this is the cost for hashing the password and defaults to 12. If + # using other algorithms, it sets how many times you want the password to be hashed. + # The number of stretches used for generating the hashed password are stored + # with the hashed password. This allows you to change the stretches without + # invalidating existing passwords. + # + # Limiting the stretches to just one in testing will increase the performance of + # your test suite dramatically. However, it is STRONGLY RECOMMENDED to not use + # a value less than 10 in other environments. Note that, for bcrypt (the default + # algorithm), the cost increases exponentially with the number of stretches (e.g. + # a value of 20 is already extremely slow: approx. 60 seconds for 1 calculation). + config.stretches = Rails.env.test? ? 1 : 12 + + # Set up a pepper to generate the hashed password. + # config.pepper = 'a8f6e7d931f41455c3827258fececea1fa901902e4c72f6963a0c621d92d742e06bf2db65fb5483d06bc5452cd180ef3f462b200a17f01205a70d3dedfe08ea7' + + # Send a notification to the original email when the user's email is changed. + # config.send_email_changed_notification = false + + # Send a notification email when the user's password is changed. + # config.send_password_change_notification = false + + # ==> Configuration for :confirmable + # A period that the user is allowed to access the website even without + # confirming their account. For instance, if set to 2.days, the user will be + # able to access the website for two days without confirming their account, + # access will be blocked just in the third day. + # You can also set it to nil, which will allow the user to access the website + # without confirming their account. + # Default is 0.days, meaning the user cannot access the website without + # confirming their account. + # config.allow_unconfirmed_access_for = 2.days + + # A period that the user is allowed to confirm their account before their + # token becomes invalid. For example, if set to 3.days, the user can confirm + # their account within 3 days after the mail was sent, but on the fourth day + # their account can't be confirmed with the token any more. + # Default is nil, meaning there is no restriction on how long a user can take + # before confirming their account. + # config.confirm_within = 3.days + + # If true, requires any email changes to be confirmed (exactly the same way as + # initial account confirmation) to be applied. Requires additional unconfirmed_email + # db field (see migrations). Until confirmed, new email is stored in + # unconfirmed_email column, and copied to email column on successful confirmation. + config.reconfirmable = true + + # Defines which key will be used when confirming an account + # config.confirmation_keys = [:email] + + # ==> Configuration for :rememberable + # The time the user will be remembered without asking for credentials again. + # config.remember_for = 2.weeks + + # Invalidates all the remember me tokens when the user signs out. + config.expire_all_remember_me_on_sign_out = true + + # If true, extends the user's remember period when remembered via cookie. + # config.extend_remember_period = false + + # Options to be passed to the created cookie. For instance, you can set + # secure: true in order to force SSL only cookies. + # config.rememberable_options = {} + + # ==> Configuration for :validatable + # Range for password length. + config.password_length = 6..128 + + # Email regex used to validate email formats. It simply asserts that + # one (and only one) @ exists in the given string. This is mainly + # to give user feedback and not to assert the e-mail validity. + config.email_regexp = /\A[^@\s]+@[^@\s]+\z/ + + # ==> Configuration for :timeoutable + # The time you want to timeout the user session without activity. After this + # time the user will be asked for credentials again. Default is 30 minutes. + # config.timeout_in = 30.minutes + + # ==> Configuration for :lockable + # Defines which strategy will be used to lock an account. + # :failed_attempts = Locks an account after a number of failed attempts to sign in. + # :none = No lock strategy. You should handle locking by yourself. + # config.lock_strategy = :failed_attempts + + # Defines which key will be used when locking and unlocking an account + # config.unlock_keys = [:email] + + # Defines which strategy will be used to unlock an account. + # :email = Sends an unlock link to the user email + # :time = Re-enables login after a certain amount of time (see :unlock_in below) + # :both = Enables both strategies + # :none = No unlock strategy. You should handle unlocking by yourself. + # config.unlock_strategy = :both + + # Number of authentication tries before locking an account if lock_strategy + # is failed attempts. + # config.maximum_attempts = 20 + + # Time interval to unlock the account if :time is enabled as unlock_strategy. + # config.unlock_in = 1.hour + + # Warn on the last attempt before the account is locked. + # config.last_attempt_warning = true + + # ==> Configuration for :recoverable + # + # Defines which key will be used when recovering the password for an account + # config.reset_password_keys = [:email] + + # Time interval you can reset your password with a reset password key. + # Don't put a too small interval or your users won't have the time to + # change their passwords. + config.reset_password_within = 6.hours + + # When set to false, does not sign a user in automatically after their password is + # reset. Defaults to true, so a user is signed in automatically after a reset. + # config.sign_in_after_reset_password = true + + # ==> Configuration for :encryptable + # Allow you to use another hashing or encryption algorithm besides bcrypt (default). + # You can use :sha1, :sha512 or algorithms from others authentication tools as + # :clearance_sha1, :authlogic_sha512 (then you should set stretches above to 20 + # for default behavior) and :restful_authentication_sha1 (then you should set + # stretches to 10, and copy REST_AUTH_SITE_KEY to pepper). + # + # Require the `devise-encryptable` gem when using anything other than bcrypt + # config.encryptor = :sha512 + + # ==> Scopes configuration + # Turn scoped views on. Before rendering "sessions/new", it will first check for + # "users/sessions/new". It's turned off by default because it's slower if you + # are using only default views. + # config.scoped_views = false + + # Configure the default scope given to Warden. By default it's the first + # devise role declared in your routes (usually :user). + # config.default_scope = :user + + # Set this configuration to false if you want /users/sign_out to sign out + # only the current scope. By default, Devise signs out all scopes. + # config.sign_out_all_scopes = true + + # ==> Navigation configuration + # Lists the formats that should be treated as navigational. Formats like + # :html should redirect to the sign in page when the user does not have + # access, but formats like :xml or :json, should return 401. + # + # If you have any extra navigational formats, like :iphone or :mobile, you + # should add them to the navigational formats lists. + # + # The "*/*" below is required to match Internet Explorer requests. + # config.navigational_formats = ['*/*', :html, :turbo_stream] + + # The default HTTP method used to sign out a resource. Default is :delete. + config.sign_out_via = :delete + + # ==> OmniAuth + # Add a new OmniAuth provider. Check the wiki for more information on setting + # up on your models and hooks. + # config.omniauth :github, 'APP_ID', 'APP_SECRET', scope: 'user,public_repo' + + # ==> Warden configuration + # If you want to use other strategies, that are not supported by Devise, or + # change the failure app, you can configure them inside the config.warden block. + # + # config.warden do |manager| + # manager.intercept_401 = false + # manager.default_strategies(scope: :user).unshift :some_external_strategy + # end + + # ==> Mountable engine configurations + # When using Devise inside an engine, let's call it `MyEngine`, and this engine + # is mountable, there are some extra configurations to be taken into account. + # The following options are available, assuming the engine is mounted as: + # + # mount MyEngine, at: '/my_engine' + # + # The router that invoked `devise_for`, in the example above, would be: + # config.router_name = :my_engine + # + # When using OmniAuth, Devise cannot automatically set OmniAuth path, + # so you need to do it manually. For the users scope, it would be: + # config.omniauth_path_prefix = '/my_engine/users/auth' + + # ==> Hotwire/Turbo configuration + # When using Devise with Hotwire/Turbo, the http status for error responses + # and some redirects must match the following. The default in Devise for existing + # apps is `200 OK` and `302 Found` respectively, but new apps are generated with + # these new defaults that match Hotwire/Turbo behavior. + # Note: These might become the new default in future versions of Devise. + config.responder.error_status = :unprocessable_entity + config.responder.redirect_status = :see_other + + # ==> Configuration for :registerable + + # When set to false, does not sign a user in automatically after their password is + # changed. Defaults to true, so a user is signed in automatically after changing a password. + # config.sign_in_after_change_password = true +end diff --git a/config/locales/devise.en.yml b/config/locales/devise.en.yml new file mode 100644 index 00000000..260e1c4b --- /dev/null +++ b/config/locales/devise.en.yml @@ -0,0 +1,65 @@ +# Additional translations at https://github.com/heartcombo/devise/wiki/I18n + +en: + devise: + confirmations: + confirmed: "Your email address has been successfully confirmed." + send_instructions: "You will receive an email with instructions for how to confirm your email address in a few minutes." + send_paranoid_instructions: "If your email address exists in our database, you will receive an email with instructions for how to confirm your email address in a few minutes." + failure: + already_authenticated: "You are already signed in." + inactive: "Your account is not activated yet." + invalid: "Invalid %{authentication_keys} or password." + locked: "Your account is locked." + last_attempt: "You have one more attempt before your account is locked." + not_found_in_database: "Invalid %{authentication_keys} or password." + timeout: "Your session expired. Please sign in again to continue." + unauthenticated: "You need to sign in or sign up before continuing." + unconfirmed: "You have to confirm your email address before continuing." + mailer: + confirmation_instructions: + subject: "Confirmation instructions" + reset_password_instructions: + subject: "Reset password instructions" + unlock_instructions: + subject: "Unlock instructions" + email_changed: + subject: "Email Changed" + password_change: + subject: "Password Changed" + omniauth_callbacks: + failure: "Could not authenticate you from %{kind} because \"%{reason}\"." + success: "Successfully authenticated from %{kind} account." + passwords: + no_token: "You can't access this page without coming from a password reset email. If you do come from a password reset email, please make sure you used the full URL provided." + send_instructions: "You will receive an email with instructions on how to reset your password in a few minutes." + send_paranoid_instructions: "If your email address exists in our database, you will receive a password recovery link at your email address in a few minutes." + updated: "Your password has been changed successfully. You are now signed in." + updated_not_active: "Your password has been changed successfully." + registrations: + destroyed: "Bye! Your account has been successfully cancelled. We hope to see you again soon." + signed_up: "Welcome! You have signed up successfully." + signed_up_but_inactive: "You have signed up successfully. However, we could not sign you in because your account is not yet activated." + signed_up_but_locked: "You have signed up successfully. However, we could not sign you in because your account is locked." + signed_up_but_unconfirmed: "A message with a confirmation link has been sent to your email address. Please follow the link to activate your account." + update_needs_confirmation: "You updated your account successfully, but we need to verify your new email address. Please check your email and follow the confirmation link to confirm your new email address." + updated: "Your account has been updated successfully." + updated_but_not_signed_in: "Your account has been updated successfully, but since your password was changed, you need to sign in again." + sessions: + signed_in: "Signed in successfully." + signed_out: "Signed out successfully." + already_signed_out: "Signed out successfully." + unlocks: + send_instructions: "You will receive an email with instructions for how to unlock your account in a few minutes." + send_paranoid_instructions: "If your account exists, you will receive an email with instructions for how to unlock it in a few minutes." + unlocked: "Your account has been unlocked successfully. Please sign in to continue." + errors: + messages: + already_confirmed: "was already confirmed, please try signing in" + confirmation_period_expired: "needs to be confirmed within %{period}, please request a new one" + expired: "has expired, please request a new one" + not_found: "not found" + not_locked: "was not locked" + not_saved: + one: "1 error prohibited this %{resource} from being saved:" + other: "%{count} errors prohibited this %{resource} from being saved:" diff --git a/config/routes.rb b/config/routes.rb index 9325588a..eecc0f0a 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -1,11 +1,14 @@ Rails.application.routes.draw do - namespace :api do - resources :todo_lists, path: :todolists do - resources :items, path: :todos - end - end + devise_for :users + root "todo_lists#index" resources :todo_lists, path: :todolists do - resources :items, only: %i[ create destroy update ], path: :todos + resources :items, path: :todos, only: [:create, :update, :destroy] + end + + namespace :api do + resources :todo_lists, path: :todolists, only: [] do + resources :items, path: :todos, only: [:update] + end end end diff --git a/db/migrate/20230404162028_add_todo_lists.rb b/db/migrate/20230404162028_add_todo_lists.rb deleted file mode 100644 index 3a8ef501..00000000 --- a/db/migrate/20230404162028_add_todo_lists.rb +++ /dev/null @@ -1,7 +0,0 @@ -class AddTodoLists < ActiveRecord::Migration[7.0] - def change - create_table :todo_lists do |t| - t.string :name, null: false - end - end -end diff --git a/db/migrate/20250130040527_add_devise_to_users.rb b/db/migrate/20250130040527_add_devise_to_users.rb new file mode 100644 index 00000000..e370c089 --- /dev/null +++ b/db/migrate/20250130040527_add_devise_to_users.rb @@ -0,0 +1,20 @@ +# frozen_string_literal: true + +class AddDeviseToUsers < ActiveRecord::Migration[7.0] + def change + create_table :users do |t| + t.string :email, null: false, default: "" + t.string :encrypted_password, null: false, default: "" + + t.string :reset_password_token + t.datetime :reset_password_sent_at + + t.datetime :remember_created_at + + t.timestamps null: false + end + + add_index :users, :email, unique: true + add_index :users, :reset_password_token, unique: true + end +end diff --git a/db/migrate/20250130040549_create_todo_lists.rb b/db/migrate/20250130040549_create_todo_lists.rb new file mode 100644 index 00000000..72ab0458 --- /dev/null +++ b/db/migrate/20250130040549_create_todo_lists.rb @@ -0,0 +1,10 @@ +class CreateTodoLists < ActiveRecord::Migration[7.0] + def change + create_table :todo_lists do |t| + t.string :name, null: false + t.references :user, null: false, foreign_key: true + + t.timestamps + end + end +end diff --git a/db/migrate/20250129222127_create_items.rb b/db/migrate/20250130040610_create_items.rb similarity index 62% rename from db/migrate/20250129222127_create_items.rb rename to db/migrate/20250130040610_create_items.rb index 5361177c..c95c7d88 100644 --- a/db/migrate/20250129222127_create_items.rb +++ b/db/migrate/20250130040610_create_items.rb @@ -1,9 +1,9 @@ class CreateItems < ActiveRecord::Migration[7.0] def change create_table :items do |t| - t.string :title - t.string :description - t.boolean :completed, default: false + t.string :title, null: false + t.text :description + t.boolean :completed, default: false, null: false t.references :todo_list, null: false, foreign_key: true t.timestamps diff --git a/db/schema.rb b/db/schema.rb index 3eac164c..2967b084 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,11 +10,11 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[7.0].define(version: 2025_01_29_222127) do +ActiveRecord::Schema[7.0].define(version: 2025_01_30_040610) do create_table "items", force: :cascade do |t| - t.string "title" - t.string "description" - t.boolean "completed", default: false + t.string "title", null: false + t.text "description" + t.boolean "completed", default: false, null: false t.integer "todo_list_id", null: false t.datetime "created_at", null: false t.datetime "updated_at", null: false @@ -23,7 +23,24 @@ create_table "todo_lists", force: :cascade do |t| t.string "name", null: false + t.integer "user_id", null: false + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["user_id"], name: "index_todo_lists_on_user_id" + end + + create_table "users", force: :cascade do |t| + t.string "email", default: "", null: false + t.string "encrypted_password", default: "", null: false + t.string "reset_password_token" + t.datetime "reset_password_sent_at" + t.datetime "remember_created_at" + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["email"], name: "index_users_on_email", unique: true + t.index ["reset_password_token"], name: "index_users_on_reset_password_token", unique: true end add_foreign_key "items", "todo_lists" + add_foreign_key "todo_lists", "users" end diff --git a/db/seeds.rb b/db/seeds.rb index 50e22a9b..370a16ae 100644 --- a/db/seeds.rb +++ b/db/seeds.rb @@ -1,5 +1,35 @@ +# Create test user +user = User.create!( + email: 'test@user.com', + password: '123123', + password_confirmation: '123123' +) + +# Create first todo list with 2 items +todo_list_1 = user.todo_lists.create!( + name: 'Shopping List' +) + +todo_list_1.items.create!([ + { + title: 'Grocery Shopping', + description: 'Need to buy vegetables, fruits, and milk for the week', + completed: false + }, + { + title: 'New Running Shoes', + description: 'Look for Nike or Adidas running shoes at the mall', + completed: false + } +]) + +# Create second todo list with no items +todo_list_2 = user.todo_lists.create!( + name: 'Work Tasks' +) + TodoList.create(name: 'Setup Rails Application') TodoList.create(name: 'Setup Docker PG database') TodoList.create(name: 'Create todo_lists table') TodoList.create(name: 'Create TodoList model') -TodoList.create(name: 'Create TodoList controller') \ No newline at end of file +TodoList.create(name: 'Create TodoList controller') diff --git a/spec/models/todo_list_spec.rb b/spec/models/todo_list_spec.rb new file mode 100644 index 00000000..04043313 --- /dev/null +++ b/spec/models/todo_list_spec.rb @@ -0,0 +1,5 @@ +require 'rails_helper' + +RSpec.describe TodoList, type: :model do + pending "add some examples to (or delete) #{__FILE__}" +end diff --git a/spec/models/user_spec.rb b/spec/models/user_spec.rb new file mode 100644 index 00000000..47a31bb4 --- /dev/null +++ b/spec/models/user_spec.rb @@ -0,0 +1,5 @@ +require 'rails_helper' + +RSpec.describe User, type: :model do + pending "add some examples to (or delete) #{__FILE__}" +end From fc337e8eec6f92e9be8465ee42e2a78fed739193 Mon Sep 17 00:00:00 2001 From: Yuchi Hsueh Date: Thu, 30 Jan 2025 17:12:19 -0300 Subject: [PATCH 7/7] adds tests and update readme file --- Gemfile | 5 + Gemfile.lock | 17 + README.md | 352 +++++++++++++++++- app/controllers/api/items_controller.rb | 2 + app/controllers/api/todo_lists_controller.rb | 106 +++--- app/controllers/items_controller.rb | 12 +- app/controllers/todo_lists_controller.rb | 7 +- app/models/todo_list.rb | 2 +- config/routes.rb | 4 +- .../20250130040549_create_todo_lists.rb | 2 +- db/schema.rb | 2 +- spec/controllers/api/items_controller_spec.rb | 187 ++++++++++ .../api/todo_lists_controller_spec.rb | 159 ++++++-- spec/controllers/items_controller_spec.rb | 228 ++++++++++++ .../controllers/todo_lists_controller_spec.rb | 198 ++++++++++ spec/factories/items.rb | 8 + spec/factories/todo_lists.rb | 6 + spec/factories/users.rb | 7 + spec/helpers/items_helper_spec.rb | 15 - spec/models/item_spec.rb | 40 +- spec/models/todo_list_spec.rb | 43 ++- spec/models/user_spec.rb | 21 +- spec/rails_helper.rb | 17 +- spec/requests/items_spec.rb | 7 - 24 files changed, 1328 insertions(+), 119 deletions(-) create mode 100644 spec/controllers/api/items_controller_spec.rb create mode 100644 spec/controllers/items_controller_spec.rb create mode 100644 spec/controllers/todo_lists_controller_spec.rb create mode 100644 spec/factories/items.rb create mode 100644 spec/factories/todo_lists.rb create mode 100644 spec/factories/users.rb delete mode 100644 spec/helpers/items_helper_spec.rb delete mode 100644 spec/requests/items_spec.rb diff --git a/Gemfile b/Gemfile index aecf0356..4c903745 100644 --- a/Gemfile +++ b/Gemfile @@ -55,6 +55,10 @@ gem "devise" group :development, :test do # See https://guides.rubyonrails.org/debugging_rails_applications.html#debugging-with-the-debug-gem gem "debug", platforms: %i[ mri mingw x64_mingw ] + gem "rspec-rails" + gem "factory_bot_rails" + gem "faker" + gem 'rails-controller-testing' end group :development do @@ -76,4 +80,5 @@ group :test do gem "capybara" gem "selenium-webdriver" gem "webdrivers" + gem "shoulda-matchers" end diff --git a/Gemfile.lock b/Gemfile.lock index 86532ae3..c5fdc1c0 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -96,6 +96,13 @@ GEM warden (~> 1.2.3) diff-lcs (1.5.0) erubi (1.12.0) + factory_bot (6.5.0) + activesupport (>= 5.0.0) + factory_bot_rails (6.4.4) + factory_bot (~> 6.5) + railties (>= 5.0.0) + faker (3.5.1) + i18n (>= 1.8.11, < 2) globalid (1.1.0) activesupport (>= 5.0) i18n (1.12.0) @@ -171,6 +178,10 @@ GEM activesupport (= 7.0.4.3) bundler (>= 1.15.0) railties (= 7.0.4.3) + rails-controller-testing (1.0.5) + actionpack (>= 5.0.1.rc1) + actionview (>= 5.0.1.rc1) + activesupport (>= 5.0.1.rc1) rails-dom-testing (2.0.3) activesupport (>= 4.2.0) nokogiri (>= 1.6) @@ -217,6 +228,8 @@ GEM rexml (~> 3.2, >= 3.2.5) rubyzip (>= 1.2.2, < 3.0) websocket (~> 1.0) + shoulda-matchers (6.4.0) + activesupport (>= 5.2.0) sprockets (4.2.0) concurrent-ruby (~> 1.0) rack (>= 2.2.4, < 4) @@ -265,14 +278,18 @@ DEPENDENCIES capybara debug devise + factory_bot_rails + faker importmap-rails jbuilder kaminari (~> 1.2, >= 1.2.2) puma (~> 5.0) rails (~> 7.0.4, >= 7.0.4.3) + rails-controller-testing rspec rspec-rails selenium-webdriver + shoulda-matchers sprockets-rails sqlite3 (~> 1.6) stimulus-rails diff --git a/README.md b/README.md index 3d974f7b..bda1d582 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,3 @@ -# rails-interview / TodoApi - -[![Open in Coder](https://dev.crunchloop.io/open-in-coder.svg)](https://dev.crunchloop.io/templates/fly-containers/workspace?param.Git%20Repository=git@github.com:crunchloop/rails-interview.git) - This is a simple Todo List API built in Ruby on Rails 7. This project is currently being used for Ruby full-stack candidates. ## Build @@ -10,7 +6,11 @@ To build the application: `bin/setup` -## Run the API +## Run seeds + +`rails db:seed` + +## Run To run the TodoApi in your local environment: @@ -22,14 +22,344 @@ To run tests: `bin/rspec` -Check integration tests at: (https://github.com/crunchloop/interview-tests) +## Web Implementation + +### Authentication & User Management + +The application uses [Devise](https://github.com/heartcombo/devise) for user authentication and management, providing: +- User registration and login +- Password recovery +- Session management +- Account confirmation via email +- Secure password handling + +### Frontend Technologies + +#### CSS Framework +- **Bootstrap 5** + ```html + + ``` + +#### Icons +- **Font Awesome 6** + ```html + + ``` + +### Real-time Updates + +The application uses Turbo Streams for real-time updates, enabling: +- Live updates for todo lists and items +- Instant UI updates without page refreshes +- Enhanced user experience with dynamic content + +## API Documentation + +This API allows you to manage todo lists and their items. All responses are in JSON format. + +All index endpoints use Kaminari for pagination with the following query parameters: +- `page` (optional): Page number (default: 1) +- `per_page` (optional): Items per page (default: 10) + +Example: `?page=1&per_page=5` + +## Todo Lists + +### List Todo Lists + +Retrieves a paginated list of todo lists. + +```http +GET /api/todolists?page=1&per_page=1 +``` + +#### Response + +```json +{ + "todo_lists": [ + { + "id": 1, + "name": "Shopping List", + "created_at": "2024-03-21T10:00:00.000Z", + "updated_at": "2024-03-21T10:00:00.000Z" + } + ], + "total_pages": 1, + "current_page": 1, + "total_count": 1 +} +``` + +### Get Single Todo List + +Retrieves a specific todo list by ID. + +```http +GET /api/todolists/:id +``` + +#### Response + +```json +{ + "id": 1, + "name": "Shopping List", + "created_at": "2024-03-21T10:00:00.000Z", + "updated_at": "2024-03-21T10:00:00.000Z" +} +``` + +### Create Todo List + +Creates a new todo list. + +```http +POST /api/todolists +``` + +#### Request Body + +```json +{ + "todo_list": { + "name": "New Todo List" + } +} +``` + +#### Response + +```json +{ + "msg": "Todo list created successfully", + "todo_list": { + "id": 1, + "name": "New Todo List", + "created_at": "2024-03-21T10:00:00.000Z", + "updated_at": "2024-03-21T10:00:00.000Z" + } +} +``` + +### Update Todo List + +Updates an existing todo list. + +```http +PATCH/PUT /api/todolists/:id +``` + +#### Request Body + +```json +{ + "todo_list": { + "name": "Updated List Name" + } +} +``` + +#### Response + +```json +{ + "msg": "Todo list updated successfully", + "todo_list": { + "id": 1, + "name": "Updated List Name", + "created_at": "2024-03-21T10:00:00.000Z", + "updated_at": "2024-03-21T10:00:00.000Z" + } +} +``` + +### Delete Todo List + +Deletes a todo list. + +```http +DELETE /api/todolists/:id +``` + +#### Response + +```json +{ + "msg": "Todo list deleted successfully" +} +``` + +## Todo Items + +### List Items + +Retrieves a paginated list of items in a todo list. + +```http +GET /api/todolists/:todo_list_id/todos +``` + +#### Response + +```json +{ + "items": [ + { + "id": 1, + "title": "Buy groceries", + "description": "Get milk and eggs", + "todo_list_id": 1, + "created_at": "2024-03-21T10:00:00.000Z", + "updated_at": "2024-03-21T10:00:00.000Z" + } + ], + "total_pages": 5, + "current_page": 1, + "total_count": 47 +} +``` + +### Get Single Item + +Retrieves a specific item from a todo list. + +```http +GET /api/todolists/:todo_list_id/todos/:id +``` + +#### Response + +```json +{ + "item": { + "id": 1, + "title": "Buy groceries", + "description": "Get milk and eggs", + "todo_list_id": 1, + "created_at": "2024-03-21T10:00:00.000Z", + "updated_at": "2024-03-21T10:00:00.000Z" + } +} +``` + +### Create Item + +Creates a new item in a todo list. + +```http +POST /api/todolists/:todo_list_id/todos +``` + +#### Request Body + +```json +{ + "item": { + "title": "New Task", + "description": "Task description" + } +} +``` + +#### Response + +```json +{ + "msg": "Item created successfully", + "item": { + "id": 1, + "title": "New Task", + "description": "Task description", + "todo_list_id": 1, + "created_at": "2024-03-21T10:00:00.000Z", + "updated_at": "2024-03-21T10:00:00.000Z" + } +} +``` + +### Update Item + +Updates an existing item in a todo list. + +```http +PATCH/PUT /api/todolists/:todo_list_id/todos/:id +``` + +#### Request Body + +```json +{ + "item": { + "title": "Updated Task", + "description": "Updated description" + } +} +``` + +#### Response + +```json +{ + "msg": "Item updated successfully", + "item": { + "id": 1, + "title": "Updated Task", + "description": "Updated description", + "todo_list_id": 1, + "created_at": "2024-03-21T10:00:00.000Z", + "updated_at": "2024-03-21T10:00:00.000Z" + } +} +``` + +### Delete Item + +Deletes an item from a todo list. + +```http +DELETE /api/todolists/:todo_list_id/todos/:id +``` + +#### Response + +```json +{ + "msg": "Item deleted successfully" +} +``` + +## Error Responses + +The API uses conventional HTTP response codes to indicate the success or failure of requests. + +### Not Found (404) + +Returned when the requested resource doesn't exist. + +```json +{ + "error": "Todo list not found" +} +``` -## Contact +or -- Santiago Doldán (sdoldan@crunchloop.io) +```json +{ + "error": "Item not found" +} +``` -## About Crunchloop +### Validation Error (422) -![crunchloop](https://s3.amazonaws.com/crunchloop.io/logo-blue.png) +Returned when the request data fails validation. -We strongly believe in giving back :rocket:. Let's work together [`Get in touch`](https://crunchloop.io/#contact). +```json +{ + "errors": [ + "Name can't be blank" + ] +} +``` diff --git a/app/controllers/api/items_controller.rb b/app/controllers/api/items_controller.rb index dfcacb77..bb82e290 100644 --- a/app/controllers/api/items_controller.rb +++ b/app/controllers/api/items_controller.rb @@ -55,6 +55,8 @@ def destroy def set_todo_list @todo_list = TodoList.find(params[:todo_list_id]) + rescue ActiveRecord::RecordNotFound + render json: { error: "Todo list not found" }, status: :not_found end def set_item diff --git a/app/controllers/api/todo_lists_controller.rb b/app/controllers/api/todo_lists_controller.rb index 69d49ca4..4d273d0c 100644 --- a/app/controllers/api/todo_lists_controller.rb +++ b/app/controllers/api/todo_lists_controller.rb @@ -1,67 +1,67 @@ - class Api::TodoListsController < ApplicationController - skip_before_action :verify_authenticity_token - rescue_from ActiveRecord::RecordNotFound, with: :todo_list_not_found - before_action :set_todo_list, only: %i[ show update destroy ] +class Api::TodoListsController < ApplicationController + skip_before_action :verify_authenticity_token + rescue_from ActiveRecord::RecordNotFound, with: :todo_list_not_found + before_action :set_todo_list, only: %i[ show update destroy ] - def index - @todo_lists = TodoList.page(params[:page]).per(params[:per_page] || 10) - render json: { - todo_lists: @todo_lists, - total_pages: @todo_lists.total_pages, - current_page: @todo_lists.current_page, - total_count: @todo_lists.total_count - }, status: :ok - end - - def show - render json: @todo_list, status: :ok - end + def index + @todo_lists = TodoList.page(params[:page]).per(params[:per_page] || 10) + render json: { + todo_lists: @todo_lists, + total_pages: @todo_lists.total_pages, + current_page: @todo_lists.current_page, + total_count: @todo_lists.total_count + }, status: :ok + end - def create - @todo_list = TodoList.new(todo_list_params) - if @todo_list.save - render json: { - msg: "Todo list created successfully", - todo_list: @todo_list - }, status: :created - else - error_handler(@todo_list.errors) - end - end + def show + render json: @todo_list, status: :ok + end - def update - if @todo_list.update(todo_list_params) - render json: { - msg: "Todo list updated successfully", - todo_list: @todo_list - }, status: :ok - else - error_handler(@todo_list.errors) - end + def create + @todo_list = TodoList.new(todo_list_params) + if @todo_list.save + render json: { + msg: "Todo list created successfully", + todo_list: @todo_list + }, status: :created + else + error_handler(@todo_list.errors) end + end - def destroy - @todo_list.destroy + def update + if @todo_list.update(todo_list_params) render json: { - msg: "Todo list deleted successfully" + msg: "Todo list updated successfully", + todo_list: @todo_list }, status: :ok + else + error_handler(@todo_list.errors) end + end - private + def destroy + @todo_list.destroy + render json: { + msg: "Todo list deleted successfully" + }, status: :ok + end - def set_todo_list - @todo_list = TodoList.find(params[:id]) - end + private - def todo_list_params - params.require(:todo_list).permit(:name) - end + def set_todo_list + @todo_list = TodoList.find(params[:id]) + end - def error_handler(errors) - render json: { errors: errors.full_messages }, status: :unprocessable_entity - end + def todo_list_params + params.require(:todo_list).permit(:name) + end - def todo_list_not_found - render json: { error: "Todo list not found" }, status: :not_found - end + def error_handler(errors) + render json: { errors: errors.full_messages }, status: :unprocessable_entity + end + + def todo_list_not_found + render json: { error: "Todo list not found" }, status: :not_found end +end diff --git a/app/controllers/items_controller.rb b/app/controllers/items_controller.rb index 44746df4..0873a96b 100644 --- a/app/controllers/items_controller.rb +++ b/app/controllers/items_controller.rb @@ -1,4 +1,6 @@ class ItemsController < ApplicationController + rescue_from ActiveRecord::RecordNotFound, with: :item_not_found + before_action :authenticate_user! before_action :set_todo_list before_action :set_item, only: %i[update destroy] @@ -15,7 +17,7 @@ def create ] end else - format.html { redirect_to @todo_list, alert: 'Error creating item.' } + format.html { redirect_to @todo_list, alert: 'Unable to create item. Please check the form.' } format.turbo_stream do render turbo_stream.update("new_item", partial: "items/form", @@ -37,7 +39,7 @@ def update ) } else - format.html { redirect_to @todo_list, alert: 'Error updating item.' } + format.html { redirect_to @todo_list, alert: 'Unable to update item. Please check the form.' } format.turbo_stream { render turbo_stream: turbo_stream.replace( @item, @@ -61,6 +63,8 @@ def destroy def set_todo_list @todo_list = TodoList.find(params[:todo_list_id]) + rescue ActiveRecord::RecordNotFound + redirect_to root_path, alert: 'The todo list you requested could not be found.' end def set_item @@ -70,4 +74,8 @@ def set_item def item_params params.require(:item).permit(:title, :description, :completed) end + + def item_not_found + redirect_to root_path, alert: 'The item you requested could not be found.' + end end diff --git a/app/controllers/todo_lists_controller.rb b/app/controllers/todo_lists_controller.rb index cbeb4a96..a10db17a 100644 --- a/app/controllers/todo_lists_controller.rb +++ b/app/controllers/todo_lists_controller.rb @@ -1,4 +1,5 @@ class TodoListsController < ApplicationController + rescue_from ActiveRecord::RecordNotFound, with: :todo_list_not_found before_action :authenticate_user! before_action :set_todo_list, only: %i[ show edit update destroy ] @@ -43,7 +44,7 @@ def destroy respond_to do |format| format.turbo_stream { render turbo_stream: turbo_stream.remove(@todo_list) } - format.html { redirect_to todo_lists_url, notice: 'Todo list was successfully destroyed.' } + format.html { redirect_to todo_lists_url, notice: 'Todo list was successfully deleted.' } end end @@ -56,4 +57,8 @@ def set_todo_list def todo_list_params params.require(:todo_list).permit(:name) end + + def todo_list_not_found + redirect_to root_path, alert: 'The todo list you requested could not be found.' + end end diff --git a/app/models/todo_list.rb b/app/models/todo_list.rb index da38afc0..5b8d2eb1 100644 --- a/app/models/todo_list.rb +++ b/app/models/todo_list.rb @@ -1,5 +1,5 @@ class TodoList < ApplicationRecord - belongs_to :user + belongs_to :user, optional: true has_many :items, dependent: :destroy validates :name, presence: true diff --git a/config/routes.rb b/config/routes.rb index eecc0f0a..2a933d02 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -7,8 +7,8 @@ end namespace :api do - resources :todo_lists, path: :todolists, only: [] do - resources :items, path: :todos, only: [:update] + resources :todo_lists, path: :todolists, except: [:new, :edit] do + resources :items, path: :todos, except: [:new, :edit] end end end diff --git a/db/migrate/20250130040549_create_todo_lists.rb b/db/migrate/20250130040549_create_todo_lists.rb index 72ab0458..ecbf8757 100644 --- a/db/migrate/20250130040549_create_todo_lists.rb +++ b/db/migrate/20250130040549_create_todo_lists.rb @@ -2,7 +2,7 @@ class CreateTodoLists < ActiveRecord::Migration[7.0] def change create_table :todo_lists do |t| t.string :name, null: false - t.references :user, null: false, foreign_key: true + t.references :user, null: true, foreign_key: true t.timestamps end diff --git a/db/schema.rb b/db/schema.rb index 2967b084..f277bf63 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -23,7 +23,7 @@ create_table "todo_lists", force: :cascade do |t| t.string "name", null: false - t.integer "user_id", null: false + t.integer "user_id" t.datetime "created_at", null: false t.datetime "updated_at", null: false t.index ["user_id"], name: "index_todo_lists_on_user_id" diff --git a/spec/controllers/api/items_controller_spec.rb b/spec/controllers/api/items_controller_spec.rb new file mode 100644 index 00000000..2b56339a --- /dev/null +++ b/spec/controllers/api/items_controller_spec.rb @@ -0,0 +1,187 @@ +require 'rails_helper' + +RSpec.describe Api::ItemsController, type: :controller do + let(:todo_list) { create(:todo_list) } + let!(:item) { create(:item, todo_list: todo_list, title: "Test Item", completed: false) } + let(:other_todo_list) { create(:todo_list) } + let(:other_item) { create(:item, todo_list: other_todo_list) } + + describe 'GET #index' do + it 'returns paginated items' do + get :index, params: { todo_list_id: todo_list.id }, format: :json + + expect(response).to have_http_status(:success) + json_response = JSON.parse(response.body) + + expect(json_response['items']).to be_present + expect(json_response['total_pages']).to be_present + expect(json_response['current_page']).to be_present + expect(json_response['total_count']).to be_present + end + + it 'respects per_page parameter' do + 5.times { create(:item, todo_list: todo_list) } + + get :index, params: { todo_list_id: todo_list.id, per_page: 3 }, format: :json + + json_response = JSON.parse(response.body) + expect(json_response['items'].length).to eq(3) + end + end + + describe 'GET #show' do + it 'returns the item' do + get :show, params: { todo_list_id: todo_list.id, id: item.id }, format: :json + + expect(response).to have_http_status(:success) + json_response = JSON.parse(response.body) + expect(json_response['item']['id']).to eq(item.id) + end + + it 'returns not found for non-existent item' do + get :show, params: { todo_list_id: todo_list.id, id: 999999 }, format: :json + + expect(response).to have_http_status(:not_found) + json_response = JSON.parse(response.body) + expect(json_response['error']).to eq('Item not found') + end + end + + describe 'POST #create' do + let(:valid_params) do + { + todo_list_id: todo_list.id, + item: { title: "New Item", description: "Description" } + } + end + + let(:invalid_params) do + { + todo_list_id: todo_list.id, + item: { title: "" } + } + end + + context 'with valid parameters' do + it 'creates a new item' do + expect { + post :create, params: valid_params, format: :json + }.to change(Item, :count).by(1) + + expect(response).to have_http_status(:created) + json_response = JSON.parse(response.body) + expect(json_response['msg']).to eq('Item created successfully') + expect(json_response['item']['title']).to eq('New Item') + end + end + + context 'with invalid parameters' do + it 'does not create an item' do + expect { + post :create, params: invalid_params, format: :json + }.not_to change(Item, :count) + + expect(response).to have_http_status(:unprocessable_entity) + json_response = JSON.parse(response.body) + expect(json_response['errors']).to include("Title can't be blank") + end + end + end + + describe 'PATCH #update' do + let(:update_params) do + { + todo_list_id: todo_list.id, + id: item.id, + item: { title: "Updated Title" } + } + end + + let(:invalid_update_params) do + { + todo_list_id: todo_list.id, + id: item.id, + item: { title: "" } + } + end + + context 'with valid parameters' do + it 'updates the item' do + patch :update, params: update_params, format: :json + + expect(response).to have_http_status(:success) + json_response = JSON.parse(response.body) + expect(json_response['msg']).to eq('Item updated successfully') + expect(item.reload.title).to eq('Updated Title') + end + end + + context 'with invalid parameters' do + it 'does not update the item' do + patch :update, params: invalid_update_params, format: :json + + expect(response).to have_http_status(:unprocessable_entity) + json_response = JSON.parse(response.body) + expect(json_response['errors']).to include("Title can't be blank") + end + end + + context 'when item does not exist' do + it 'returns not found status' do + patch :update, params: { + todo_list_id: todo_list.id, + id: 999999, + item: { title: "Updated Title" } + }, format: :json + + expect(response).to have_http_status(:not_found) + json_response = JSON.parse(response.body) + expect(json_response['error']).to eq('Item not found') + end + end + end + + describe 'DELETE #destroy' do + it 'deletes the item' do + expect { + delete :destroy, params: { todo_list_id: todo_list.id, id: item.id }, format: :json + }.to change(Item, :count).by(-1) + + expect(response).to have_http_status(:success) + json_response = JSON.parse(response.body) + expect(json_response['msg']).to eq('Item deleted successfully') + end + + it 'returns not found for non-existent item' do + delete :destroy, params: { todo_list_id: todo_list.id, id: 999999 }, format: :json + + expect(response).to have_http_status(:not_found) + json_response = JSON.parse(response.body) + expect(json_response['error']).to eq('Item not found') + end + end + + context 'when todo list does not exist' do + it 'returns not found status for all actions' do + get :index, params: { todo_list_id: 999999 }, format: :json + expect(response).to have_http_status(:not_found) + expect(JSON.parse(response.body)['error']).to eq('Todo list not found') + + get :show, params: { todo_list_id: 999999, id: item.id }, format: :json + expect(response).to have_http_status(:not_found) + expect(JSON.parse(response.body)['error']).to eq('Todo list not found') + + post :create, params: { todo_list_id: 999999, item: { title: "New" } }, format: :json + expect(response).to have_http_status(:not_found) + expect(JSON.parse(response.body)['error']).to eq('Todo list not found') + + patch :update, params: { todo_list_id: 999999, id: item.id, item: { title: "Updated" } }, format: :json + expect(response).to have_http_status(:not_found) + expect(JSON.parse(response.body)['error']).to eq('Todo list not found') + + delete :destroy, params: { todo_list_id: 999999, id: item.id }, format: :json + expect(response).to have_http_status(:not_found) + expect(JSON.parse(response.body)['error']).to eq('Todo list not found') + end + end +end diff --git a/spec/controllers/api/todo_lists_controller_spec.rb b/spec/controllers/api/todo_lists_controller_spec.rb index 78410bf2..8486b84f 100644 --- a/spec/controllers/api/todo_lists_controller_spec.rb +++ b/spec/controllers/api/todo_lists_controller_spec.rb @@ -1,38 +1,155 @@ require 'rails_helper' -describe Api::TodoListsController do - render_views +RSpec.describe Api::TodoListsController, type: :controller do + let!(:todo_list) { create(:todo_list, name: 'Setup RoR project') } - describe 'GET index' do - let!(:todo_list) { TodoList.create(name: 'Setup RoR project') } + describe 'GET #index' do + it 'returns paginated todo lists' do + get :index, format: :json - context 'when format is HTML' do - it 'raises a routing error' do + expect(response).to have_http_status(:success) + json_response = JSON.parse(response.body) + + expect(json_response['todo_lists']).to be_present + expect(json_response['total_pages']).to be_present + expect(json_response['current_page']).to be_present + expect(json_response['total_count']).to be_present + end + + it 'respects per_page parameter' do + 5.times { create(:todo_list) } + + get :index, params: { per_page: 3 }, format: :json + + json_response = JSON.parse(response.body) + expect(json_response['todo_lists'].length).to eq(3) + end + end + + describe 'GET #show' do + it 'returns the todo list' do + get :show, params: { id: todo_list.id }, format: :json + + expect(response).to have_http_status(:success) + expect(JSON.parse(response.body)['id']).to eq(todo_list.id) + expect(JSON.parse(response.body)['name']).to eq(todo_list.name) + end + + it 'returns not found for non-existent todo list' do + get :show, params: { id: 999999 }, format: :json + + expect(response).to have_http_status(:not_found) + json_response = JSON.parse(response.body) + expect(json_response['error']).to eq('Todo list not found') + end + end + + describe 'POST #create' do + let(:valid_params) do + { + todo_list: { name: 'New Todo List' } + } + end + + let(:invalid_params) do + { + todo_list: { name: '' } + } + end + + context 'with valid parameters' do + it 'creates a new todo list' do expect { - get :index - }.to raise_error(ActionController::RoutingError, 'Not supported format') + post :create, params: valid_params, format: :json + }.to change(TodoList, :count).by(1) + + expect(response).to have_http_status(:created) + json_response = JSON.parse(response.body) + expect(json_response['msg']).to eq('Todo list created successfully') + expect(json_response['todo_list']['name']).to eq('New Todo List') end end - context 'when format is JSON' do - it 'returns a success code' do - get :index, format: :json + context 'with invalid parameters' do + it 'does not create a todo list' do + expect { + post :create, params: invalid_params, format: :json + }.not_to change(TodoList, :count) - expect(response.status).to eq(200) + expect(response).to have_http_status(:unprocessable_entity) + json_response = JSON.parse(response.body) + expect(json_response['errors']).to include("Name can't be blank") end + end + end + + describe 'PATCH #update' do + let(:update_params) do + { + id: todo_list.id, + todo_list: { name: 'Updated Name' } + } + end + + let(:invalid_update_params) do + { + id: todo_list.id, + todo_list: { name: '' } + } + end + + context 'with valid parameters' do + it 'updates the todo list' do + patch :update, params: update_params, format: :json + + expect(response).to have_http_status(:success) + json_response = JSON.parse(response.body) + expect(json_response['msg']).to eq('Todo list updated successfully') + expect(todo_list.reload.name).to eq('Updated Name') + end + end - it 'includes todo list records' do - get :index, format: :json + context 'with invalid parameters' do + it 'does not update the todo list' do + patch :update, params: invalid_update_params, format: :json - todo_lists = JSON.parse(response.body) + expect(response).to have_http_status(:unprocessable_entity) + json_response = JSON.parse(response.body) + expect(json_response['errors']).to include("Name can't be blank") + end + end + + context 'when todo list does not exist' do + it 'returns not found status' do + patch :update, params: { + id: 999999, + todo_list: { name: 'Updated Name' } + }, format: :json - aggregate_failures 'includes the id and name' do - expect(todo_lists.count).to eq(1) - expect(todo_lists[0].keys).to match_array(['id', 'name']) - expect(todo_lists[0]['id']).to eq(todo_list.id) - expect(todo_lists[0]['name']).to eq(todo_list.name) - end + expect(response).to have_http_status(:not_found) + json_response = JSON.parse(response.body) + expect(json_response['error']).to eq('Todo list not found') end end end + + describe 'DELETE #destroy' do + it 'deletes the todo list' do + expect { + delete :destroy, params: { id: todo_list.id }, format: :json + }.to change(TodoList, :count).by(-1) + + expect(response).to have_http_status(:success) + json_response = JSON.parse(response.body) + expect(json_response['msg']).to eq('Todo list deleted successfully') + end + + it 'returns not found for non-existent todo list' do + delete :destroy, params: { id: 999999 }, format: :json + + expect(response).to have_http_status(:not_found) + json_response = JSON.parse(response.body) + expect(json_response['error']).to eq('Todo list not found') + end + end end diff --git a/spec/controllers/items_controller_spec.rb b/spec/controllers/items_controller_spec.rb new file mode 100644 index 00000000..6fb15f91 --- /dev/null +++ b/spec/controllers/items_controller_spec.rb @@ -0,0 +1,228 @@ +require 'rails_helper' + +RSpec.describe ItemsController, type: :controller do + let(:user) { create(:user) } + let(:todo_list) { create(:todo_list, user: user) } + let!(:item) { create(:item, todo_list: todo_list, title: 'Test Item') } + + let(:other_user) { create(:user) } + let(:other_todo_list) { create(:todo_list, user: other_user) } + let!(:other_item) { create(:item, todo_list: other_todo_list, title: 'Other Item') } + + before do + sign_in user + end + + describe 'POST #create' do + context 'with valid parameters' do + let(:valid_params) do + { + todo_list_id: todo_list.id, + item: { title: 'New Item', description: 'Description' } + } + end + + it 'creates a new item' do + expect { + post :create, params: valid_params, format: :turbo_stream + }.to change(Item, :count).by(1) + end + + it 'creates an item associated with the todo list' do + post :create, params: valid_params, format: :turbo_stream + expect(Item.last.todo_list).to eq(todo_list) + end + + it 'returns a turbo stream response' do + post :create, params: valid_params, format: :turbo_stream + expect(response.media_type).to eq Mime[:turbo_stream] + end + + it 'redirects to todo list with HTML format' do + post :create, params: valid_params + expect(response).to redirect_to(todo_list) + expect(flash[:notice]).to eq('Item was successfully created.') + end + end + + context 'with invalid parameters' do + let(:invalid_params) do + { + todo_list_id: todo_list.id, + item: { title: '' } + } + end + + it 'does not create a new item' do + expect { + post :create, params: invalid_params + }.not_to change(Item, :count) + end + + it 'redirects to todo list with alert' do + post :create, params: invalid_params + expect(response).to redirect_to(todo_list) + expect(flash[:alert]).to eq('Unable to create item. Please check the form.') + end + end + + context 'when accessing another user\'s todo list' do + let(:invalid_access_params) do + { + todo_list_id: other_todo_list.id, + item: { title: 'Hacked Item' } + } + end + + it 'redirects to todo list' do + post :create, params: invalid_access_params + expect(response).to redirect_to(todo_list_path(other_todo_list)) + end + end + end + + describe 'PATCH #update' do + context 'with valid parameters' do + let(:update_params) do + { + todo_list_id: todo_list.id, + id: item.id, + item: { title: 'Updated Item', completed: true } + } + end + + it 'updates the item' do + patch :update, params: update_params, format: :turbo_stream + item.reload + expect(item.title).to eq('Updated Item') + expect(item.completed).to be true + end + + it 'returns a turbo stream response' do + patch :update, params: update_params, format: :turbo_stream + expect(response.media_type).to eq Mime[:turbo_stream] + end + + it 'redirects to todo list with HTML format' do + patch :update, params: update_params + expect(response).to redirect_to(todo_list) + expect(flash[:notice]).to eq('Item was successfully updated.') + end + end + + context 'with invalid parameters' do + let(:invalid_update_params) do + { + todo_list_id: todo_list.id, + id: item.id, + item: { title: '' } + } + end + + it 'does not update the item' do + original_title = item.title + patch :update, params: invalid_update_params + item.reload + expect(item.title).to eq(original_title) + end + + it 'redirects to todo list with alert' do + patch :update, params: invalid_update_params + expect(response).to redirect_to(todo_list) + expect(flash[:alert]).to eq('Unable to update item. Please check the form.') + end + end + + context 'when updating another user\'s item' do + let(:other_item_params) do + { + todo_list_id: other_todo_list.id, + id: other_item.id, + item: { title: 'Hacked' } + } + end + + it 'redirects to todo list' do + patch :update, params: other_item_params + expect(response).to redirect_to(todo_list_path(other_todo_list)) + end + end + + context 'when item is not found' do + it 'redirects to root path with alert' do + patch :update, params: { todo_list_id: todo_list.id, id: 999999, item: { title: 'Not Found' } } + expect(response).to redirect_to(root_path) + expect(flash[:alert]).to eq('The item you requested could not be found.') + end + end + end + + describe 'DELETE #destroy' do + context 'when deleting own item' do + let(:delete_params) do + { + todo_list_id: todo_list.id, + id: item.id + } + end + + it 'destroys the item' do + expect { + delete :destroy, params: delete_params, format: :turbo_stream + }.to change(Item, :count).by(-1) + end + + it 'returns a turbo stream response' do + delete :destroy, params: delete_params, format: :turbo_stream + expect(response.media_type).to eq Mime[:turbo_stream] + end + + it 'redirects to todo list with HTML format' do + delete :destroy, params: delete_params + expect(response).to redirect_to(todo_list) + expect(flash[:notice]).to eq('Item was successfully deleted.') + end + end + + context 'when deleting another user\'s item' do + let(:other_delete_params) do + { + todo_list_id: other_todo_list.id, + id: other_item.id + } + end + + it 'redirects to todo list' do + delete :destroy, params: other_delete_params + expect(response).to redirect_to(todo_list_path(other_todo_list)) + end + end + + context 'when item is not found' do + it 'redirects to root path with alert' do + delete :destroy, params: { todo_list_id: todo_list.id, id: 999999 } + expect(response).to redirect_to(root_path) + expect(flash[:alert]).to eq('The item you requested could not be found.') + end + end + end + + context 'when user is not authenticated' do + before { sign_out user } + + it 'redirects to login page for create' do + post :create, params: { todo_list_id: todo_list.id, item: { title: 'New Item' } } + expect(response).to redirect_to(new_user_session_path) + end + + it 'redirects to login page for update' do + patch :update, params: { todo_list_id: todo_list.id, id: item.id, item: { title: 'Updated' } } + expect(response).to redirect_to(new_user_session_path) + end + + it 'redirects to login page for destroy' do + delete :destroy, params: { todo_list_id: todo_list.id, id: item.id } + expect(response).to redirect_to(new_user_session_path) + end + end +end diff --git a/spec/controllers/todo_lists_controller_spec.rb b/spec/controllers/todo_lists_controller_spec.rb new file mode 100644 index 00000000..975db90c --- /dev/null +++ b/spec/controllers/todo_lists_controller_spec.rb @@ -0,0 +1,198 @@ +require 'rails_helper' + +RSpec.describe TodoListsController, type: :controller do + let(:user) { create(:user) } + let!(:todo_list) { create(:todo_list, user: user, name: 'Setup RoR project') } + let(:other_user) { create(:user) } + let!(:other_todo_list) { create(:todo_list, user: other_user, name: 'Other list') } + + before do + sign_in user + end + + describe 'GET #index' do + it 'returns a successful response' do + get :index + expect(response).to be_successful + end + + it 'assigns only the user\'s todo lists' do + get :index + expect(assigns(:todo_lists)).to include(todo_list) + expect(assigns(:todo_lists)).not_to include(other_todo_list) + end + + it 'renders the index template' do + get :index + expect(response).to render_template(:index) + end + end + + describe 'GET #show' do + context 'when accessing own todo list' do + it 'returns a successful response' do + get :show, params: { id: todo_list.id } + expect(response).to be_successful + end + + it 'assigns the requested todo list' do + get :show, params: { id: todo_list.id } + expect(assigns(:todo_list)).to eq(todo_list) + end + end + + context 'when accessing another user\'s todo list' do + it 'redirects to root path with alert' do + get :show, params: { id: other_todo_list.id } + expect(response).to redirect_to(root_path) + expect(flash[:alert]).to eq('The todo list you requested could not be found.') + end + end + end + + describe 'POST #create' do + context 'with valid parameters' do + let(:valid_params) { { todo_list: { name: 'New Todo List' } } } + + it 'creates a new todo list' do + expect { + post :create, params: valid_params, format: :turbo_stream + }.to change(TodoList, :count).by(1) + end + + it 'creates a todo list associated with the current user' do + post :create, params: valid_params, format: :turbo_stream + expect(TodoList.last.user).to eq(user) + end + + it 'returns a turbo stream response' do + post :create, params: valid_params, format: :turbo_stream + expect(response.media_type).to eq Mime[:turbo_stream] + end + + it 'redirects to todo lists path with HTML format' do + post :create, params: valid_params + expect(response).to redirect_to(todo_lists_path) + expect(flash[:notice]).to eq('Todo list was successfully created.') + end + end + + context 'with invalid parameters' do + let(:invalid_params) { { todo_list: { name: '' } } } + + it 'does not create a new todo list' do + expect { + post :create, params: invalid_params, format: :turbo_stream + }.not_to change(TodoList, :count) + end + end + end + + describe 'PATCH #update' do + context 'with valid parameters' do + let(:new_attributes) { { name: 'Updated Name' } } + + it 'updates the requested todo list' do + patch :update, params: { id: todo_list.id, todo_list: new_attributes } + todo_list.reload + expect(todo_list.name).to eq('Updated Name') + end + + it 'redirects to todo lists path' do + patch :update, params: { id: todo_list.id, todo_list: new_attributes } + expect(response).to redirect_to(todo_lists_path) + expect(flash[:notice]).to eq('Todo list was successfully updated.') + end + end + + context 'with invalid parameters' do + let(:invalid_attributes) { { name: '' } } + + it 'does not update the todo list' do + original_name = todo_list.name + patch :update, params: { id: todo_list.id, todo_list: invalid_attributes } + todo_list.reload + expect(todo_list.name).to eq(original_name) + end + + it 'returns unprocessable entity status' do + patch :update, params: { id: todo_list.id, todo_list: invalid_attributes } + expect(response).to have_http_status(:unprocessable_entity) + end + end + + context 'when updating another user\'s todo list' do + it 'redirects to root path with alert' do + patch :update, params: { id: other_todo_list.id, todo_list: { name: 'Hacked' } } + expect(response).to redirect_to(root_path) + expect(flash[:alert]).to eq('The todo list you requested could not be found.') + end + end + end + + describe 'DELETE #destroy' do + context 'when deleting own todo list' do + it 'destroys the requested todo list' do + expect { + delete :destroy, params: { id: todo_list.id }, format: :turbo_stream + }.to change(TodoList, :count).by(-1) + end + + it 'returns a turbo stream response' do + delete :destroy, params: { id: todo_list.id }, format: :turbo_stream + expect(response.media_type).to eq Mime[:turbo_stream] + end + + it 'redirects to todo lists path with HTML format' do + delete :destroy, params: { id: todo_list.id } + expect(response).to redirect_to(todo_lists_url) + expect(flash[:notice]).to eq('Todo list was successfully deleted.') + end + end + + context 'when deleting another user\'s todo list' do + it 'redirects to root path with alert' do + delete :destroy, params: { id: other_todo_list.id } + expect(response).to redirect_to(root_path) + expect(flash[:alert]).to eq('The todo list you requested could not be found.') + end + end + end + + context 'when user is not authenticated' do + before { sign_out user } + + it 'redirects to login page for index' do + get :index + expect(response).to redirect_to(new_user_session_path) + end + + it 'redirects to login page for show' do + get :show, params: { id: todo_list.id } + expect(response).to redirect_to(new_user_session_path) + end + + it 'redirects to login page for create' do + post :create, params: { todo_list: { name: 'New List' } } + expect(response).to redirect_to(new_user_session_path) + end + + it 'redirects to login page for update' do + patch :update, params: { id: todo_list.id, todo_list: { name: 'Updated' } } + expect(response).to redirect_to(new_user_session_path) + end + + it 'redirects to login page for destroy' do + delete :destroy, params: { id: todo_list.id } + expect(response).to redirect_to(new_user_session_path) + end + end + + context 'when accessing another user\'s todo list' do + it 'redirects to root path with alert' do + get :show, params: { id: other_todo_list.id } + expect(response).to redirect_to(root_path) + expect(flash[:alert]).to eq('The todo list you requested could not be found.') + end + end +end diff --git a/spec/factories/items.rb b/spec/factories/items.rb new file mode 100644 index 00000000..67d5adc3 --- /dev/null +++ b/spec/factories/items.rb @@ -0,0 +1,8 @@ +FactoryBot.define do + factory :item do + sequence(:title) { |n| "Item #{n}" } + sequence(:description) { |n| "Description for item #{n}" } + completed { false } + association :todo_list + end +end diff --git a/spec/factories/todo_lists.rb b/spec/factories/todo_lists.rb new file mode 100644 index 00000000..39c751dc --- /dev/null +++ b/spec/factories/todo_lists.rb @@ -0,0 +1,6 @@ +FactoryBot.define do + factory :todo_list do + sequence(:name) { |n| "Todo List #{n}" } + association :user + end +end diff --git a/spec/factories/users.rb b/spec/factories/users.rb new file mode 100644 index 00000000..76fd1d66 --- /dev/null +++ b/spec/factories/users.rb @@ -0,0 +1,7 @@ +FactoryBot.define do + factory :user do + sequence(:email) { |n| "user#{n}@example.com" } + password { 'password123' } + password_confirmation { 'password123' } + end +end diff --git a/spec/helpers/items_helper_spec.rb b/spec/helpers/items_helper_spec.rb deleted file mode 100644 index c1652491..00000000 --- a/spec/helpers/items_helper_spec.rb +++ /dev/null @@ -1,15 +0,0 @@ -require 'rails_helper' - -# Specs in this file have access to a helper object that includes -# the ItemsHelper. For example: -# -# describe ItemsHelper do -# describe "string concat" do -# it "concats two strings with spaces" do -# expect(helper.concat_strings("this","that")).to eq("this that") -# end -# end -# end -RSpec.describe ItemsHelper, type: :helper do - pending "add some examples to (or delete) #{__FILE__}" -end diff --git a/spec/models/item_spec.rb b/spec/models/item_spec.rb index 9580acfa..ed7cb0ab 100644 --- a/spec/models/item_spec.rb +++ b/spec/models/item_spec.rb @@ -1,5 +1,43 @@ require 'rails_helper' RSpec.describe Item, type: :model do - pending "add some examples to (or delete) #{__FILE__}" + describe 'associations' do + it { should belong_to(:todo_list) } + end + + describe 'validations' do + it { should validate_presence_of(:title) } + it { should validate_inclusion_of(:completed).in_array([true, false]) } + end + + describe 'creation' do + let(:todo_list) { create(:todo_list) } + + it 'creates a valid item' do + item = build(:item, todo_list: todo_list, title: 'Test Item') + expect(item).to be_valid + end + + it 'is invalid without a title' do + item = build(:item, todo_list: todo_list, title: nil) + expect(item).not_to be_valid + expect(item.errors[:title]).to include("can't be blank") + end + + it 'is valid without a description' do + item = build(:item, todo_list: todo_list, title: 'Test Item', description: nil) + expect(item).to be_valid + end + + it 'sets completed to false by default' do + item = create(:item, todo_list: todo_list, title: 'Test Item') + expect(item.completed).to be false + end + + it 'is invalid without a todo list' do + item = build(:item, todo_list: nil, title: 'Test Item') + expect(item).not_to be_valid + expect(item.errors[:todo_list]).to include("must exist") + end + end end diff --git a/spec/models/todo_list_spec.rb b/spec/models/todo_list_spec.rb index 04043313..da844248 100644 --- a/spec/models/todo_list_spec.rb +++ b/spec/models/todo_list_spec.rb @@ -1,5 +1,46 @@ require 'rails_helper' RSpec.describe TodoList, type: :model do - pending "add some examples to (or delete) #{__FILE__}" + describe 'associations' do + it { should belong_to(:user).optional } + it { should have_many(:items).dependent(:destroy) } + end + + describe 'validations' do + it { should validate_presence_of(:name) } + end + + describe 'creation' do + it 'creates a valid todo list with a user' do + user = create(:user) + todo_list = build(:todo_list, user: user, name: 'My Todo List') + expect(todo_list).to be_valid + end + + it 'creates a valid todo list without a user' do + todo_list = build(:todo_list, user: nil, name: 'My Todo List') + expect(todo_list).to be_valid + end + + it 'is invalid without a name' do + todo_list = build(:todo_list, name: nil) + expect(todo_list).not_to be_valid + expect(todo_list.errors[:name]).to include("can't be blank") + end + end + + describe 'items association' do + let(:todo_list) { create(:todo_list, name: 'My Todo List') } + + it 'can have many items' do + create(:item, todo_list: todo_list, title: 'First Item') + create(:item, todo_list: todo_list, title: 'Second Item') + expect(todo_list.items.count).to eq(2) + end + + it 'destroys associated items when deleted' do + create(:item, todo_list: todo_list, title: 'Test Item') + expect { todo_list.destroy }.to change(Item, :count).by(-1) + end + end end diff --git a/spec/models/user_spec.rb b/spec/models/user_spec.rb index 47a31bb4..47f65eee 100644 --- a/spec/models/user_spec.rb +++ b/spec/models/user_spec.rb @@ -1,5 +1,24 @@ require 'rails_helper' RSpec.describe User, type: :model do - pending "add some examples to (or delete) #{__FILE__}" + describe 'associations' do + it { should have_many(:todo_lists).dependent(:destroy) } + end + + describe 'validations' do + it { should validate_presence_of(:email) } + it { should validate_uniqueness_of(:email).case_insensitive } + it { should validate_presence_of(:password) } + end + + describe 'creation' do + it 'creates a valid user' do + user = User.new( + email: 'test@example.com', + password: 'password123', + password_confirmation: 'password123' + ) + expect(user).to be_valid + end + end end diff --git a/spec/rails_helper.rb b/spec/rails_helper.rb index a53bdba2..fa7f93cc 100644 --- a/spec/rails_helper.rb +++ b/spec/rails_helper.rb @@ -31,7 +31,7 @@ end RSpec.configure do |config| # Remove this line if you're not using ActiveRecord or ActiveRecord fixtures - config.fixture_path = "#{::Rails.root}/spec/fixtures" + config.fixture_path = Rails.root.join('spec/fixtures') # If you're not using ActiveRecord, or you'd prefer not to run each of your # examples within a transaction, remove the following line or assign false @@ -60,4 +60,19 @@ config.filter_rails_from_backtrace! # arbitrary gems may also be filtered via: # config.filter_gems_from_backtrace("gem name") + + # Include FactoryBot methods + config.include FactoryBot::Syntax::Methods + + # Include Devise test helpers + config.include Devise::Test::ControllerHelpers, type: :controller + config.include Devise::Test::IntegrationHelpers, type: :request +end + +# Configure shoulda-matchers +Shoulda::Matchers.configure do |config| + config.integrate do |with| + with.test_framework :rspec + with.library :rails + end end diff --git a/spec/requests/items_spec.rb b/spec/requests/items_spec.rb deleted file mode 100644 index 063e6325..00000000 --- a/spec/requests/items_spec.rb +++ /dev/null @@ -1,7 +0,0 @@ -require 'rails_helper' - -RSpec.describe "Items", type: :request do - describe "GET /index" do - pending "add some examples (or delete) #{__FILE__}" - end -end