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 "" ...@@ -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." 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 "" msgstr ""
msgid "Admin"
msgstr ""
msgid "Admin Area" msgid "Admin Area"
msgstr "" msgstr ""
...@@ -9343,6 +9346,9 @@ msgstr "" ...@@ -9343,6 +9346,9 @@ msgstr ""
msgid "Create new label" msgid "Create new label"
msgstr "" msgstr ""
msgid "Create new project"
msgstr ""
msgid "Create new..." msgid "Create new..."
msgstr "" msgstr ""
...@@ -15288,6 +15294,9 @@ msgstr "" ...@@ -15288,6 +15294,9 @@ msgstr ""
msgid "Go to previous page" msgid "Go to previous page"
msgstr "" msgstr ""
msgid "Go to primary node"
msgstr ""
msgid "Go to project" msgid "Go to project"
msgstr "" msgstr ""
...@@ -20284,6 +20293,9 @@ msgstr "" ...@@ -20284,6 +20293,9 @@ msgstr ""
msgid "Memory Usage" msgid "Memory Usage"
msgstr "" msgstr ""
msgid "Menu"
msgstr ""
msgid "Merge" msgid "Merge"
msgstr "" 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