Commit dbe15b4a authored by Nick Thomas's avatar Nick Thomas

Merge branch 'bw-automatically-navigate-to-last-board-visited' into 'master'

Automatically navigate to last board visited

See merge request gitlab-org/gitlab-ce!22430
parents 9791f82b 7aeab58f
...@@ -5,6 +5,7 @@ class Groups::BoardsController < Groups::ApplicationController ...@@ -5,6 +5,7 @@ class Groups::BoardsController < Groups::ApplicationController
before_action :assign_endpoint_vars before_action :assign_endpoint_vars
before_action :boards, only: :index before_action :boards, only: :index
before_action :redirect_to_recent_board, only: :index
def index def index
respond_with_boards respond_with_boards
...@@ -13,6 +14,9 @@ class Groups::BoardsController < Groups::ApplicationController ...@@ -13,6 +14,9 @@ class Groups::BoardsController < Groups::ApplicationController
def show def show
@board = boards.find(params[:id]) @board = boards.find(params[:id])
# add/update the board in the recent visited table
Boards::Visits::CreateService.new(@board.group, current_user).execute(@board) if request.format.html?
respond_with_board respond_with_board
end end
...@@ -31,4 +35,18 @@ class Groups::BoardsController < Groups::ApplicationController ...@@ -31,4 +35,18 @@ class Groups::BoardsController < Groups::ApplicationController
def serialize_as_json(resource) def serialize_as_json(resource)
resource.as_json(only: [:id]) resource.as_json(only: [:id])
end end
def includes_board?(board_id)
boards.any? { |board| board.id == board_id }
end
def redirect_to_recent_board
return if request.format.json?
recently_visited = Boards::Visits::LatestService.new(group, current_user).execute
if recently_visited && includes_board?(recently_visited.board_id)
redirect_to(group_board_path(id: recently_visited.board_id), status: :found)
end
end
end end
...@@ -8,6 +8,7 @@ class Projects::BoardsController < Projects::ApplicationController ...@@ -8,6 +8,7 @@ class Projects::BoardsController < Projects::ApplicationController
before_action :authorize_read_board!, only: [:index, :show] before_action :authorize_read_board!, only: [:index, :show]
before_action :boards, only: :index before_action :boards, only: :index
before_action :assign_endpoint_vars before_action :assign_endpoint_vars
before_action :redirect_to_recent_board, only: :index
def index def index
respond_with_boards respond_with_boards
...@@ -16,6 +17,9 @@ class Projects::BoardsController < Projects::ApplicationController ...@@ -16,6 +17,9 @@ class Projects::BoardsController < Projects::ApplicationController
def show def show
@board = boards.find(params[:id]) @board = boards.find(params[:id])
# add/update the board in the recent visited table
Boards::Visits::CreateService.new(@board.project, current_user).execute(@board) if request.format.html?
respond_with_board respond_with_board
end end
...@@ -33,10 +37,24 @@ class Projects::BoardsController < Projects::ApplicationController ...@@ -33,10 +37,24 @@ class Projects::BoardsController < Projects::ApplicationController
end end
def authorize_read_board! def authorize_read_board!
return access_denied! unless can?(current_user, :read_board, project) access_denied! unless can?(current_user, :read_board, project)
end end
def serialize_as_json(resource) def serialize_as_json(resource)
resource.as_json(only: [:id]) resource.as_json(only: [:id])
end end
def includes_board?(board_id)
boards.any? { |board| board.id == board_id }
end
def redirect_to_recent_board
return if request.format.json?
recently_visited = Boards::Visits::LatestService.new(project, current_user).execute
if recently_visited && includes_board?(recently_visited.board_id)
redirect_to(namespace_project_board_path(id: recently_visited.board_id), status: :found)
end
end
end end
# frozen_string_literal: true
# Tracks which boards in a specific group a user has visited
class BoardGroupRecentVisit < ActiveRecord::Base
belongs_to :user
belongs_to :group
belongs_to :board
validates :user, presence: true
validates :group, presence: true
validates :board, presence: true
scope :by_user_group, -> (user, group) { where(user: user, group: group).order(:updated_at) }
def self.visited!(user, board)
visit = find_or_create_by(user: user, group: board.group, board: board)
visit.touch if visit.updated_at < Time.now
rescue ActiveRecord::RecordNotUnique
retry
end
def self.latest(user, group)
by_user_group(user, group).last
end
end
# frozen_string_literal: true
# Tracks which boards in a specific project a user has visited
class BoardProjectRecentVisit < ActiveRecord::Base
belongs_to :user
belongs_to :project
belongs_to :board
validates :user, presence: true
validates :project, presence: true
validates :board, presence: true
scope :by_user_project, -> (user, project) { where(user: user, project: project).order(:updated_at) }
def self.visited!(user, board)
visit = find_or_create_by(user: user, project: board.project, board: board)
visit.touch if visit.updated_at < Time.now
rescue ActiveRecord::RecordNotUnique
retry
end
def self.latest(user, project)
by_user_project(user, project).last
end
end
# frozen_string_literal: true
module Boards
module Visits
class CreateService < Boards::BaseService
def execute(board)
return unless current_user && Gitlab::Database.read_write?
if parent.is_a?(Group)
BoardGroupRecentVisit.visited!(current_user, board)
else
BoardProjectRecentVisit.visited!(current_user, board)
end
end
end
end
end
# frozen_string_literal: true
module Boards
module Visits
class LatestService < Boards::BaseService
def execute
return nil unless current_user
if parent.is_a?(Group)
BoardGroupRecentVisit.latest(current_user, parent)
else
BoardProjectRecentVisit.latest(current_user, parent)
end
end
end
end
end
---
title: Automatically navigate to last board visited
merge_request: 22430
author:
type: changed
# frozen_string_literal: true
class CreateBoardProjectRecentVisits < ActiveRecord::Migration
include Gitlab::Database::MigrationHelpers
DOWNTIME = false
def change
create_table :board_project_recent_visits, id: :bigserial do |t|
t.timestamps_with_timezone null: false
t.references :user, index: true, foreign_key: { on_delete: :cascade }
t.references :project, index: true, foreign_key: { on_delete: :cascade }
t.references :board, index: true, foreign_key: { on_delete: :cascade }
end
add_index :board_project_recent_visits, [:user_id, :project_id, :board_id], unique: true, name: 'index_board_project_recent_visits_on_user_project_and_board'
end
end
# frozen_string_literal: true
class CreateBoardGroupRecentVisits < ActiveRecord::Migration
include Gitlab::Database::MigrationHelpers
DOWNTIME = false
def change
create_table :board_group_recent_visits, id: :bigserial do |t|
t.timestamps_with_timezone null: false
t.references :user, index: true, foreign_key: { on_delete: :cascade }
t.references :board, index: true, foreign_key: { on_delete: :cascade }
t.references :group, references: :namespace, column: :group_id, index: true
t.foreign_key :namespaces, column: :group_id, on_delete: :cascade
end
add_index :board_group_recent_visits, [:user_id, :group_id, :board_id], unique: true, name: 'index_board_group_recent_visits_on_user_group_and_board'
end
end
...@@ -11,7 +11,7 @@ ...@@ -11,7 +11,7 @@
# #
# It's strongly recommended that you check this file into your version control system. # It's strongly recommended that you check this file into your version control system.
ActiveRecord::Schema.define(version: 20181013005024) do ActiveRecord::Schema.define(version: 20181016152238) do
# These are extensions that must be enabled in order to support this database # These are extensions that must be enabled in order to support this database
enable_extension "plpgsql" enable_extension "plpgsql"
...@@ -204,6 +204,32 @@ ActiveRecord::Schema.define(version: 20181013005024) do ...@@ -204,6 +204,32 @@ ActiveRecord::Schema.define(version: 20181013005024) do
add_index "badges", ["group_id"], name: "index_badges_on_group_id", using: :btree add_index "badges", ["group_id"], name: "index_badges_on_group_id", using: :btree
add_index "badges", ["project_id"], name: "index_badges_on_project_id", using: :btree add_index "badges", ["project_id"], name: "index_badges_on_project_id", using: :btree
create_table "board_group_recent_visits", id: :bigserial, force: :cascade do |t|
t.datetime_with_timezone "created_at", null: false
t.datetime_with_timezone "updated_at", null: false
t.integer "user_id"
t.integer "board_id"
t.integer "group_id"
end
add_index "board_group_recent_visits", ["board_id"], name: "index_board_group_recent_visits_on_board_id", using: :btree
add_index "board_group_recent_visits", ["group_id"], name: "index_board_group_recent_visits_on_group_id", using: :btree
add_index "board_group_recent_visits", ["user_id", "group_id", "board_id"], name: "index_board_group_recent_visits_on_user_group_and_board", unique: true, using: :btree
add_index "board_group_recent_visits", ["user_id"], name: "index_board_group_recent_visits_on_user_id", using: :btree
create_table "board_project_recent_visits", id: :bigserial, force: :cascade do |t|
t.datetime_with_timezone "created_at", null: false
t.datetime_with_timezone "updated_at", null: false
t.integer "user_id"
t.integer "project_id"
t.integer "board_id"
end
add_index "board_project_recent_visits", ["board_id"], name: "index_board_project_recent_visits_on_board_id", using: :btree
add_index "board_project_recent_visits", ["project_id"], name: "index_board_project_recent_visits_on_project_id", using: :btree
add_index "board_project_recent_visits", ["user_id", "project_id", "board_id"], name: "index_board_project_recent_visits_on_user_project_and_board", unique: true, using: :btree
add_index "board_project_recent_visits", ["user_id"], name: "index_board_project_recent_visits_on_user_id", using: :btree
create_table "boards", force: :cascade do |t| create_table "boards", force: :cascade do |t|
t.integer "project_id" t.integer "project_id"
t.datetime "created_at", null: false t.datetime "created_at", null: false
...@@ -2306,6 +2332,12 @@ ActiveRecord::Schema.define(version: 20181013005024) do ...@@ -2306,6 +2332,12 @@ ActiveRecord::Schema.define(version: 20181013005024) do
add_foreign_key "application_settings", "users", column: "usage_stats_set_by_user_id", name: "fk_964370041d", on_delete: :nullify add_foreign_key "application_settings", "users", column: "usage_stats_set_by_user_id", name: "fk_964370041d", on_delete: :nullify
add_foreign_key "badges", "namespaces", column: "group_id", on_delete: :cascade add_foreign_key "badges", "namespaces", column: "group_id", on_delete: :cascade
add_foreign_key "badges", "projects", on_delete: :cascade add_foreign_key "badges", "projects", on_delete: :cascade
add_foreign_key "board_group_recent_visits", "boards", on_delete: :cascade
add_foreign_key "board_group_recent_visits", "namespaces", column: "group_id", on_delete: :cascade
add_foreign_key "board_group_recent_visits", "users", on_delete: :cascade
add_foreign_key "board_project_recent_visits", "boards", on_delete: :cascade
add_foreign_key "board_project_recent_visits", "projects", on_delete: :cascade
add_foreign_key "board_project_recent_visits", "users", on_delete: :cascade
add_foreign_key "boards", "namespaces", column: "group_id", on_delete: :cascade add_foreign_key "boards", "namespaces", column: "group_id", on_delete: :cascade
add_foreign_key "boards", "projects", name: "fk_f15266b5f9", on_delete: :cascade add_foreign_key "boards", "projects", name: "fk_f15266b5f9", on_delete: :cascade
add_foreign_key "chat_teams", "namespaces", on_delete: :cascade add_foreign_key "chat_teams", "namespaces", on_delete: :cascade
......
...@@ -176,6 +176,9 @@ Clicking on the current board name in the upper left corner will reveal a ...@@ -176,6 +176,9 @@ Clicking on the current board name in the upper left corner will reveal a
menu from where you can create another Issue Board and rename or delete the menu from where you can create another Issue Board and rename or delete the
existing one. existing one.
Clicking on the main issue board link will take you to the last board
you visited.
NOTE: **Note:** NOTE: **Note:**
The Multiple Issue Boards feature is available for The Multiple Issue Boards feature is available for
**projects in GitLab Starter Edition** and for **groups in GitLab Premium Edition**. **projects in GitLab Starter Edition** and for **groups in GitLab Premium Edition**.
......
...@@ -22,6 +22,27 @@ describe Groups::BoardsController do ...@@ -22,6 +22,27 @@ describe Groups::BoardsController do
expect(response.content_type).to eq 'text/html' expect(response.content_type).to eq 'text/html'
end end
it 'redirects to latest visited board' do
board = create(:board, group: group)
create(:board_group_recent_visit, group: board.group, board: board, user: user)
list_boards
expect(response).to redirect_to(group_board_path(id: board.id))
end
it 'renders template if visited board is not found' do
visited = double
allow(visited).to receive(:board_id).and_return(12)
allow_any_instance_of(Boards::Visits::LatestService).to receive(:execute).and_return(visited)
list_boards
expect(response).to render_template :index
expect(response.content_type).to eq 'text/html'
end
context 'with unauthorized user' do context 'with unauthorized user' do
before do before do
allow(Ability).to receive(:allowed?).with(user, :read_cross_project, :global).and_return(true) allow(Ability).to receive(:allowed?).with(user, :read_cross_project, :global).and_return(true)
...@@ -35,12 +56,30 @@ describe Groups::BoardsController do ...@@ -35,12 +56,30 @@ describe Groups::BoardsController do
expect(response.content_type).to eq 'text/html' expect(response.content_type).to eq 'text/html'
end end
end end
context 'when user is signed out' do
let(:group) { create(:group, :public) }
it 'renders template' do
sign_out(user)
board = create(:board, group: group)
create(:board_group_recent_visit, group: board.group, board: board, user: user)
list_boards
expect(response).to render_template :index
expect(response.content_type).to eq 'text/html'
end
end
end end
context 'when format is JSON' do context 'when format is JSON' do
it 'return an array with one group board' do it 'return an array with one group board' do
create(:board, group: group) create(:board, group: group)
expect(Boards::Visits::LatestService).not_to receive(:new)
list_boards format: :json list_boards format: :json
parsed_response = JSON.parse(response.body) parsed_response = JSON.parse(response.body)
...@@ -74,7 +113,7 @@ describe Groups::BoardsController do ...@@ -74,7 +113,7 @@ describe Groups::BoardsController do
context 'when format is HTML' do context 'when format is HTML' do
it 'renders template' do it 'renders template' do
read_board board: board expect { read_board board: board }.to change(BoardGroupRecentVisit, :count).by(1)
expect(response).to render_template :show expect(response).to render_template :show
expect(response.content_type).to eq 'text/html' expect(response.content_type).to eq 'text/html'
...@@ -93,10 +132,25 @@ describe Groups::BoardsController do ...@@ -93,10 +132,25 @@ describe Groups::BoardsController do
expect(response.content_type).to eq 'text/html' expect(response.content_type).to eq 'text/html'
end end
end end
context 'when user is signed out' do
let(:group) { create(:group, :public) }
it 'does not save visit' do
sign_out(user)
expect { read_board board: board }.to change(BoardGroupRecentVisit, :count).by(0)
expect(response).to render_template :show
expect(response.content_type).to eq 'text/html'
end
end
end end
context 'when format is JSON' do context 'when format is JSON' do
it 'returns project board' do it 'returns project board' do
expect(Boards::Visits::CreateService).not_to receive(:new)
read_board board: board, format: :json read_board board: board, format: :json
expect(response).to match_response_schema('board') expect(response).to match_response_schema('board')
......
...@@ -28,6 +28,27 @@ describe Projects::BoardsController do ...@@ -28,6 +28,27 @@ describe Projects::BoardsController do
expect(response.content_type).to eq 'text/html' expect(response.content_type).to eq 'text/html'
end end
it 'redirects to latest visited board' do
board = create(:board, project: project)
create(:board_project_recent_visit, project: board.project, board: board, user: user)
list_boards
expect(response).to redirect_to(namespace_project_board_path(id: board.id))
end
it 'renders template if visited board is not found' do
visited = double
allow(visited).to receive(:board_id).and_return(12)
allow_any_instance_of(Boards::Visits::LatestService).to receive(:execute).and_return(visited)
list_boards
expect(response).to render_template :index
expect(response.content_type).to eq 'text/html'
end
context 'with unauthorized user' do context 'with unauthorized user' do
before do before do
allow(Ability).to receive(:allowed?).with(user, :read_project, project).and_return(true) allow(Ability).to receive(:allowed?).with(user, :read_project, project).and_return(true)
...@@ -41,12 +62,30 @@ describe Projects::BoardsController do ...@@ -41,12 +62,30 @@ describe Projects::BoardsController do
expect(response.content_type).to eq 'text/html' expect(response.content_type).to eq 'text/html'
end end
end end
context 'when user is signed out' do
let(:project) { create(:project, :public) }
it 'renders template' do
sign_out(user)
board = create(:board, project: project)
create(:board_project_recent_visit, project: board.project, board: board, user: user)
list_boards
expect(response).to render_template :index
expect(response.content_type).to eq 'text/html'
end
end
end end
context 'when format is JSON' do context 'when format is JSON' do
it 'returns a list of project boards' do it 'returns a list of project boards' do
create_list(:board, 2, project: project) create_list(:board, 2, project: project)
expect(Boards::Visits::LatestService).not_to receive(:new)
list_boards format: :json list_boards format: :json
parsed_response = JSON.parse(response.body) parsed_response = JSON.parse(response.body)
...@@ -98,7 +137,7 @@ describe Projects::BoardsController do ...@@ -98,7 +137,7 @@ describe Projects::BoardsController do
context 'when format is HTML' do context 'when format is HTML' do
it 'renders template' do it 'renders template' do
read_board board: board expect { read_board board: board }.to change(BoardProjectRecentVisit, :count).by(1)
expect(response).to render_template :show expect(response).to render_template :show
expect(response.content_type).to eq 'text/html' expect(response.content_type).to eq 'text/html'
...@@ -117,10 +156,25 @@ describe Projects::BoardsController do ...@@ -117,10 +156,25 @@ describe Projects::BoardsController do
expect(response.content_type).to eq 'text/html' expect(response.content_type).to eq 'text/html'
end end
end end
context 'when user is signed out' do
let(:project) { create(:project, :public) }
it 'does not save visit' do
sign_out(user)
expect { read_board board: board }.to change(BoardProjectRecentVisit, :count).by(0)
expect(response).to render_template :show
expect(response.content_type).to eq 'text/html'
end
end
end end
context 'when format is JSON' do context 'when format is JSON' do
it 'returns project board' do it 'returns project board' do
expect(Boards::Visits::CreateService).not_to receive(:new)
read_board board: board, format: :json read_board board: board, format: :json
expect(response).to match_response_schema('board') expect(response).to match_response_schema('board')
......
# frozen_string_literal: true
FactoryBot.define do
factory :board_group_recent_visit do
user
group
board
end
end
# frozen_string_literal: true
FactoryBot.define do
factory :board_project_recent_visit do
user
project
board
end
end
# frozen_string_literal: true
require 'spec_helper'
describe BoardGroupRecentVisit do
let(:user) { create(:user) }
let(:group) { create(:group) }
let(:board) { create(:board, group: group) }
describe 'relationships' do
it { is_expected.to belong_to(:user) }
it { is_expected.to belong_to(:group) }
it { is_expected.to belong_to(:board) }
end
describe 'validations' do
it { is_expected.to validate_presence_of(:user) }
it { is_expected.to validate_presence_of(:group) }
it { is_expected.to validate_presence_of(:board) }
end
describe '#visited' do
it 'creates a visit if one does not exists' do
expect { described_class.visited!(user, board) }.to change(described_class, :count).by(1)
end
shared_examples 'was visited previously' do
let!(:visit) { create :board_group_recent_visit, group: board.group, board: board, user: user, updated_at: 7.days.ago }
it 'updates the timestamp' do
Timecop.freeze do
described_class.visited!(user, board)
expect(described_class.count).to eq 1
expect(described_class.first.updated_at).to be_like_time(Time.zone.now)
end
end
end
it_behaves_like 'was visited previously'
context 'when we try to create a visit that is not unique' do
before do
expect(described_class).to receive(:find_or_create_by).and_raise(ActiveRecord::RecordNotUnique, 'record not unique')
expect(described_class).to receive(:find_or_create_by).and_return(visit)
end
it_behaves_like 'was visited previously'
end
end
describe '#latest' do
it 'returns the most recent visited' do
board2 = create(:board, group: group)
board3 = create(:board, group: group)
create :board_group_recent_visit, group: board.group, board: board, user: user, updated_at: 7.days.ago
create :board_group_recent_visit, group: board2.group, board: board2, user: user, updated_at: 5.days.ago
recent = create :board_group_recent_visit, group: board3.group, board: board3, user: user, updated_at: 1.day.ago
expect(described_class.latest(user, group)).to eq recent
end
end
end
# frozen_string_literal: true
require 'spec_helper'
describe BoardProjectRecentVisit do
let(:user) { create(:user) }
let(:project) { create(:project) }
let(:board) { create(:board, project: project) }
describe 'relationships' do
it { is_expected.to belong_to(:user) }
it { is_expected.to belong_to(:project) }
it { is_expected.to belong_to(:board) }
end
describe 'validations' do
it { is_expected.to validate_presence_of(:user) }
it { is_expected.to validate_presence_of(:project) }
it { is_expected.to validate_presence_of(:board) }
end
describe '#visited' do
it 'creates a visit if one does not exists' do
expect { described_class.visited!(user, board) }.to change(described_class, :count).by(1)
end
shared_examples 'was visited previously' do
let!(:visit) { create :board_project_recent_visit, project: board.project, board: board, user: user, updated_at: 7.days.ago }
it 'updates the timestamp' do
Timecop.freeze do
described_class.visited!(user, board)
expect(described_class.count).to eq 1
expect(described_class.first.updated_at).to be_like_time(Time.zone.now)
end
end
end
it_behaves_like 'was visited previously'
context 'when we try to create a visit that is not unique' do
before do
expect(described_class).to receive(:find_or_create_by).and_raise(ActiveRecord::RecordNotUnique, 'record not unique')
expect(described_class).to receive(:find_or_create_by).and_return(visit)
end
it_behaves_like 'was visited previously'
end
end
describe '#latest' do
it 'returns the most recent visited' do
board2 = create(:board, project: project)
board3 = create(:board, project: project)
create :board_project_recent_visit, project: board.project, board: board, user: user, updated_at: 7.days.ago
create :board_project_recent_visit, project: board2.project, board: board2, user: user, updated_at: 5.days.ago
recent = create :board_project_recent_visit, project: board3.project, board: board3, user: user, updated_at: 1.day.ago
expect(described_class.latest(user, project)).to eq recent
end
end
end
# frozen_string_literal: true
require 'spec_helper'
describe Boards::Visits::CreateService do
describe '#execute' do
let(:user) { create(:user) }
context 'when a project board' do
let(:project) { create(:project) }
let(:project_board) { create(:board, project: project) }
subject(:service) { described_class.new(project_board.parent, user) }
it 'returns nil when there is no user' do
service.current_user = nil
expect(service.execute(project_board)).to eq nil
end
it 'returns nil when database is read only' do
allow(Gitlab::Database).to receive(:read_only?) { true }
expect(service.execute(project_board)).to eq nil
end
it 'records the visit' do
expect(BoardProjectRecentVisit).to receive(:visited!).once
service.execute(project_board)
end
end
context 'when a group board' do
let(:group) { create(:group) }
let(:group_board) { create(:board, group: group) }
subject(:service) { described_class.new(group_board.parent, user) }
it 'returns nil when there is no user' do
service.current_user = nil
expect(service.execute(group_board)).to eq nil
end
it 'records the visit' do
expect(BoardGroupRecentVisit).to receive(:visited!).once
service.execute(group_board)
end
end
end
end
# frozen_string_literal: true
require 'spec_helper'
describe Boards::Visits::LatestService do
describe '#execute' do
let(:user) { create(:user) }
context 'when a project board' do
let(:project) { create(:project) }
let(:project_board) { create(:board, project: project) }
subject(:service) { described_class.new(project_board.parent, user) }
it 'returns nil when there is no user' do
service.current_user = nil
expect(service.execute).to eq nil
end
it 'queries for most recent visit' do
expect(BoardProjectRecentVisit).to receive(:latest).once
service.execute
end
end
context 'when a group board' do
let(:group) { create(:group) }
let(:group_board) { create(:board, group: group) }
subject(:service) { described_class.new(group_board.parent, user) }
it 'returns nil when there is no user' do
service.current_user = nil
expect(service.execute).to eq nil
end
it 'queries for most recent visit' do
expect(BoardGroupRecentVisit).to receive(:latest).once
service.execute
end
end
end
end
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment