diff --git a/app/assets/javascripts/right_sidebar.js b/app/assets/javascripts/right_sidebar.js index 225e21ad322..93a37e17126 100644 --- a/app/assets/javascripts/right_sidebar.js +++ b/app/assets/javascripts/right_sidebar.js @@ -79,11 +79,12 @@ Sidebar.prototype.sidebarToggleClicked = function(e, triggered) { Sidebar.prototype.toggleTodo = function(e) { var $btnText, $this, $todoLoading, ajaxType, url; $this = $(e.currentTarget); - ajaxType = $this.attr('data-delete-path') ? 'delete' : 'post'; - if ($this.attr('data-delete-path')) { - url = '' + $this.attr('data-delete-path'); + ajaxType = $this.data('deletePath') ? 'delete' : 'post'; + + if ($this.data('deletePath')) { + url = '' + $this.data('deletePath'); } else { - url = '' + $this.data('url'); + url = '' + $this.data('createPath'); } $this.tooltip('hide'); diff --git a/app/controllers/concerns/issuable_actions.rb b/app/controllers/concerns/issuable_actions.rb index ad9cc0925b7..3d64ae8b775 100644 --- a/app/controllers/concerns/issuable_actions.rb +++ b/app/controllers/concerns/issuable_actions.rb @@ -5,7 +5,6 @@ module IssuableActions include Gitlab::Utils::StrongMemoize included do - before_action :labels, only: [:show, :new, :edit] before_action :authorize_destroy_issuable!, only: :destroy before_action :authorize_admin_issuable!, only: :bulk_update end @@ -25,7 +24,10 @@ module IssuableActions def show respond_to do |format| - format.html + format.html do + @issuable_sidebar = serializer.represent(issuable, serializer: 'sidebar') # rubocop:disable Gitlab/ModuleWithInstanceVariables + end + format.json do render json: serializer.represent(issuable, serializer: params[:serializer]) end @@ -168,10 +170,6 @@ module IssuableActions end end - def labels - @labels ||= LabelsFinder.new(current_user, project_id: @project.id).execute # rubocop:disable Gitlab/ModuleWithInstanceVariables - end - def authorize_destroy_issuable! unless can?(current_user, :"destroy_#{issuable.to_ability_name}", issuable) return access_denied! diff --git a/app/controllers/projects/merge_requests/conflicts_controller.rb b/app/controllers/projects/merge_requests/conflicts_controller.rb index ac1969adc6e..26219012121 100644 --- a/app/controllers/projects/merge_requests/conflicts_controller.rb +++ b/app/controllers/projects/merge_requests/conflicts_controller.rb @@ -8,7 +8,7 @@ class Projects::MergeRequests::ConflictsController < Projects::MergeRequests::Ap def show respond_to do |format| format.html do - labels + @issuable_sidebar = serializer.represent(@merge_request, serializer: 'sidebar') end format.json do @@ -60,9 +60,15 @@ class Projects::MergeRequests::ConflictsController < Projects::MergeRequests::Ap end end + private + def authorize_can_resolve_conflicts! @conflicts_list = ::MergeRequests::Conflicts::ListService.new(@merge_request) return render_404 unless @conflicts_list.can_be_resolved_by?(current_user) end + + def serializer + MergeRequestSerializer.new(current_user: current_user, project: merge_request.project) + end end diff --git a/app/controllers/projects/merge_requests_controller.rb b/app/controllers/projects/merge_requests_controller.rb index da9316d5f22..8b8eac7ff8e 100644 --- a/app/controllers/projects/merge_requests_controller.rb +++ b/app/controllers/projects/merge_requests_controller.rb @@ -22,8 +22,7 @@ class Projects::MergeRequestsController < Projects::MergeRequests::ApplicationCo format.html format.json do render json: { - html: view_to_html_string("projects/merge_requests/_merge_requests"), - labels: @labels.as_json(methods: :text_color) + html: view_to_html_string("projects/merge_requests/_merge_requests") } end end @@ -43,8 +42,7 @@ class Projects::MergeRequestsController < Projects::MergeRequests::ApplicationCo @noteable = @merge_request @commits_count = @merge_request.commits_count - - labels + @issuable_sidebar = serializer.represent(@merge_request, serializer: 'sidebar') set_pipeline_variables diff --git a/app/helpers/issuables_helper.rb b/app/helpers/issuables_helper.rb index ea861bd2fc4..5e277a4c5ed 100644 --- a/app/helpers/issuables_helper.rb +++ b/app/helpers/issuables_helper.rb @@ -23,23 +23,36 @@ module IssuablesHelper end end - def sidebar_due_date_tooltip_label(issuable) - if issuable.due_date - "#{_('Due date')}
#{due_date_remaining_days(issuable)}" + def sidebar_milestone_tooltip_label(milestone) + if milestone && milestone[:due_date] + "#{milestone[:title]}
#{sidebar_milestone_remaining_days(milestone)}" else - _('Due date') + _('Milestone') + (milestone ? "
#{milestone[:title]}" : "") end end - def due_date_remaining_days(issuable) - remaining_days_in_words = remaining_days_in_words(issuable) + def sidebar_milestone_remaining_days(milestone) + due_date_remaining_days(due_date: milestone[:due_date], start_date: milestone[:start_date]) if milestone[:due_date] + end - "#{issuable.due_date.to_s(:medium)} (#{remaining_days_in_words})" + def sidebar_due_date_tooltip_label(due_date) + _('Due date') + (due_date ? "
#{due_date_remaining_days(due_date)}" : "") + end + + def due_date_remaining_days(due_date, start_date = nil) + "#{due_date.to_s(:medium)} (#{remaining_days_in_words(due_date: due_date, start_date: start_date)})" + end + + def sidebar_label_filter_path(base_path, label_name) + query_params = {label_name: [label_name]}.to_query + + "#{base_path}?#{query_params}" end def multi_label_name(current_labels, default_label) if current_labels && current_labels.any? - title = current_labels.first.try(:title) + title = current_labels.first.try(:title) || current_labels.first[:title] + if current_labels.size > 1 "#{title} +#{current_labels.size - 1} more" else @@ -50,13 +63,13 @@ module IssuablesHelper end end - def issuable_json_path(issuable, url_params = {}) + def issuable_json_path(issuable) project = issuable.project if issuable.is_a?(MergeRequest) - project_merge_request_path(project, issuable.iid, :json, url_params) + project_merge_request_path(project, issuable.iid, :json) else - project_issue_path(project, issuable.iid, :json, url_params) + project_issue_path(project, issuable.iid, :json) end end @@ -197,19 +210,11 @@ module IssuablesHelper output.join.html_safe end - # rubocop: disable CodeReuse/ActiveRecord - def issuable_todo(issuable) - if current_user - current_user.todos.find_by(target: issuable, state: :pending) - end - end - # rubocop: enable CodeReuse/ActiveRecord - def issuable_labels_tooltip(labels, limit: 5) first, last = labels.partition.with_index { |_, i| i < limit } if labels && labels.any? - label_names = first.collect(&:name) + label_names = first.collect { |l| l[:title] } label_names << "and #{last.size} more" unless last.empty? label_names.join(', ') @@ -356,12 +361,6 @@ module IssuablesHelper issuable.model_name.human.downcase end - def selected_labels - Array(params[:label_name]).map do |label_name| - Label.new(title: label_name) - end - end - def has_filter_bar_param? finder.class.scalar_params.any? { |p| params[p].present? } end @@ -386,22 +385,6 @@ module IssuablesHelper params[:issuable_template] if issuable_templates(issuable).any? { |template| template[:name] == params[:issuable_template] } end - def issuable_todo_button_data(issuable, todo, is_collapsed) - { - todo_text: "Add todo", - mark_text: "Mark todo as done", - todo_icon: (is_collapsed ? sprite_icon('todo-add') : nil), - mark_icon: (is_collapsed ? sprite_icon('todo-done', css_class: 'todo-undone') : nil), - issuable_id: issuable.id, - issuable_type: issuable.class.name.underscore, - url: project_todos_path(@project), - delete_path: (dashboard_todo_path(todo) if todo), - placement: (is_collapsed ? 'left' : nil), - container: (is_collapsed ? 'body' : nil), - boundary: 'viewport' - } - end - def close_reopen_params(issuable, action) { issuable.model_name.to_s.underscore => { state_event: action } @@ -418,27 +401,20 @@ module IssuablesHelper end end - def issuable_sidebar_options(issuable, can_edit_issuable) + def issuable_sidebar_options(sidebar_data) { - endpoint: issuable_json_path(issuable, serializer: 'sidebar_extras'), - toggleSubscriptionEndpoint: toggle_subscription_path(issuable), - moveIssueEndpoint: move_namespace_project_issue_path(namespace_id: issuable.project.namespace.to_param, project_id: issuable.project, id: issuable), - projectsAutocompleteEndpoint: autocomplete_projects_path(project_id: @project.id), - editable: can_edit_issuable, - currentUser: UserSerializer.new.represent(current_user), + endpoint: "#{sidebar_data[:issuable_json_path]}?serializer=sidebar_extras", + toggleSubscriptionEndpoint: sidebar_data[:toggle_subscription_path], + moveIssueEndpoint: sidebar_data[:move_issue_path], + projectsAutocompleteEndpoint: sidebar_data[:projects_autocomplete_path], + editable: sidebar_data[:can_edit], + currentUser: sidebar_data[:current_user], rootPath: root_path, - fullPath: @project.full_path + fullPath: sidebar_data[:project_full_path] } end def parent @project || @group end - - def issuable_milestone_tooltip_title(issuable) - if issuable.milestone - milestone_tooltip = milestone_tooltip_title(issuable.milestone) - _('Milestone') + (milestone_tooltip ? ': ' + milestone_tooltip : '') - end - end end diff --git a/app/helpers/milestones_helper.rb b/app/helpers/milestones_helper.rb index 9666080092b..e3dc598e98a 100644 --- a/app/helpers/milestones_helper.rb +++ b/app/helpers/milestones_helper.rb @@ -114,12 +114,6 @@ module MilestonesHelper end end - def milestone_tooltip_title(milestone) - if milestone - "#{milestone.title}
#{milestone_tooltip_due_date(milestone)}" - end - end - def milestone_time_for(date, date_type) title = date_type == :start ? "Start date" : "End date" diff --git a/app/models/user.rb b/app/models/user.rb index f20756d1cc3..6b60932a1b2 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -1422,6 +1422,10 @@ class User < ActiveRecord::Base todos.where(id: ids) end + def pending_todo_for(target) + todos.find_by(target: target, state: :pending) + end + # @deprecated alias_method :owned_or_masters_groups, :owned_or_maintainers_groups diff --git a/app/serializers/entity_date_helper.rb b/app/serializers/entity_date_helper.rb index cc0c2abf863..87bba87a0ed 100644 --- a/app/serializers/entity_date_helper.rb +++ b/app/serializers/entity_date_helper.rb @@ -45,13 +45,16 @@ module EntityDateHelper # If due date is provided, it returns "# days|weeks|months remaining|ago" # If start date is provided and elapsed, with no due date, it returns "# days elapsed" def remaining_days_in_words(entity) - if entity.try(:expired?) + start_date = entity.try(:start_date) || entity.try(:[], :start_date) + due_date = entity.try(:due_date) || entity.try(:[], :due_date) + + if due_date && due_date.past? content_tag(:strong, 'Past due') - elsif entity.try(:upcoming?) + elsif start_date && start_date.future? content_tag(:strong, 'Upcoming') - elsif entity.due_date - is_upcoming = (entity.due_date - Date.today).to_i > 0 - time_ago = time_ago_in_words(entity.due_date) + elsif due_date + is_upcoming = (due_date - Date.today).to_i > 0 + time_ago = time_ago_in_words(due_date) # https://gitlab.com/gitlab-org/gitlab-ce/issues/49440 # @@ -63,8 +66,8 @@ module EntityDateHelper remaining_or_ago = is_upcoming ? _("remaining") : _("ago") "#{content} #{remaining_or_ago}".html_safe - elsif entity.start_date && entity.start_date.past? - days = entity.elapsed_days + elsif start_date && start_date.past? + days = (Date.today - start_date).to_i "#{content_tag(:strong, days)} #{'day'.pluralize(days)} elapsed".html_safe end end diff --git a/app/serializers/issuable_sidebar_entity.rb b/app/serializers/issuable_sidebar_entity.rb index 9af2276b362..11666049959 100644 --- a/app/serializers/issuable_sidebar_entity.rb +++ b/app/serializers/issuable_sidebar_entity.rb @@ -3,6 +3,109 @@ class IssuableSidebarEntity < Grape::Entity include RequestAwareEntity + with_options if: { include_basic: true } do + expose :id + expose :type do |issuable| + issuable.to_ability_name + end + expose :author_id + expose :project_id do |issuable| + issuable.project.id + end + expose :discussion_locked?, as: :discussion_locked + expose :reference do |issuable| + issuable.to_reference(issuable.project, full: true) + end + + expose :current_user, using: UserEntity do |issuable| + request.current_user + end + + # Relationships + expose :todo, using: IssuableSidebarTodoEntity do |issuable| + request.current_user.pending_todo_for(issuable) if request.current_user + end + expose :milestone, using: ::API::Entities::Milestone + expose :labels, using: LabelEntity + + # Permissions + expose :signed_in do |issuable| + request.current_user.present? + end + + expose :can_edit do |issuable| + can?(request.current_user, :"admin_#{issuable.to_ability_name}", issuable.project) + end + + expose :can_move do |issuable| + request.current_user && issuable.can_move?(request.current_user) + end + + expose :can_admin_label do |issuable| + can?(request.current_user, :admin_label, issuable.project) + end + + # Paths + expose :issuable_json_path do |issuable| + if issuable.is_a?(MergeRequest) + project_merge_request_path(issuable.project, issuable.iid, :json) + else + project_issue_path(issuable.project, issuable.iid, :json) + end + end + + expose :namespace_path do |issuable| + issuable.project.namespace.full_path + end + + expose :project_path do |issuable| + issuable.project.path + end + + expose :project_full_path do |issuable| + issuable.project.full_path + end + + expose :project_issuables_path do |issuable| + project = issuable.project + namespace = project.namespace + + if issuable.is_a?(MergeRequest) + namespace_project_merge_requests_path(namespace, project) + else + namespace_project_issues_path(namespace, project) + end + end + + expose :create_todo_path do |issuable| + project_todos_path(issuable.project) + end + + expose :project_milestones_path do |issuable| + project_milestones_path(issuable.project, :json) + end + + expose :project_labels_path do |issuable| + project_labels_path(issuable.project, :json, include_ancestor_groups: true) + end + + expose :toggle_subscription_path do |issuable| + toggle_subscription_path(issuable) + end + + expose :move_issue_path do |issuable| + move_namespace_project_issue_path( + namespace_id: issuable.project.namespace.to_param, + project_id: issuable.project, + id: issuable + ) + end + + expose :projects_autocomplete_path do |issuable| + autocomplete_projects_path(project_id: issuable.project.id) + end + end + with_options if: { include_extras: true } do include TimeTrackableEntity diff --git a/app/serializers/issuable_sidebar_todo_entity.rb b/app/serializers/issuable_sidebar_todo_entity.rb new file mode 100644 index 00000000000..b2c98433f05 --- /dev/null +++ b/app/serializers/issuable_sidebar_todo_entity.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +class IssuableSidebarTodoEntity < Grape::Entity + include Gitlab::Routing + + expose :id + + expose :delete_path do |todo| + dashboard_todo_path(todo) if todo + end +end diff --git a/app/serializers/issue_serializer.rb b/app/serializers/issue_serializer.rb index cdb386b83b9..93625ff3afc 100644 --- a/app/serializers/issue_serializer.rb +++ b/app/serializers/issue_serializer.rb @@ -7,9 +7,9 @@ class IssueSerializer < BaseSerializer def represent(issue, opts = {}) entity = case opts[:serializer] - when 'sidebar_extras' - opts[:include_basic] = false - opts[:include_extras] = true + when 'sidebar', 'sidebar_extras' + opts[:include_basic] = (opts[:serializer] == 'sidebar') + opts[:include_extras] = (opts[:serializer] == 'sidebar_extras') IssueSidebarEntity when 'board' IssueBoardEntity diff --git a/app/serializers/issue_sidebar_entity.rb b/app/serializers/issue_sidebar_entity.rb index 1b73ead5cd7..d50e19ad046 100644 --- a/app/serializers/issue_sidebar_entity.rb +++ b/app/serializers/issue_sidebar_entity.rb @@ -1,6 +1,11 @@ # frozen_string_literal: true class IssueSidebarEntity < IssuableSidebarEntity + with_options if: { include_basic: true } do + expose :due_date + expose :confidential + end + with_options if: { include_extras: true } do expose :assignees, using: API::Entities::UserBasic end diff --git a/app/serializers/merge_request_serializer.rb b/app/serializers/merge_request_serializer.rb index e252d9a3501..4d236273d49 100644 --- a/app/serializers/merge_request_serializer.rb +++ b/app/serializers/merge_request_serializer.rb @@ -7,9 +7,9 @@ class MergeRequestSerializer < BaseSerializer def represent(merge_request, opts = {}) entity = case opts[:serializer] - when 'sidebar_extras' - opts[:include_basic] = false - opts[:include_extras] = true + when 'sidebar', 'sidebar_extras' + opts[:include_basic] = (opts[:serializer] == 'sidebar') + opts[:include_extras] = (opts[:serializer] == 'sidebar_extras') MergeRequestSidebarEntity when 'basic' MergeRequestBasicEntity diff --git a/app/serializers/merge_request_sidebar_entity.rb b/app/serializers/merge_request_sidebar_entity.rb index 70607230642..a861f2ea73b 100644 --- a/app/serializers/merge_request_sidebar_entity.rb +++ b/app/serializers/merge_request_sidebar_entity.rb @@ -1,4 +1,11 @@ # frozen_string_literal: true class MergeRequestSidebarEntity < IssuableSidebarEntity + with_options if: { include_basic: true } do + expose :assignee, using: API::Entities::UserBasic + + expose :can_merge do |issuable| + issuable.can_be_merged_by?(issuable.assignee) if issuable.assignee + end + end end diff --git a/app/views/projects/issues/show.html.haml b/app/views/projects/issues/show.html.haml index b50b3ca207b..d862640197a 100644 --- a/app/views/projects/issues/show.html.haml +++ b/app/views/projects/issues/show.html.haml @@ -88,4 +88,4 @@ %section.issuable-discussion = render 'projects/issues/discussion' -= render 'shared/issuable/sidebar', issuable: @issue += render 'shared/issuable/sidebar', issuable: @issue, issuable_sidebar: @issuable_sidebar diff --git a/app/views/projects/merge_requests/conflicts/show.html.haml b/app/views/projects/merge_requests/conflicts/show.html.haml index a6e2565a485..ede37c11e64 100644 --- a/app/views/projects/merge_requests/conflicts/show.html.haml +++ b/app/views/projects/merge_requests/conflicts/show.html.haml @@ -6,7 +6,7 @@ .merge-request-details.issuable-details = render "projects/merge_requests/mr_box" -= render 'shared/issuable/sidebar', issuable: @merge_request += render 'shared/issuable/sidebar', issuable: @merge_request, issuable_sidebar: @issuable_sidebar #conflicts{ "v-cloak" => "true", data: { conflicts_path: conflicts_project_merge_request_path(@merge_request.project, @merge_request, format: :json), resolve_conflicts_path: resolve_conflicts_project_merge_request_path(@merge_request.project, @merge_request) } } diff --git a/app/views/projects/merge_requests/show.html.haml b/app/views/projects/merge_requests/show.html.haml index cc9292b54d7..4ebf8afd669 100644 --- a/app/views/projects/merge_requests/show.html.haml +++ b/app/views/projects/merge_requests/show.html.haml @@ -86,7 +86,8 @@ .mr-loading-status = spinner -= render 'shared/issuable/sidebar', issuable: @merge_request += render 'shared/issuable/sidebar', issuable: @merge_request, issuable_sidebar: @issuable_sidebar + - if @merge_request.can_be_reverted?(current_user) = render "projects/commit/change", type: 'revert', commit: @merge_request.merge_commit, title: @merge_request.title - if @merge_request.can_be_cherry_picked? diff --git a/app/views/shared/issuable/_sidebar.html.haml b/app/views/shared/issuable/_sidebar.html.haml index 9eecfa39390..18093e5996b 100644 --- a/app/views/shared/issuable/_sidebar.html.haml +++ b/app/views/shared/issuable/_sidebar.html.haml @@ -1,32 +1,34 @@ -- todo = issuable_todo(issuable) +- issuable_type = issuable_sidebar[:type] +- signed_in = issuable_sidebar[:signed_in] +- can_edit_issuable = issuable_sidebar[:can_edit] -%aside.right-sidebar.js-right-sidebar.js-issuable-sidebar{ data: { signed: { in: current_user.present? } }, class: sidebar_gutter_collapsed_class, 'aria-live' => 'polite' } - .issuable-sidebar{ data: { endpoint: "#{issuable_json_path(issuable)}" } } - - can_edit_issuable = can?(current_user, :"admin_#{issuable.to_ability_name}", @project) +%aside.right-sidebar.js-right-sidebar.js-issuable-sidebar{ data: { signed: { in: signed_in } }, class: sidebar_gutter_collapsed_class, 'aria-live' => 'polite' } + .issuable-sidebar .block.issuable-sidebar-header - - if current_user + - if signed_in %span.issuable-header-text.hide-collapsed.float-left = _('Todo') %a.gutter-toggle.float-right.js-sidebar-toggle.has-tooltip{ role: "button", href: "#", "aria-label" => "Toggle sidebar", title: sidebar_gutter_tooltip_text, data: { container: 'body', placement: 'left', boundary: 'viewport' } } = sidebar_gutter_toggle_icon - - if current_user - = render "shared/issuable/sidebar_todo", todo: todo, issuable: issuable + - if signed_in + = render "shared/issuable/sidebar_todo", issuable_sidebar: issuable_sidebar - = form_for [@project.namespace.becomes(Namespace), @project, issuable], remote: true, format: :json, html: { class: 'issuable-context-form inline-update js-issuable-update' } do |f| - - if current_user + = form_for issuable_type, url: issuable_sidebar[:issuable_json_path], remote: true, html: { class: 'issuable-context-form inline-update js-issuable-update' } do |f| + - if signed_in .block.todo.hide-expanded - = render "shared/issuable/sidebar_todo", todo: todo, issuable: issuable, is_collapsed: true + = render "shared/issuable/sidebar_todo", issuable_sidebar: issuable_sidebar, is_collapsed: true .block.assignee.qa-assignee-block - = render "shared/issuable/sidebar_assignees", issuable: issuable, can_edit_issuable: can_edit_issuable, signed_in: current_user.present? + = render "shared/issuable/sidebar_assignees", issuable: issuable, issuable_sidebar: issuable_sidebar - = render_if_exists 'shared/issuable/sidebar_item_epic', issuable: issuable + = render_if_exists 'shared/issuable/sidebar_item_epic', issuable_sidebar: issuable_sidebar + - milestone = issuable_sidebar[:milestone] || {} .block.milestone - .sidebar-collapsed-icon.has-tooltip{ title: milestone_tooltip_title(issuable.milestone), data: { container: 'body', html: 'true', placement: 'left', boundary: 'viewport' } } + .sidebar-collapsed-icon.has-tooltip{ title: sidebar_milestone_tooltip_label(milestone), data: { container: 'body', html: 'true', placement: 'left', boundary: 'viewport' } } = icon('clock-o', 'aria-hidden': 'true') %span.milestone-title.collapse-truncated-title - - if issuable.milestone - = issuable.milestone.title + - if milestone.present? + = milestone[:title] - else = _('None') .title.hide-collapsed @@ -35,49 +37,50 @@ - if can_edit_issuable = link_to _('Edit'), '#', class: 'js-sidebar-dropdown-toggle edit-link float-right' .value.hide-collapsed - - if issuable.milestone - = link_to issuable.milestone.title, milestone_path(issuable.milestone), class: "bold has-tooltip", title: milestone_tooltip_due_date(issuable.milestone), data: { container: "body", html: 'true', boundary: 'viewport' } + - if milestone.present? + = link_to milestone[:title], milestone[:web_url], class: "bold has-tooltip", title: sidebar_milestone_remaining_days(milestone), data: { container: "body", html: 'true', boundary: 'viewport' } - else %span.no-value = _('None') .selectbox.hide-collapsed - = f.hidden_field 'milestone_id', value: issuable.milestone_id, id: nil - = dropdown_tag('Milestone', options: { title: _('Assign milestone'), toggle_class: 'js-milestone-select js-extra-options', filter: true, dropdown_class: 'dropdown-menu-selectable', placeholder: _('Search milestones'), data: { show_no: true, field_name: "#{issuable.to_ability_name}[milestone_id]", project_id: @project.id, issuable_id: issuable.id, milestones: project_milestones_path(@project, :json), ability_name: issuable.to_ability_name, issue_update: issuable_json_path(issuable), use_id: true, default_no: true, selected: (issuable.milestone.name if issuable.milestone), null_default: true, display: 'static' }}) - - if issuable.has_attribute?(:time_estimate) - #issuable-time-tracker.block - // Fallback while content is loading - .title.hide-collapsed - = _('Time tracking') - = icon('spinner spin', 'aria-hidden': 'true') - - if issuable.has_attribute?(:due_date) + = f.hidden_field 'milestone_id', value: milestone[:id], id: nil + = dropdown_tag('Milestone', options: { title: _('Assign milestone'), toggle_class: 'js-milestone-select js-extra-options', filter: true, dropdown_class: 'dropdown-menu-selectable', placeholder: _('Search milestones'), data: { show_no: true, field_name: "#{issuable_type}[milestone_id]", project_id: issuable_sidebar[:project_id], issuable_id: issuable_sidebar[:id], milestones: issuable_sidebar[:project_milestones_path], ability_name: issuable_type, issue_update: issuable_sidebar[:issuable_json_path], use_id: true, default_no: true, selected: milestone[:title], null_default: true, display: 'static' }}) + + #issuable-time-tracker.block + // Fallback while content is loading + .title.hide-collapsed + = _('Time tracking') + = icon('spinner spin', 'aria-hidden': 'true') + + - if issuable_sidebar.has_key?(:due_date) .block.due_date - .sidebar-collapsed-icon.has-tooltip{ data: { placement: 'left', container: 'body', html: 'true', boundary: 'viewport' }, title: sidebar_due_date_tooltip_label(issuable) } + .sidebar-collapsed-icon.has-tooltip{ data: { placement: 'left', container: 'body', html: 'true', boundary: 'viewport' }, title: sidebar_due_date_tooltip_label(issuable_sidebar[:due_date]) } = icon('calendar', 'aria-hidden': 'true') %span.js-due-date-sidebar-value - = issuable.due_date.try(:to_s, :medium) || 'None' + = issuable_sidebar[:due_date].try(:to_s, :medium) || 'None' .title.hide-collapsed = _('Due date') = icon('spinner spin', class: 'hidden block-loading', 'aria-hidden': 'true') - - if can?(current_user, :"admin_#{issuable.to_ability_name}", @project) + - if can_edit_issuable = link_to _('Edit'), '#', class: 'js-sidebar-dropdown-toggle edit-link float-right' .value.hide-collapsed %span.value-content - - if issuable.due_date - %span.bold= issuable.due_date.to_s(:medium) + - if issuable_sidebar[:due_date] + %span.bold= issuable_sidebar[:due_date].to_s(:medium) - else %span.no-value = _('No due date') - - if can?(current_user, :"admin_#{issuable.to_ability_name}", @project) - %span.no-value.js-remove-due-date-holder{ class: ("hidden" if issuable.due_date.nil?) } + - if can_edit_issuable + %span.no-value.js-remove-due-date-holder{ class: ("hidden" if issuable_sidebar[:due_date].nil?) } \- %a.js-remove-due-date{ href: "#", role: "button" } = _('remove due date') - - if can?(current_user, :"admin_#{issuable.to_ability_name}", @project) + - if can_edit_issuable .selectbox.hide-collapsed - = f.hidden_field :due_date, value: issuable.due_date.try(:strftime, 'yy-mm-dd') + = f.hidden_field :due_date, value: issuable_sidebar[:due_date].try(:strftime, 'yy-mm-dd') .dropdown - %button.dropdown-menu-toggle.js-due-date-select{ type: 'button', data: { toggle: 'dropdown', field_name: "#{issuable.to_ability_name}[due_date]", ability_name: issuable.to_ability_name, issue_update: issuable_json_path(issuable), display: 'static' } } + %button.dropdown-menu-toggle.js-due-date-select{ type: 'button', data: { toggle: 'dropdown', field_name: "#{issuable_type}[due_date]", ability_name: issuable_type, issue_update: issuable_sidebar[:issuable_json_path], display: 'static' } } %span.dropdown-toggle-text = _('Due date') = icon('chevron-down', 'aria-hidden': 'true') @@ -86,56 +89,56 @@ = dropdown_content do .js-due-date-calendar - - if @labels - - selected_labels = issuable.labels - .block.labels - .sidebar-collapsed-icon.js-sidebar-labels-tooltip{ title: issuable_labels_tooltip(issuable.labels_array), data: { placement: "left", container: "body", boundary: 'viewport' } } - = icon('tags', 'aria-hidden': 'true') - %span - = selected_labels.size - .title.hide-collapsed - = _('Labels') - = icon('spinner spin', class: 'hidden block-loading', 'aria-hidden': 'true') - - if can_edit_issuable - = link_to _('Edit'), '#', class: 'js-sidebar-dropdown-toggle edit-link float-right' - .value.issuable-show-labels.dont-hide.hide-collapsed.qa-labels-block{ class: ("has-labels" if selected_labels.any?) } - - if selected_labels.any? - - selected_labels.each do |label| - = link_to_label(label, subject: issuable.project, type: issuable.to_ability_name) - - else - %span.no-value - = _('None') - .selectbox.hide-collapsed + - selected_labels = issuable_sidebar[:labels] + .block.labels + .sidebar-collapsed-icon.js-sidebar-labels-tooltip{ title: issuable_labels_tooltip(selected_labels), data: { placement: "left", container: "body", boundary: 'viewport' } } + = icon('tags', 'aria-hidden': 'true') + %span + = selected_labels.size + .title.hide-collapsed + = _('Labels') + = icon('spinner spin', class: 'hidden block-loading', 'aria-hidden': 'true') + - if can_edit_issuable + = link_to _('Edit'), '#', class: 'js-sidebar-dropdown-toggle edit-link float-right' + .value.issuable-show-labels.dont-hide.hide-collapsed.qa-labels-block{ class: ("has-labels" if selected_labels.any?) } + - if selected_labels.any? - selected_labels.each do |label| - = hidden_field_tag "#{issuable.to_ability_name}[label_names][]", label.id, id: nil - .dropdown - %button.dropdown-menu-toggle.js-label-select.js-multiselect.js-label-sidebar-dropdown{ type: "button", data: {toggle: "dropdown", default_label: "Labels", field_name: "#{issuable.to_ability_name}[label_names][]", ability_name: issuable.to_ability_name, show_no: "true", show_any: "true", namespace_path: @project.try(:namespace).try(:full_path), project_path: @project.try(:path), issue_update: issuable_json_path(issuable), labels: (labels_filter_path_with_defaults if @project), display: 'static' } } - %span.dropdown-toggle-text{ class: ("is-default" if selected_labels.empty?) } - = multi_label_name(selected_labels, "Labels") - = icon('chevron-down', 'aria-hidden': 'true') - .dropdown-menu.dropdown-select.dropdown-menu-paging.dropdown-menu-labels.dropdown-menu-selectable - = render partial: "shared/issuable/label_page_default" - - if can? current_user, :admin_label, @project and @project - = render partial: "shared/issuable/label_page_create" + = link_to sidebar_label_filter_path(issuable_sidebar[:project_issuables_path], label[:title]) do + %span.badge.color-label.has-tooltip{ style: "background-color: #{label[:color]}; color: #{label[:text_color]}", title: label[:description], data: { container: "body" } } + = label[:title] + - else + %span.no-value + = _('None') + .selectbox.hide-collapsed + - selected_labels.each do |label| + = hidden_field_tag "#{issuable_type}[label_names][]", label[:id], id: nil + .dropdown + %button.dropdown-menu-toggle.js-label-select.js-multiselect.js-label-sidebar-dropdown{ type: "button", data: {toggle: "dropdown", default_label: "Labels", field_name: "#{issuable_type}[label_names][]", ability_name: issuable_type, show_no: "true", show_any: "true", namespace_path: issuable_sidebar[:namespace_path], project_path: issuable_sidebar[:project_path], issue_update: issuable_sidebar[:issuable_json_path], labels: issuable_sidebar[:project_labels_path], display: 'static' } } + %span.dropdown-toggle-text{ class: ("is-default" if selected_labels.empty?) } + = multi_label_name(selected_labels, "Labels") + = icon('chevron-down', 'aria-hidden': 'true') + .dropdown-menu.dropdown-select.dropdown-menu-paging.dropdown-menu-labels.dropdown-menu-selectable + = render partial: "shared/issuable/label_page_default" + - if issuable_sidebar[:can_admin_label] + = render partial: "shared/issuable/label_page_create" - = render_if_exists 'shared/issuable/sidebar_weight', issuable: issuable + = render_if_exists 'shared/issuable/sidebar_weight', issuable_sidebar: issuable_sidebar - - if issuable.has_attribute?(:confidential) + - if issuable_sidebar.has_key?(:confidential) -# haml-lint:disable InlineJavaScript - %script#js-confidential-issue-data{ type: "application/json" }= { is_confidential: @issue.confidential, is_editable: can_edit_issuable }.to_json.html_safe + %script#js-confidential-issue-data{ type: "application/json" }= { is_confidential: issuable_sidebar[:confidential], is_editable: can_edit_issuable }.to_json.html_safe #js-confidential-entry-point - - if issuable.has_attribute?(:discussion_locked) - -# haml-lint:disable InlineJavaScript - %script#js-lock-issue-data{ type: "application/json" }= { is_locked: issuable.discussion_locked?, is_editable: can_edit_issuable }.to_json.html_safe - #js-lock-entry-point + -# haml-lint:disable InlineJavaScript + %script#js-lock-issue-data{ type: "application/json" }= { is_locked: issuable_sidebar[:discussion_locked], is_editable: can_edit_issuable }.to_json.html_safe + #js-lock-entry-point .js-sidebar-participants-entry-point - - if current_user + - if signed_in .js-sidebar-subscriptions-entry-point - - project_ref = cross_project_reference(@project, issuable) + - project_ref = issuable_sidebar[:reference] .block.project-reference .sidebar-collapsed-icon.dont-change-state = clipboard_button(text: project_ref, title: _('Copy reference to clipboard'), placement: "left", boundary: 'viewport') @@ -145,7 +148,8 @@ %cite{ title: project_ref } = project_ref = clipboard_button(text: project_ref, title: _('Copy reference to clipboard'), placement: "left", boundary: 'viewport') - - if current_user && issuable.can_move?(current_user) + + - if issuable_sidebar[:can_move] .block.js-sidebar-move-issue-block .sidebar-collapsed-icon{ data: { toggle: 'tooltip', placement: 'left', container: 'body', boundary: 'viewport' }, title: _('Move issue') } = custom_icon('icon_arrow_right') @@ -164,4 +168,4 @@ = icon('spinner spin', class: 'sidebar-move-issue-confirmation-loading-icon') -# haml-lint:disable InlineJavaScript - %script.js-sidebar-options{ type: "application/json" }= issuable_sidebar_options(issuable, can_edit_issuable).to_json.html_safe + %script.js-sidebar-options{ type: "application/json" }= issuable_sidebar_options(issuable_sidebar).to_json.html_safe diff --git a/app/views/shared/issuable/_sidebar_assignees.html.haml b/app/views/shared/issuable/_sidebar_assignees.html.haml index 8a13c7a3b83..25e4f0dc14e 100644 --- a/app/views/shared/issuable/_sidebar_assignees.html.haml +++ b/app/views/shared/issuable/_sidebar_assignees.html.haml @@ -1,11 +1,15 @@ -- if issuable.is_a?(Issue) - #js-vue-sidebar-assignees{ data: { field: "#{issuable.to_ability_name}[assignee_ids]", signed_in: signed_in } } +- issuable_type = issuable_sidebar[:type] +- signed_in = issuable_sidebar[:signed_in] +- can_edit_issuable = issuable_sidebar[:can_edit] + +- if issuable_type == "issue" + #js-vue-sidebar-assignees{ data: { field: "#{issuable_type}[assignee_ids]", signed_in: signed_in } } .title.hide-collapsed = _('Assignee') = icon('spinner spin') - else - .sidebar-collapsed-icon.sidebar-collapsed-user{ data: { toggle: "tooltip", placement: "left", container: "body", boundary: 'viewport' }, title: sidebar_assignee_tooltip_label(issuable) } - - if issuable.assignee + .sidebar-collapsed-icon.sidebar-collapsed-user{ data: { toggle: "tooltip", placement: "left", container: "body", boundary: 'viewport' }, title: (issuable_sidebar.dig(:assignee, :name) || _('Assignee')) } + - if issuable_sidebar[:assignee] = link_to_member(@project, issuable.assignee, size: 24) - else = icon('user', 'aria-hidden': 'true') @@ -18,13 +22,13 @@ %a.gutter-toggle.float-right.js-sidebar-toggle{ role: "button", href: "#", "aria-label" => _('Toggle sidebar') } = sidebar_gutter_toggle_icon .value.hide-collapsed - - if issuable.assignee + - if issuable_sidebar[:assignee] = link_to_member(@project, issuable.assignee, size: 32, extra_class: 'bold') do - - if !issuable.can_be_merged_by?(issuable.assignee) + - if issuable_sidebar[:can_merge] %span.float-right.cannot-be-merged{ data: { toggle: 'tooltip', placement: 'left' }, title: _('Not allowed to merge') } = icon('exclamation-triangle', 'aria-hidden': 'true') %span.username - = issuable.assignee.to_reference + @#{issuable_sidebar[:assignee][:username]} - else %span.assign-yourself.no-value = _('No assignee') @@ -34,19 +38,33 @@ = _('assign yourself') .selectbox.hide-collapsed - - issuable.assignees.each do |assignee| - = hidden_field_tag "#{issuable.to_ability_name}[assignee_ids][]", assignee.id, id: nil, data: { avatar_url: assignee.avatar_url, name: assignee.name, username: assignee.username } + - if issuable.assignees.none? + = hidden_field_tag "#{issuable_type}[assignee_ids][]", 0, id: nil + - else + - issuable.assignees.each do |assignee| + = hidden_field_tag "#{issuable_type}[assignee_ids][]", assignee.id, id: nil, data: { avatar_url: assignee.avatar_url, name: assignee.name, username: assignee.username } - - options = { toggle_class: 'js-user-search js-author-search', title: _('Assign to'), filter: true, dropdown_class: 'dropdown-menu-user dropdown-menu-selectable dropdown-menu-author', placeholder: _('Search users'), data: { first_user: current_user&.username, current_user: true, project_id: @project&.id, author_id: issuable.author_id, field_name: "#{issuable.to_ability_name}[assignee_ids][]", issue_update: issuable_json_path(issuable), ability_name: issuable.to_ability_name, null_user: true, display: 'static' } } + - options = { toggle_class: 'js-user-search js-author-search', + title: _('Assign to'), + filter: true, + dropdown_class: 'dropdown-menu-user dropdown-menu-selectable dropdown-menu-author', + placeholder: _('Search users'), + data: { first_user: issuable_sidebar.dig(:current_user, :username), + current_user: true, + project_id: issuable_sidebar[:project_id], + author_id: issuable_sidebar[:author_id], + field_name: "#{issuable_type}[assignee_ids][]", + issue_update: issuable_sidebar[:issuable_json_path], + ability_name: issuable_type, + null_user: true, + display: 'static' } } - title = _('Select assignee') - - if issuable.is_a?(Issue) - - unless issuable.assignees.any? - = hidden_field_tag "#{issuable.to_ability_name}[assignee_ids][]", 0, id: nil + - if issuable_type == "issue" - dropdown_options = issue_assignees_dropdown_options - title = dropdown_options[:title] - options[:toggle_class] += ' js-multiselect js-save-user-data' - - data = { field_name: "#{issuable.to_ability_name}[assignee_ids][]" } + - data = { field_name: "#{issuable_type}[assignee_ids][]" } - data[:multi_select] = true - data['dropdown-title'] = title - data['dropdown-header'] = dropdown_options[:data][:'dropdown-header'] diff --git a/app/views/shared/issuable/_sidebar_todo.html.haml b/app/views/shared/issuable/_sidebar_todo.html.haml index 660ee6d5777..8411327566b 100644 --- a/app/views/shared/issuable/_sidebar_todo.html.haml +++ b/app/views/shared/issuable/_sidebar_todo.html.haml @@ -1,14 +1,28 @@ - is_collapsed = local_assigns.fetch(:is_collapsed, false) -- mark_content = is_collapsed ? sprite_icon('todo-done', css_class: 'todo-undone') : _('Mark todo as done') -- todo_content = is_collapsed ? sprite_icon('todo-add') : _('Add todo') +- todo = issuable_sidebar[:todo] || {} + +- todo_text = _('Add todo') +- mark_text = _('Mark todo as done') +- todo_icon = sprite_icon('todo-add') +- mark_icon = sprite_icon('todo-done', css_class: 'todo-undone') + +- mark_content = is_collapsed ? mark_icon : mark_text +- todo_content = is_collapsed ? todo_icon : todo_text %button.issuable-todo-btn.js-issuable-todo{ type: 'button', class: (is_collapsed ? 'btn-blank sidebar-collapsed-icon dont-change-state has-tooltip' : 'btn btn-default issuable-header-btn float-right'), - title: (todo.nil? ? _('Add todo') : _('Mark todo as done')), - 'aria-label' => (todo.nil? ? _('Add todo') : _('Mark todo as done')), - data: issuable_todo_button_data(issuable, todo, is_collapsed) } + title: (todo[:id] ? mark_text : todo_text), + 'aria-label' => (todo[:id] ? mark_text : todo_text), + data: { todo_text: todo_text, + mark_text: mark_text, + todo_icon: is_collapsed ? todo_icon : nil, + mark_icon: is_collapsed ? mark_icon : nil, + issuable_id: issuable_sidebar[:id], + issuable_type: issuable_sidebar[:type], + create_path: issuable_sidebar[:create_todo_path], + delete_path: todo[:delete_path] } } %span.issuable-todo-inner.js-issuable-todo-inner< - - if todo + - if todo[:id] = mark_content - else = todo_content diff --git a/app/views/shared/issuable/form/_metadata.html.haml b/app/views/shared/issuable/form/_metadata.html.haml index ac8d58c0bfe..e370dff9526 100644 --- a/app/views/shared/issuable/form/_metadata.html.haml +++ b/app/views/shared/issuable/form/_metadata.html.haml @@ -19,10 +19,9 @@ .issuable-form-select-holder = render "shared/issuable/milestone_dropdown", selected: issuable.milestone, name: "#{issuable.class.model_name.param_key}[milestone_id]", show_any: false, show_upcoming: false, show_started: false, extra_class: "qa-issuable-milestone-dropdown js-issuable-form-dropdown js-dropdown-keep-input", dropdown_title: "Select milestone" .form-group.row - - has_labels = @labels && @labels.any? = form.label :label_ids, "Labels", class: "col-form-label #{has_due_date ? "col-md-2 col-lg-4" : "col-sm-2"}" = form.hidden_field :label_ids, multiple: true, value: '' - .col-sm-10{ class: "#{"col-md-8" if has_due_date} #{'issuable-form-padding-top' if !has_labels}" } + .col-sm-10{ class: "#{"col-md-8" if has_due_date}" } .issuable-form-select-holder = render "shared/issuable/label_dropdown", classes: ["js-issuable-form-dropdown"], selected: issuable.labels, data_options: { field_name: "#{issuable.class.model_name.param_key}[label_ids][]", show_any: false }, dropdown_title: "Select label" diff --git a/spec/controllers/projects/merge_requests_controller_spec.rb b/spec/controllers/projects/merge_requests_controller_spec.rb index 962dfb91c9f..3ab5a5f0804 100644 --- a/spec/controllers/projects/merge_requests_controller_spec.rb +++ b/spec/controllers/projects/merge_requests_controller_spec.rb @@ -30,19 +30,6 @@ describe Projects::MergeRequestsController do end end - shared_examples "loads labels" do |action| - it "loads labels into the @labels variable" do - get action, - params: { - namespace_id: project.namespace.to_param, - project_id: project, - id: merge_request.iid - }, - format: 'html' - expect(assigns(:labels)).not_to be_nil - end - end - describe "GET show" do def go(extra_params = {}) params = { @@ -54,8 +41,6 @@ describe Projects::MergeRequestsController do get :show, params: params.merge(extra_params) end - it_behaves_like "loads labels", :show - describe 'as html' do context 'when diff files were cleaned' do render_views diff --git a/spec/helpers/issuables_helper_spec.rb b/spec/helpers/issuables_helper_spec.rb index 4af98bc3678..3efac10ac9f 100644 --- a/spec/helpers/issuables_helper_spec.rb +++ b/spec/helpers/issuables_helper_spec.rb @@ -43,16 +43,19 @@ describe IssuablesHelper do end describe '#issuable_labels_tooltip' do + let (:label_entity) { LabelEntity.represent(label) } + let (:label2_entity) { LabelEntity.represent(label2) } + it 'returns label text with no labels' do expect(issuable_labels_tooltip([])).to eq("Labels") end it 'returns label text with labels within max limit' do - expect(issuable_labels_tooltip([label])).to eq(label.title) + expect(issuable_labels_tooltip([label_entity])).to eq(label[:title]) end it 'returns label text with labels exceeding max limit' do - expect(issuable_labels_tooltip([label, label2], limit: 1)).to eq("#{label.title}, and 1 more") + expect(issuable_labels_tooltip([label_entity, label2_entity], limit: 1)).to eq("#{label[:title]}, and 1 more") end end @@ -197,33 +200,4 @@ describe IssuablesHelper do expect(helper.issuable_initial_data(issue)).to eq(expected_data) end end - - describe '#selected_labels' do - context 'if label_name param is a string' do - it 'returns a new label with title' do - allow(helper).to receive(:params) - .and_return(ActionController::Parameters.new(label_name: 'test label')) - - labels = helper.selected_labels - - expect(labels).to be_an(Array) - expect(labels.size).to eq(1) - expect(labels.first.title).to eq('test label') - end - end - - context 'if label_name param is an array' do - it 'returns a new label with title for each element' do - allow(helper).to receive(:params) - .and_return(ActionController::Parameters.new(label_name: ['test label 1', 'test label 2'])) - - labels = helper.selected_labels - - expect(labels).to be_an(Array) - expect(labels.size).to eq(2) - expect(labels.first.title).to eq('test label 1') - expect(labels.second.title).to eq('test label 2') - end - end - end end diff --git a/spec/serializers/entity_date_helper_spec.rb b/spec/serializers/entity_date_helper_spec.rb index 36da8d33a44..df7f33847c9 100644 --- a/spec/serializers/entity_date_helper_spec.rb +++ b/spec/serializers/entity_date_helper_spec.rb @@ -96,5 +96,31 @@ describe EntityDateHelper do expect(milestone_remaining).to eq("2 days elapsed") end end + + context 'with Hash as param' do + context 'when due_date is in the past' do + it 'returns "Past due"' do + expect(date_helper_class.remaining_days_in_words(due_date: 2.days.ago.to_date)).to eq("Past due") + end + end + + context 'when due_date is in the future' do + it 'returns days remaining' do + expect(date_helper_class.remaining_days_in_words(due_date: 12.days.from_now.to_date)).to eq("12 days remaining") + end + end + + context 'when start_date is in the future' do + it 'returns "Upcoming"' do + expect(date_helper_class.remaining_days_in_words(start_date: 2.days.from_now.to_date)).to eq("Upcoming") + end + end + + context 'when start_date is in the past' do + it 'returns days elapsed' do + expect(date_helper_class.remaining_days_in_words(start_date: 2.days.ago.to_date)).to eq("2 days elapsed") + end + end + end end end diff --git a/spec/serializers/merge_request_serializer_spec.rb b/spec/serializers/merge_request_serializer_spec.rb index aa055854add..2c91e620dcd 100644 --- a/spec/serializers/merge_request_serializer_spec.rb +++ b/spec/serializers/merge_request_serializer_spec.rb @@ -20,7 +20,7 @@ describe MergeRequestSerializer do context 'sidebar merge request serialization' do let(:serializer) { 'sidebar_extras' } - it 'matches basic merge request json schema' do + it 'matches sidebar merge request json schema' do expect(json_entity).to match_schema('entities/merge_request_sidebar') end end diff --git a/spec/views/projects/merge_requests/show.html.haml_spec.rb b/spec/views/projects/merge_requests/show.html.haml_spec.rb index fa6c4ce4ac8..b0042be339c 100644 --- a/spec/views/projects/merge_requests/show.html.haml_spec.rb +++ b/spec/views/projects/merge_requests/show.html.haml_spec.rb @@ -32,6 +32,11 @@ describe 'projects/merge_requests/show.html.haml' do assign(:noteable, closed_merge_request) assign(:notes, []) assign(:pipelines, Ci::Pipeline.none) + assign( + :issuable_sidebar, + MergeRequestSerializer.new(current_user: user, project: project) + .represent(closed_merge_request, serializer: 'sidebar') + ) preload_view_requirements