Commit c859cdd0 authored by Paul Slaughter's avatar Paul Slaughter Committed by James Fargher

Introduce top nav helper

- This provides a view model which will
  power both the responsive and wide view
  of the top nav.
- Introduces a PORO for TopNavMenuItem
- Adds some Builders to help improve
  readability and maintainability of this
- Migrates a fiew nav links from the views
  to here. These will be finished in a later
  commit.
parent 83d80428
# frozen_string_literal: true
module Nav
module TopNavHelper
PROJECTS_VIEW = :projects
def top_nav_view_model(project:)
builder = ::Gitlab::Nav::TopNavViewModelBuilder.new
if current_user
build_view_model(builder: builder, project: project)
else
build_anonymous_view_model(builder: builder)
end
builder.build
end
private
def build_anonymous_view_model(builder:)
# These come from `app/views/layouts/nav/_explore.html.ham`
# TODO: We will move the rest of them shortly
# https://gitlab.com/gitlab-org/gitlab/-/merge_requests/56587
if explore_nav_link?(:projects)
builder.add_primary_menu_item(
**projects_menu_item_attrs.merge({
active: active_nav_link?(path: ['dashboard#show', 'root#show', 'projects#trending', 'projects#starred', 'projects#index']),
href: explore_root_path
})
)
end
end
def build_view_model(builder:, project:)
# These come from `app/views/layouts/nav/_dashboard.html.haml`
if dashboard_nav_link?(:projects)
current_item = project ? current_project(project: project) : {}
builder.add_primary_menu_item(
**projects_menu_item_attrs.merge({
active: active_nav_link?(path: ['root#index', 'projects#trending', 'projects#starred', 'dashboard/projects#index']),
css_class: 'qa-projects-dropdown',
data: { track_label: "projects_dropdown", track_event: "click_dropdown", track_experiment: "new_repo" },
view: PROJECTS_VIEW
})
)
builder.add_view(PROJECTS_VIEW, container_view_props(current_item: current_item, submenu: projects_submenu))
end
if dashboard_nav_link?(:milestones)
builder.add_primary_menu_item(
id: 'milestones',
title: 'Milestones',
active: active_nav_link?(controller: 'dashboard/milestones'),
icon: 'clock',
data: { qa_selector: 'milestones_link' },
href: dashboard_milestones_path
)
end
# Using admin? is generally discouraged because it does not check for
# "admin_mode". In this case we are migrating code and check both, so
# we should be good.
# rubocop: disable Cop/UserAdmin
if current_user&.admin?
builder.add_secondary_menu_item(
id: 'admin',
title: _('Admin'),
active: active_nav_link?(controller: 'admin/dashboard'),
icon: 'admin',
css_class: 'qa-admin-area-link',
href: admin_root_path
)
end
if Gitlab::CurrentSettings.admin_mode
if header_link?(:admin_mode)
builder.add_secondary_menu_item(
id: 'leave_admin_mode',
title: _('Leave Admin Mode'),
active: active_nav_link?(controller: 'admin/sessions'),
icon: 'lock-open',
href: destroy_admin_session_path,
method: :post
)
elsif current_user.admin?
builder.add_secondary_menu_item(
id: 'enter_admin_mode',
title: _('Enter Admin Mode'),
active: active_nav_link?(controller: 'admin/sessions'),
icon: 'lock',
href: new_admin_session_path
)
end
end
# rubocop: enable Cop/UserAdmin
end
def projects_menu_item_attrs
{
id: 'project',
title: _('Projects'),
icon: 'project'
}
end
def container_view_props(current_item:, submenu:)
{
namespace: 'projects',
currentUserName: current_user&.username,
currentItem: current_item,
linksPrimary: submenu[:primary],
linksSecondary: submenu[:secondary]
}
end
def current_project(project:)
return {} unless project.persisted?
{
id: project.id,
name: project.name,
namespace: project.full_name,
webUrl: project_path(project),
avatarUrl: project.avatar_url
}
end
def projects_submenu
# These project links come from `app/views/layouts/nav/projects_dropdown/_show.html.haml`
builder = ::Gitlab::Nav::TopNavMenuBuilder.new
builder.add_primary_menu_item(id: 'your', title: _('Your projects'), href: dashboard_projects_path)
builder.add_primary_menu_item(id: 'starred', title: _('Starred projects'), href: starred_dashboard_projects_path)
builder.add_primary_menu_item(id: 'explore', title: _('Explore projects'), href: explore_root_path)
builder.add_secondary_menu_item(id: 'create', title: _('Create new project'), href: new_project_path)
builder.build
end
end
end
Nav::TopNavHelper.prepend_ee_mod
# frozen_string_literal: true
module EE
module Nav
module TopNavHelper
extend ::Gitlab::Utils::Override
private
override :build_view_model
def build_view_model(builder:, project:)
super
# These come from `ee/app/views/dashboard/_nav_link_list.html.haml`
if dashboard_nav_link?(:environments)
builder.add_primary_menu_item(
id: 'environments',
title: 'Environments',
icon: 'environment',
data: { qa_selector: 'environment_link' },
href: operations_environments_path
)
end
if dashboard_nav_link?(:operations)
builder.add_primary_menu_item(
id: 'operations',
title: 'Operations',
icon: 'cloud-gear',
data: { qa_selector: 'operations_link' },
href: operations_path
)
end
if dashboard_nav_link?(:security)
builder.add_primary_menu_item(
id: 'security',
title: 'Security',
icon: 'shield',
data: { qa_selector: 'security_link' },
href: security_dashboard_path
)
end
# These come from `ee/app/views/layouts/nav/_geo_primary_node_url.html.haml`
if ::Gitlab::Geo.secondary? && ::Gitlab::Geo.primary_node_configured?
builder.add_secondary_menu_item(
id: 'geo',
title: _('Go to primary node'),
icon: 'location-dot',
href: ::Gitlab::Geo.primary_node.url
)
end
end
end
end
end
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe Nav::TopNavHelper do
describe '#top_nav_view_model' do
let_it_be(:user) { build_stubbed(:user) }
let(:current_user) { user }
let(:with_environments) { false }
let(:with_operations) { false }
let(:with_security) { false }
let(:with_geo_secondary) { false }
let(:with_geo_primary_node_configured) { false }
let(:subject) { helper.top_nav_view_model(project: nil) }
before do
allow(helper).to receive(:current_user) { current_user }
allow(helper).to receive(:header_link?).with(anything) { false }
# Defaulting all `dashboard_nav_link?` calls to false ensures the CE-specific behavior
# is not tested in this EE spec
allow(helper).to receive(:dashboard_nav_link?).with(anything) { false }
allow(helper).to receive(:dashboard_nav_link?).with(:environments) { with_environments }
allow(helper).to receive(:dashboard_nav_link?).with(:operations) { with_operations }
allow(helper).to receive(:dashboard_nav_link?).with(:security) { with_security }
allow(::Gitlab::Geo).to receive(:secondary?) { with_geo_secondary }
allow(::Gitlab::Geo).to receive(:primary_node_configured?) { with_geo_primary_node_configured }
end
context 'with environments' do
let(:with_environments) { true }
it 'has expected :primary' do
expected_primary = ::Gitlab::Nav::TopNavMenuItem.build(
data: {
qa_selector: 'environment_link'
},
href: '/-/operations/environments',
icon: 'environment',
id: 'environments',
title: 'Environments'
)
expect(subject[:primary]).to eq([expected_primary])
end
end
context 'with operations' do
let(:with_operations) { true }
it 'has expected :primary' do
expected_primary = ::Gitlab::Nav::TopNavMenuItem.build(
data: {
qa_selector: 'operations_link'
},
href: '/-/operations',
icon: 'cloud-gear',
id: 'operations',
title: 'Operations'
)
expect(subject[:primary]).to eq([expected_primary])
end
end
context 'with security' do
let(:with_security) { true }
it 'has expected :primary' do
expected_primary = ::Gitlab::Nav::TopNavMenuItem.build(
data: {
qa_selector: 'security_link'
},
href: '/-/security/dashboard',
icon: 'shield',
id: 'security',
title: 'Security'
)
expect(subject[:primary]).to eq([expected_primary])
end
end
context 'with geo' do
let(:with_geo_secondary) { true }
let(:with_geo_primary_node_configured) { true }
let(:url) { 'fake_url' }
before do
allow(::Gitlab::Geo).to receive_message_chain(:primary_node, :url) { url }
end
it 'has expected :secondary' do
expected_secondary = ::Gitlab::Nav::TopNavMenuItem.build(
href: url,
icon: 'location-dot',
id: 'geo',
title: 'Go to primary node'
)
expect(subject[:secondary]).to eq([expected_secondary])
end
end
end
end
# frozen_string_literal: true
module Gitlab
module Nav
class TopNavMenuBuilder
def initialize
@primary = []
@secondary = []
end
def add_primary_menu_item(**args)
add_menu_item(dest: @primary, **args)
end
def add_secondary_menu_item(**args)
add_menu_item(dest: @secondary, **args)
end
def build
{
primary: @primary,
secondary: @secondary
}
end
private
def add_menu_item(dest:, **args)
item = ::Gitlab::Nav::TopNavMenuItem.build(**args)
dest.push(item)
end
end
end
end
# frozen_string_literal: true
module Gitlab
module Nav
class TopNavMenuItem
# We want to have all keyword arguments for type safety.
# Ordinarily we could introduce a params object, but that's kind of what
# this is already :/. We could also take a hash and manually check every
# entry, but it's much more maintainable to do rely on native Ruby.
# rubocop: disable Metrics/ParameterLists
def self.build(id:, title:, active: false, icon: '', href: '', method: nil, view: '', css_class: '', data: {})
{
id: id,
title: title,
active: active,
icon: icon,
href: href,
method: method,
view: view.to_s,
css_class: css_class,
data: data
}
end
# rubocop: enable Metrics/ParameterLists
end
end
end
# frozen_string_literal: true
module Gitlab
module Nav
class TopNavViewModelBuilder
def initialize
@menu_builder = ::Gitlab::Nav::TopNavMenuBuilder.new
@views = {}
end
delegate :add_primary_menu_item, :add_secondary_menu_item, to: :@menu_builder
def add_view(name, props)
@views[name] = props
end
def build
menu = @menu_builder.build
menu.merge({
views: @views,
activeTitle: _('Menu')
})
end
end
end
end
......@@ -2165,6 +2165,9 @@ msgstr ""
msgid "Adjust your filters/search criteria above. If you believe this may be an error, please refer to the %{linkStart}Geo Troubleshooting%{linkEnd} documentation for more information."
msgstr ""
msgid "Admin"
msgstr ""
msgid "Admin Area"
msgstr ""
......@@ -9343,6 +9346,9 @@ msgstr ""
msgid "Create new label"
msgstr ""
msgid "Create new project"
msgstr ""
msgid "Create new..."
msgstr ""
......@@ -15288,6 +15294,9 @@ msgstr ""
msgid "Go to previous page"
msgstr ""
msgid "Go to primary node"
msgstr ""
msgid "Go to project"
msgstr ""
......@@ -20284,6 +20293,9 @@ msgstr ""
msgid "Memory Usage"
msgstr ""
msgid "Menu"
msgstr ""
msgid "Merge"
msgstr ""
......
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe Nav::TopNavHelper do
include ActionView::Helpers::UrlHelper
describe '#top_nav_view_model' do
let_it_be(:user) { build_stubbed(:user) }
let_it_be(:admin) { build_stubbed(:user, :admin) }
let(:current_user) { nil }
let(:current_project) { nil }
let(:with_current_settings_admin_mode) { false }
let(:with_header_link_admin_mode) { false }
let(:with_projects) { false }
let(:with_milestones) { false }
let(:subject) { helper.top_nav_view_model(project: current_project) }
let(:active_title) { 'Menu' }
before do
allow(helper).to receive(:current_user) { current_user }
allow(Gitlab::CurrentSettings).to receive(:admin_mode) { with_current_settings_admin_mode }
allow(helper).to receive(:header_link?).with(:admin_mode) { with_header_link_admin_mode }
# Defaulting all `dashboard_nav_link?` calls to false ensures the EE-specific behavior
# is not enabled in this CE spec
allow(helper).to receive(:dashboard_nav_link?).with(anything) { false }
allow(helper).to receive(:dashboard_nav_link?).with(:projects) { with_projects }
allow(helper).to receive(:dashboard_nav_link?).with(:milestones) { with_milestones }
end
it 'has :activeTitle' do
expect(subject[:activeTitle]).to eq(active_title)
end
context 'when current_user is nil (anonymous)' do
it 'has expected :primary' do
expected_primary = ::Gitlab::Nav::TopNavMenuItem.build(
href: '/explore',
icon: 'project',
id: 'project',
title: 'Projects'
)
expect(subject[:primary]).to eq([expected_primary])
end
end
context 'when current_user is non-admin' do
let(:current_user) { user }
it 'has no menu items or views by default' do
expect(subject).to eq({ activeTitle: active_title,
primary: [],
secondary: [],
views: {} })
end
context 'with projects' do
let(:with_projects) { true }
let(:projects_view) { subject[:views][:projects] }
it 'has expected :primary' do
expected_primary = ::Gitlab::Nav::TopNavMenuItem.build(
css_class: 'qa-projects-dropdown',
data: {
track_event: 'click_dropdown',
track_experiment: 'new_repo',
track_label: 'projects_dropdown'
},
icon: 'project',
id: 'project',
title: 'Projects',
view: 'projects'
)
expect(subject[:primary]).to eq([expected_primary])
end
context 'projects' do
it 'has expected :currentUserName' do
expect(projects_view[:currentUserName]).to eq(current_user.username)
end
it 'has expected :namespace' do
expect(projects_view[:namespace]).to eq('projects')
end
it 'has expected :linksPrimary' do
expected_links_primary = [
::Gitlab::Nav::TopNavMenuItem.build(
href: '/dashboard/projects',
id: 'your',
title: 'Your projects'
),
::Gitlab::Nav::TopNavMenuItem.build(
href: '/dashboard/projects/starred',
id: 'starred',
title: 'Starred projects'
),
::Gitlab::Nav::TopNavMenuItem.build(
href: '/explore',
id: 'explore',
title: 'Explore projects'
)
]
expect(projects_view[:linksPrimary]).to eq(expected_links_primary)
end
it 'has expected :linksSecondary' do
expected_links_secondary = [
::Gitlab::Nav::TopNavMenuItem.build(
href: '/projects/new',
id: 'create',
title: 'Create new project'
)
]
expect(projects_view[:linksSecondary]).to eq(expected_links_secondary)
end
context 'with persisted project' do
let_it_be(:project) { build_stubbed(:project) }
let(:current_project) { project }
let(:avatar_url) { 'avatar_url' }
before do
allow(project).to receive(:persisted?) { true }
allow(project).to receive(:avatar_url) { avatar_url }
end
it 'has project as :container' do
expected_container = {
avatarUrl: avatar_url,
id: project.id,
name: project.name,
namespace: project.full_name,
webUrl: project_path(project)
}
expect(projects_view[:currentItem]).to eq(expected_container)
end
end
end
end
context 'with milestones' do
let(:with_milestones) { true }
it 'has expected :primary' do
expected_primary = ::Gitlab::Nav::TopNavMenuItem.build(
data: {
qa_selector: 'milestones_link'
},
href: '/dashboard/milestones',
icon: 'clock',
id: 'milestones',
title: 'Milestones'
)
expect(subject[:primary]).to eq([expected_primary])
end
end
end
context 'when current_user is admin' do
let_it_be(:current_user) { admin }
let(:with_current_settings_admin_mode) { true }
it 'has admin as first :secondary item' do
expected_admin_item = ::Gitlab::Nav::TopNavMenuItem.build(
id: 'admin',
title: 'Admin',
icon: 'admin',
href: '/admin',
css_class: 'qa-admin-area-link'
)
expect(subject[:secondary].first).to eq(expected_admin_item)
end
context 'with header link admin_mode true' do
let(:with_header_link_admin_mode) { true }
it 'has leave_admin_mode as last :secondary item' do
expected_leave_admin_mode_item = ::Gitlab::Nav::TopNavMenuItem.build(
id: 'leave_admin_mode',
title: 'Leave Admin Mode',
icon: 'lock-open',
href: '/admin/session/destroy',
method: :post
)
expect(subject[:secondary].last).to eq(expected_leave_admin_mode_item)
end
end
context 'with header link admin_mode false' do
let(:with_header_link_admin_mode) { false }
it 'has enter_admin_mode as last :secondary item' do
expected_enter_admin_mode_item = ::Gitlab::Nav::TopNavMenuItem.build(
id: 'enter_admin_mode',
title: 'Enter Admin Mode',
icon: 'lock',
href: '/admin/session/new'
)
expect(subject[:secondary].last).to eq(expected_enter_admin_mode_item)
end
end
end
end
end
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe ::Gitlab::Nav::TopNavMenuItem do
describe '.build' do
it 'builds a hash from the given args' do
item = {
id: 'id',
title: 'Title',
active: true,
icon: 'icon',
href: 'href',
method: 'method',
view: 'view',
css_class: 'css_class',
data: {}
}
expect(described_class.build(**item)).to eq(item)
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