Save board lists collapsed setting

Persists if a board list is collapsed for each user.
This commit is contained in:
Felipe Artur 2019-08-28 20:18:40 +00:00 committed by Michael Kozono
parent 148c5ccbb4
commit 8f6a433c41
14 changed files with 406 additions and 15 deletions

View file

@ -4,7 +4,7 @@ module Boards
class ListsController < Boards::ApplicationController class ListsController < Boards::ApplicationController
include BoardsResponses include BoardsResponses
before_action :authorize_admin_list, only: [:create, :update, :destroy, :generate] before_action :authorize_admin_list, only: [:create, :destroy, :generate]
before_action :authorize_read_list, only: [:index] before_action :authorize_read_list, only: [:index]
skip_before_action :authenticate_user!, only: [:index] skip_before_action :authenticate_user!, only: [:index]
@ -15,7 +15,7 @@ module Boards
end end
def create def create
list = Boards::Lists::CreateService.new(board.parent, current_user, list_params).execute(board) list = Boards::Lists::CreateService.new(board.parent, current_user, create_list_params).execute(board)
if list.valid? if list.valid?
render json: serialize_as_json(list) render json: serialize_as_json(list)
@ -26,12 +26,13 @@ module Boards
def update def update
list = board.lists.movable.find(params[:id]) list = board.lists.movable.find(params[:id])
service = Boards::Lists::MoveService.new(board_parent, current_user, move_params) service = Boards::Lists::UpdateService.new(board_parent, current_user, update_list_params)
result = service.execute(list)
if service.execute(list) if result[:status] == :success
head :ok head :ok
else else
head :unprocessable_entity head result[:http_status]
end end
end end
@ -50,7 +51,8 @@ module Boards
service = Boards::Lists::GenerateService.new(board_parent, current_user) service = Boards::Lists::GenerateService.new(board_parent, current_user)
if service.execute(board) if service.execute(board)
render json: serialize_as_json(board.lists.movable) lists = board.lists.movable.preload_associations(current_user)
render json: serialize_as_json(lists)
else else
head :unprocessable_entity head :unprocessable_entity
end end
@ -62,12 +64,12 @@ module Boards
%i[label_id] %i[label_id]
end end
def list_params def create_list_params
params.require(:list).permit(list_creation_attrs) params.require(:list).permit(list_creation_attrs)
end end
def move_params def update_list_params
params.require(:list).permit(:position) params.require(:list).permit(:collapsed, :position)
end end
def serialize_as_json(resource) def serialize_as_json(resource)
@ -78,7 +80,9 @@ module Boards
{ {
only: [:id, :list_type, :position], only: [:id, :list_type, :position],
methods: [:title], methods: [:title],
label: true label: true,
collapsed: true,
current_user: current_user
} }
end end
end end

View file

@ -1,9 +1,11 @@
# frozen_string_literal: true # frozen_string_literal: true
class List < ApplicationRecord class List < ApplicationRecord
include Importable
belongs_to :board belongs_to :board
belongs_to :label belongs_to :label
include Importable has_many :list_user_preferences
enum list_type: { backlog: 0, label: 1, closed: 2, assignee: 3, milestone: 4 } enum list_type: { backlog: 0, label: 1, closed: 2, assignee: 3, milestone: 4 }
@ -16,9 +18,24 @@ class List < ApplicationRecord
scope :destroyable, -> { where(list_type: list_types.slice(*destroyable_types).values) } scope :destroyable, -> { where(list_type: list_types.slice(*destroyable_types).values) }
scope :movable, -> { where(list_type: list_types.slice(*movable_types).values) } scope :movable, -> { where(list_type: list_types.slice(*movable_types).values) }
scope :preload_associations, -> { preload(:board, :label) }
scope :preload_associations, -> (user) do
preload(:board, label: :priorities)
.with_preferences_for(user)
end
scope :ordered, -> { order(:list_type, :position) } scope :ordered, -> { order(:list_type, :position) }
# Loads list with preferences for given user
# if preferences exists for user or not
scope :with_preferences_for, -> (user) do
return unless user
includes(:list_user_preferences).where(list_user_preferences: { user_id: [user.id, nil] })
end
alias_method :preferences, :list_user_preferences
class << self class << self
def destroyable_types def destroyable_types
[:label] [:label]
@ -29,6 +46,31 @@ class List < ApplicationRecord
end end
end end
def preferences_for(user)
return preferences.build unless user
if preferences.loaded?
preloaded_preferences_for(user)
else
preferences.find_or_initialize_by(user: user)
end
end
def preloaded_preferences_for(user)
user_preferences =
preferences.find do |preference|
preference.user_id == user.id
end
user_preferences || preferences.build(user: user)
end
def update_preferences_for(user, preferences = {})
return unless user
preferences_for(user).update(preferences)
end
def destroyable? def destroyable?
self.class.destroyable_types.include?(list_type&.to_sym) self.class.destroyable_types.include?(list_type&.to_sym)
end end
@ -43,6 +85,14 @@ class List < ApplicationRecord
def as_json(options = {}) def as_json(options = {})
super(options).tap do |json| super(options).tap do |json|
json[:collapsed] = false
if options.key?(:collapsed)
preferences = preferences_for(options[:current_user])
json[:collapsed] = preferences.collapsed?
end
if options.key?(:label) if options.key?(:label)
json[:label] = label.as_json( json[:label] = label.as_json(
project: board.project, project: board.project,

View file

@ -0,0 +1,10 @@
# frozen_string_literal: true
class ListUserPreference < ApplicationRecord
belongs_to :user
belongs_to :list
validates :user, presence: true
validates :list, presence: true
validates :user_id, uniqueness: { scope: :list_id, message: "should have only one list preference per user" }
end

View file

@ -6,7 +6,7 @@ module Boards
def execute(board) def execute(board)
board.lists.create(list_type: :backlog) unless board.lists.backlog.exists? board.lists.create(list_type: :backlog) unless board.lists.backlog.exists?
board.lists.preload_associations board.lists.preload_associations(current_user)
end end
end end
end end

View file

@ -0,0 +1,56 @@
# frozen_string_literal: true
module Boards
module Lists
class UpdateService < Boards::BaseService
def execute(list)
return not_authorized if preferences? && !can_read?(list)
return not_authorized if position? && !can_admin?(list)
if update_preferences(list) || update_position(list)
success(list: list)
else
error(list.errors.messages, 422)
end
end
def update_preferences(list)
return unless preferences?
list.update_preferences_for(current_user, preferences)
end
def update_position(list)
return unless position?
move_service = Boards::Lists::MoveService.new(parent, current_user, params)
move_service.execute(list)
end
def preferences
{ collapsed: Gitlab::Utils.to_boolean(params[:collapsed]) }
end
def not_authorized
error("Not authorized", 403)
end
def preferences?
params.has_key?(:collapsed)
end
def position?
params.has_key?(:position)
end
def can_read?(list)
Ability.allowed?(current_user, :read_list, parent)
end
def can_admin?(list)
Ability.allowed?(current_user, :admin_list, parent)
end
end
end
end

View file

@ -0,0 +1,5 @@
---
title: Save collapsed option for board lists in database
merge_request: 31069
author:
type: added

View file

@ -0,0 +1,16 @@
# frozen_string_literal: true
class CreateListUserPreferences < ActiveRecord::Migration[5.2]
DOWNTIME = false
def change
create_table :list_user_preferences do |t|
t.references :user, index: true, null: false, foreign_key: { on_delete: :cascade }
t.references :list, index: true, null: false, foreign_key: { on_delete: :cascade }
t.timestamps_with_timezone null: false
t.boolean :collapsed
end
add_index :list_user_preferences, [:user_id, :list_id], unique: true
end
end

View file

@ -1922,6 +1922,17 @@ ActiveRecord::Schema.define(version: 2019_08_28_083843) do
t.datetime "updated_at" t.datetime "updated_at"
end end
create_table "list_user_preferences", force: :cascade do |t|
t.bigint "user_id", null: false
t.bigint "list_id", null: false
t.datetime_with_timezone "created_at", null: false
t.datetime_with_timezone "updated_at", null: false
t.boolean "collapsed"
t.index ["list_id"], name: "index_list_user_preferences_on_list_id"
t.index ["user_id", "list_id"], name: "index_list_user_preferences_on_user_id_and_list_id", unique: true
t.index ["user_id"], name: "index_list_user_preferences_on_user_id"
end
create_table "lists", id: :serial, force: :cascade do |t| create_table "lists", id: :serial, force: :cascade do |t|
t.integer "board_id", null: false t.integer "board_id", null: false
t.integer "label_id" t.integer "label_id"
@ -3880,6 +3891,8 @@ ActiveRecord::Schema.define(version: 2019_08_28_083843) do
add_foreign_key "labels", "projects", name: "fk_7de4989a69", on_delete: :cascade add_foreign_key "labels", "projects", name: "fk_7de4989a69", on_delete: :cascade
add_foreign_key "lfs_file_locks", "projects", on_delete: :cascade add_foreign_key "lfs_file_locks", "projects", on_delete: :cascade
add_foreign_key "lfs_file_locks", "users", on_delete: :cascade add_foreign_key "lfs_file_locks", "users", on_delete: :cascade
add_foreign_key "list_user_preferences", "lists", on_delete: :cascade
add_foreign_key "list_user_preferences", "users", on_delete: :cascade
add_foreign_key "lists", "boards", name: "fk_0d3f677137", on_delete: :cascade add_foreign_key "lists", "boards", name: "fk_0d3f677137", on_delete: :cascade
add_foreign_key "lists", "labels", name: "fk_7a5553d60f", on_delete: :cascade add_foreign_key "lists", "labels", name: "fk_7a5553d60f", on_delete: :cascade
add_foreign_key "lists", "milestones", on_delete: :cascade add_foreign_key "lists", "milestones", on_delete: :cascade

View file

@ -30,6 +30,21 @@ describe Boards::ListsController do
expect(json_response.length).to eq 3 expect(json_response.length).to eq 3
end end
it 'avoids n+1 queries when serializing lists' do
list_1 = create(:list, board: board)
list_1.update_preferences_for(user, { collapsed: true })
control_count = ActiveRecord::QueryRecorder.new { read_board_list user: user, board: board }.count
list_2 = create(:list, board: board)
list_2.update_preferences_for(user, { collapsed: true })
list_3 = create(:list, board: board)
list_3.update_preferences_for(user, { collapsed: true })
expect { read_board_list user: user, board: board }.not_to exceed_query_limit(control_count)
end
context 'with unauthorized user' do context 'with unauthorized user' do
let(:unauth_user) { create(:user) } let(:unauth_user) { create(:user) }
@ -154,6 +169,22 @@ describe Boards::ListsController do
end end
end end
context 'with collapsed preference' do
it 'saves collapsed preference for user' do
save_setting user: user, board: board, list: planning, setting: { collapsed: true }
expect(planning.preferences_for(user).collapsed).to eq(true)
expect(response).to have_gitlab_http_status(200)
end
it 'saves not collapsed preference for user' do
save_setting user: user, board: board, list: planning, setting: { collapsed: false }
expect(planning.preferences_for(user).collapsed).to eq(false)
expect(response).to have_gitlab_http_status(200)
end
end
def move(user:, board:, list:, position:) def move(user:, board:, list:, position:)
sign_in(user) sign_in(user)
@ -166,6 +197,19 @@ describe Boards::ListsController do
patch :update, params: params, as: :json patch :update, params: params, as: :json
end end
def save_setting(user:, board:, list:, setting: {})
sign_in(user)
params = { namespace_id: project.namespace.to_param,
project_id: project,
board_id: board.to_param,
id: list.to_param,
list: setting,
format: :json }
patch :update, params: params, as: :json
end
end end
describe 'DELETE destroy' do describe 'DELETE destroy' do

View file

@ -483,3 +483,4 @@ lists:
- milestone - milestone
- board - board
- label - label
- list_user_preferences

View file

@ -81,4 +81,83 @@ describe List do
expect(subject.title).to eq 'Closed' expect(subject.title).to eq 'Closed'
end end
end end
describe '#update_preferences_for' do
let(:user) { create(:user) }
let(:list) { create(:list) }
context 'when user is present' do
context 'when there are no preferences for user' do
it 'creates new user preferences' do
expect { list.update_preferences_for(user, collapsed: true) }.to change { ListUserPreference.count }.by(1)
expect(list.preferences_for(user).collapsed).to eq(true)
end
end
context 'when there are preferences for user' do
it 'updates user preferences' do
list.update_preferences_for(user, collapsed: false)
expect { list.update_preferences_for(user, collapsed: true) }.not_to change { ListUserPreference.count }
expect(list.preferences_for(user).collapsed).to eq(true)
end
end
context 'when user is nil' do
it 'does not create user preferences' do
expect { list.update_preferences_for(nil, collapsed: true) }.not_to change { ListUserPreference.count }
end
end
end
end
describe '#preferences_for' do
let(:user) { create(:user) }
let(:list) { create(:list) }
context 'when user is nil' do
it 'returns not persisted preferences' do
preferences = list.preferences_for(nil)
expect(preferences.persisted?).to eq(false)
expect(preferences.list_id).to eq(list.id)
expect(preferences.user_id).to be_nil
end
end
context 'when a user preference already exists' do
before do
list.update_preferences_for(user, collapsed: true)
end
it 'loads preference for user' do
preferences = list.preferences_for(user)
expect(preferences).to be_persisted
expect(preferences.collapsed).to eq(true)
end
context 'when preferences are already loaded for user' do
it 'gets preloaded user preferences' do
fetched_list = described_class.where(id: list.id).with_preferences_for(user).first
expect(fetched_list).to receive(:preloaded_preferences_for).with(user).and_call_original
preferences = fetched_list.preferences_for(user)
expect(preferences.collapsed).to eq(true)
end
end
end
context 'when preferences for user does not exist' do
it 'returns not persisted preferences' do
preferences = list.preferences_for(user)
expect(preferences.persisted?).to eq(false)
expect(preferences.user_id).to eq(user.id)
expect(preferences.list_id).to eq(list.id)
end
end
end
end end

View file

@ -0,0 +1,22 @@
# frozen_string_literal: true
require 'spec_helper'
describe ListUserPreference do
set(:user) { create(:user) }
set(:list) { create(:list) }
before do
list.update_preferences_for(user, { collapsed: true })
end
describe 'relationships' do
it { is_expected.to belong_to(:list) }
it { is_expected.to belong_to(:user) }
it do
is_expected.to validate_uniqueness_of(:user_id).scoped_to(:list_id)
.with_message("should have only one list preference per user")
end
end
end

View file

@ -3,13 +3,15 @@
require 'spec_helper' require 'spec_helper'
describe Boards::Lists::ListService do describe Boards::Lists::ListService do
let(:user) { create(:user) }
describe '#execute' do describe '#execute' do
context 'when board parent is a project' do context 'when board parent is a project' do
let(:project) { create(:project) } let(:project) { create(:project) }
let(:board) { create(:board, project: project) } let(:board) { create(:board, project: project) }
let(:label) { create(:label, project: project) } let(:label) { create(:label, project: project) }
let!(:list) { create(:list, board: board, label: label) } let!(:list) { create(:list, board: board, label: label) }
let(:service) { described_class.new(project, double) } let(:service) { described_class.new(project, user) }
it_behaves_like 'lists list service' it_behaves_like 'lists list service'
end end
@ -19,7 +21,7 @@ describe Boards::Lists::ListService do
let(:board) { create(:board, group: group) } let(:board) { create(:board, group: group) }
let(:label) { create(:group_label, group: group) } let(:label) { create(:group_label, group: group) }
let!(:list) { create(:list, board: board, label: label) } let!(:list) { create(:list, board: board, label: label) }
let(:service) { described_class.new(group, double) } let(:service) { described_class.new(group, user) }
it_behaves_like 'lists list service' it_behaves_like 'lists list service'
end end

View file

@ -0,0 +1,89 @@
# frozen_string_literal: true
require 'spec_helper'
describe Boards::Lists::UpdateService do
let(:user) { create(:user) }
let!(:list) { create(:list, board: board, position: 0) }
shared_examples 'moving list' do
context 'when user can admin list' do
it 'calls Lists::MoveService to update list position' do
board.parent.add_developer(user)
service = described_class.new(board.parent, user, position: 1)
expect(Boards::Lists::MoveService).to receive(:new).with(board.parent, user, { position: 1 }).and_call_original
expect_any_instance_of(Boards::Lists::MoveService).to receive(:execute).with(list)
service.execute(list)
end
end
context 'when user cannot admin list' do
it 'does not call Lists::MoveService to update list position' do
service = described_class.new(board.parent, user, position: 1)
expect(Boards::Lists::MoveService).not_to receive(:new)
service.execute(list)
end
end
end
shared_examples 'updating list preferences' do
context 'when user can read list' do
it 'updates list preference for user' do
board.parent.add_guest(user)
service = described_class.new(board.parent, user, collapsed: true)
service.execute(list)
expect(list.preferences_for(user).collapsed).to eq(true)
end
end
context 'when user cannot read list' do
it 'does not update list preference for user' do
service = described_class.new(board.parent, user, collapsed: true)
service.execute(list)
expect(list.preferences_for(user).collapsed).to be_nil
end
end
end
describe '#execute' do
context 'when position parameter is present' do
context 'for projects' do
it_behaves_like 'moving list' do
let(:project) { create(:project, :private) }
let(:board) { create(:board, project: project) }
end
end
context 'for groups' do
it_behaves_like 'moving list' do
let(:group) { create(:group, :private) }
let(:board) { create(:board, group: group) }
end
end
end
context 'when collapsed parameter is present' do
context 'for projects' do
it_behaves_like 'updating list preferences' do
let(:project) { create(:project, :private) }
let(:board) { create(:board, project: project) }
end
end
context 'for groups' do
it_behaves_like 'updating list preferences' do
let(:group) { create(:group, :private) }
let(:board) { create(:board, group: group) }
end
end
end
end
end