Commit a32e201d authored by Igor Drozdov's avatar Igor Drozdov

Merge branch '281963-move-cohorts-to-users' into 'master'

Move cohorts to users page

See merge request gitlab-org/gitlab!51707
parents a4b93980 c7a17427
import Vue from 'vue';
import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils';
import UsagePingDisabled from './components/usage_ping_disabled.vue';
import AdminUsersApp from './components/app.vue';
export default function (el = document.querySelector('#js-admin-users-app')) {
export const initAdminUsersApp = (el = document.querySelector('#js-admin-users-app')) => {
if (!el) {
return false;
}
......@@ -19,4 +20,24 @@ export default function (el = document.querySelector('#js-admin-users-app')) {
},
}),
});
}
};
export const initCohortsEmptyState = (el = document.querySelector('#js-cohorts-empty-state')) => {
if (!el) {
return false;
}
const { emptyStateSvgPath, enableUsagePingLink, docsLink } = el.dataset;
return new Vue({
el,
provide: {
svgPath: emptyStateSvgPath,
primaryButtonPath: enableUsagePingLink,
docsLink,
},
render(h) {
return h(UsagePingDisabled);
},
});
};
import { historyPushState } from '~/lib/utils/common_utils';
import { mergeUrlParams } from '~/lib/utils/url_utility';
const COHORTS_PANE = 'cohorts';
const tabClickHandler = (e) => {
const { hash } = e.currentTarget;
const tab = hash === `#${COHORTS_PANE}` ? COHORTS_PANE : null;
const newUrl = mergeUrlParams({ tab }, window.location.href);
historyPushState(newUrl);
};
const initTabs = () => {
const tabLinks = document.querySelectorAll('.js-users-tab-item a');
if (tabLinks.length) {
tabLinks.forEach((tabLink) => {
tabLink.addEventListener('click', (e) => tabClickHandler(e));
});
}
};
export default initTabs;
import Vue from 'vue';
import UsagePingDisabled from '~/admin/cohorts/components/usage_ping_disabled.vue';
document.addEventListener('DOMContentLoaded', () => {
const emptyStateContainer = document.getElementById('js-cohorts-empty-state');
if (!emptyStateContainer) return false;
const { emptyStateSvgPath, enableUsagePingLink, docsLink } = emptyStateContainer.dataset;
return new Vue({
el: emptyStateContainer,
provide: {
svgPath: emptyStateSvgPath,
primaryButtonPath: enableUsagePingLink,
docsLink,
},
render(h) {
return h(UsagePingDisabled);
},
});
});
......@@ -4,7 +4,8 @@ import Translate from '~/vue_shared/translate';
import ModalManager from './components/user_modal_manager.vue';
import csrf from '~/lib/utils/csrf';
import initConfirmModal from '~/confirm_modal';
import initAdminUsersApp from '~/admin/users';
import { initAdminUsersApp, initCohortsEmptyState } from '~/admin/users';
import initTabs from '~/admin/users/tabs';
const MODAL_TEXTS_CONTAINER_SELECTOR = '#js-modal-texts';
const MODAL_MANAGER_SELECTOR = '#js-delete-user-modal';
......@@ -58,4 +59,6 @@ document.addEventListener('DOMContentLoaded', () => {
initConfirmModal();
initAdminUsersApp();
initCohortsEmptyState();
initTabs();
});
# frozen_string_literal: true
class Admin::CohortsController < Admin::ApplicationController
include Analytics::UniqueVisitsHelper
track_unique_visits :index, target_id: 'i_analytics_cohorts'
feature_category :devops_reports
# Backwards compatibility. Remove it and routing in 14.0
# @see https://gitlab.com/gitlab-org/gitlab/-/issues/299303
def index
if Gitlab::CurrentSettings.usage_ping_enabled
cohorts_results = Rails.cache.fetch('cohorts', expires_in: 1.day) do
CohortsService.new.execute
end
@cohorts = CohortsSerializer.new.represent(cohorts_results)
end
redirect_to admin_users_path(tab: 'cohorts')
end
end
......@@ -2,6 +2,7 @@
class Admin::UsersController < Admin::ApplicationController
include RoutableActions
include Analytics::UniqueVisitsHelper
before_action :user, except: [:index, :new, :create]
before_action :check_impersonation_availability, only: :impersonate
......@@ -15,6 +16,10 @@ class Admin::UsersController < Admin::ApplicationController
@users = @users.includes(:authorized_projects) # rubocop: disable CodeReuse/ActiveRecord
@users = @users.sort_by_attribute(@sort = params[:sort])
@users = @users.page(params[:page])
@cohorts = load_cohorts
track_cohorts_visit if params[:tab] == 'cohorts'
end
def show
......@@ -307,6 +312,22 @@ class Admin::UsersController < Admin::ApplicationController
def log_impersonation_event
Gitlab::AppLogger.info(_("User %{current_user_username} has started impersonating %{username}") % { current_user_username: current_user.username, username: user.username })
end
def load_cohorts
if Gitlab::CurrentSettings.usage_ping_enabled
cohorts_results = Rails.cache.fetch('cohorts', expires_in: 1.day) do
CohortsService.new.execute
end
CohortsSerializer.new.represent(cohorts_results)
end
end
def track_cohorts_visit
if request.format.html? && request.headers['DNT'] != '1'
track_visit('i_analytics_cohorts')
end
end
end
Admin::UsersController.prepend_if_ee('EE::Admin::UsersController')
......@@ -64,7 +64,7 @@ module NavHelper
end
def admin_analytics_nav_links
%w(dev_ops_report cohorts)
%w(dev_ops_report)
end
def group_issues_sub_menu_items
......
- breadcrumb_title _("Cohorts")
- page_title _("Cohorts")
- if @cohorts
= render 'cohorts_table'
- else
......
.top-area.scrolling-tabs-container.inner-page-scroll-tabs
.fade-left
= sprite_icon('chevron-lg-left', size: 12)
.fade-right
= sprite_icon('chevron-lg-right', size: 12)
%ul.nav-links.nav.nav-tabs.scrolling-tabs
= nav_link(html_options: { class: active_when(params[:filter].nil?) }) do
= link_to admin_users_path do
= s_('AdminUsers|Active')
%small.badge.badge-pill= limited_counter_with_delimiter(User.active_without_ghosts)
= nav_link(html_options: { class: active_when(params[:filter] == 'admins') }) do
= link_to admin_users_path(filter: "admins") do
= s_('AdminUsers|Admins')
%small.badge.badge-pill= limited_counter_with_delimiter(User.admins)
= nav_link(html_options: { class: "#{active_when(params[:filter] == 'two_factor_enabled')} filter-two-factor-enabled" }) do
= link_to admin_users_path(filter: 'two_factor_enabled') do
= s_('AdminUsers|2FA Enabled')
%small.badge.badge-pill= limited_counter_with_delimiter(User.with_two_factor)
= nav_link(html_options: { class: "#{active_when(params[:filter] == 'two_factor_disabled')} filter-two-factor-disabled" }) do
= link_to admin_users_path(filter: 'two_factor_disabled') do
= s_('AdminUsers|2FA Disabled')
%small.badge.badge-pill= limited_counter_with_delimiter(User.without_two_factor)
= nav_link(html_options: { class: active_when(params[:filter] == 'external') }) do
= link_to admin_users_path(filter: 'external') do
= s_('AdminUsers|External')
%small.badge.badge-pill= limited_counter_with_delimiter(User.external)
= nav_link(html_options: { class: active_when(params[:filter] == 'blocked') }) do
= link_to admin_users_path(filter: "blocked") do
= s_('AdminUsers|Blocked')
%small.badge.badge-pill= limited_counter_with_delimiter(User.blocked)
= nav_link(html_options: { class: "#{active_when(params[:filter] == 'blocked_pending_approval')} filter-blocked-pending-approval" }) do
= link_to admin_users_path(filter: "blocked_pending_approval"), data: { qa_selector: 'pending_approval_tab' } do
= s_('AdminUsers|Pending approval')
%small.badge.badge-pill= limited_counter_with_delimiter(User.blocked_pending_approval)
= nav_link(html_options: { class: active_when(params[:filter] == 'deactivated') }) do
= link_to admin_users_path(filter: "deactivated") do
= s_('AdminUsers|Deactivated')
%small.badge.badge-pill= limited_counter_with_delimiter(User.deactivated)
= nav_link(html_options: { class: active_when(params[:filter] == 'wop') }) do
= link_to admin_users_path(filter: "wop") do
= s_('AdminUsers|Without projects')
%small.badge.badge-pill= limited_counter_with_delimiter(User.without_projects)
.nav-controls
= render_if_exists 'admin/users/admin_email_users'
= render_if_exists 'admin/users/admin_export_user_permissions'
= link_to s_('AdminUsers|New user'), new_admin_user_path, class: 'btn gl-button btn-success btn-search float-right'
.filtered-search-block.row-content-block.border-top-0
= form_tag admin_users_path, method: :get do
- if params[:filter].present?
= hidden_field_tag "filter", h(params[:filter])
.search-holder
.search-field-holder.gl-mb-4
= search_field_tag :search_query, params[:search_query], placeholder: s_('AdminUsers|Search by name, email or username'), class: 'form-control search-text-input js-search-input', spellcheck: false, data: { qa_selector: 'user_search_field' }
- if @sort.present?
= hidden_field_tag :sort, @sort
= sprite_icon('search', css_class: 'search-icon')
= button_tag s_('AdminUsers|Search users') if Rails.env.test?
.dropdown.user-sort-dropdown
= label_tag 'Sort by', nil, class: 'label-bold'
- toggle_text = @sort.present? ? users_sort_options_hash[@sort] : sort_title_name
= dropdown_toggle(toggle_text, { toggle: 'dropdown' })
%ul.dropdown-menu.dropdown-menu-right
%li.dropdown-header
= s_('AdminUsers|Sort by')
%li
- users_sort_options_hash.each do |value, title|
= link_to admin_users_path(sort: value, filter: params[:filter], search_query: params[:search_query]) do
= title
- if Feature.enabled?(:vue_admin_users)
#js-admin-users-app{ data: admin_users_data_attributes(@users) }
.gl-spinner-container.gl-my-7
%span.gl-vertical-align-bottom.gl-spinner.gl-spinner-dark.gl-spinner-lg{ aria: { label: _('Loading') } }
- elsif @users.empty?
.nothing-here-block.border-top-0
= s_('AdminUsers|No users found')
- else
.table-holder
.thead-white.text-nowrap.gl-responsive-table-row.table-row-header{ role: 'row' }
.table-section.section-40{ role: 'rowheader' }= _('Name')
.table-section.section-10{ role: 'rowheader' }= _('Projects')
.table-section.section-15{ role: 'rowheader' }= _('Created on')
.table-section.section-15{ role: 'rowheader' }= _('Last activity')
= render partial: 'admin/users/user', collection: @users
= paginate @users, theme: "gitlab"
= render partial: 'admin/users/modals'
- page_title _("Users")
.top-area.scrolling-tabs-container.inner-page-scroll-tabs
.fade-left
= sprite_icon('chevron-lg-left', size: 12)
.fade-right
= sprite_icon('chevron-lg-right', size: 12)
%ul.nav-links.nav.nav-tabs.scrolling-tabs
= nav_link(html_options: { class: active_when(params[:filter].nil?) }) do
= link_to admin_users_path do
= s_('AdminUsers|Active')
%small.badge.badge-pill= limited_counter_with_delimiter(User.active_without_ghosts)
= nav_link(html_options: { class: active_when(params[:filter] == 'admins') }) do
= link_to admin_users_path(filter: "admins") do
= s_('AdminUsers|Admins')
%small.badge.badge-pill= limited_counter_with_delimiter(User.admins)
= nav_link(html_options: { class: "#{active_when(params[:filter] == 'two_factor_enabled')} filter-two-factor-enabled" }) do
= link_to admin_users_path(filter: 'two_factor_enabled') do
= s_('AdminUsers|2FA Enabled')
%small.badge.badge-pill= limited_counter_with_delimiter(User.with_two_factor)
= nav_link(html_options: { class: "#{active_when(params[:filter] == 'two_factor_disabled')} filter-two-factor-disabled" }) do
= link_to admin_users_path(filter: 'two_factor_disabled') do
= s_('AdminUsers|2FA Disabled')
%small.badge.badge-pill= limited_counter_with_delimiter(User.without_two_factor)
= nav_link(html_options: { class: active_when(params[:filter] == 'external') }) do
= link_to admin_users_path(filter: 'external') do
= s_('AdminUsers|External')
%small.badge.badge-pill= limited_counter_with_delimiter(User.external)
= nav_link(html_options: { class: active_when(params[:filter] == 'blocked') }) do
= link_to admin_users_path(filter: "blocked") do
= s_('AdminUsers|Blocked')
%small.badge.badge-pill= limited_counter_with_delimiter(User.blocked)
= nav_link(html_options: { class: "#{active_when(params[:filter] == 'blocked_pending_approval')} filter-blocked-pending-approval" }) do
= link_to admin_users_path(filter: "blocked_pending_approval"), data: { qa_selector: 'pending_approval_tab' } do
= s_('AdminUsers|Pending approval')
%small.badge.badge-pill= limited_counter_with_delimiter(User.blocked_pending_approval)
= nav_link(html_options: { class: active_when(params[:filter] == 'deactivated') }) do
= link_to admin_users_path(filter: "deactivated") do
= s_('AdminUsers|Deactivated')
%small.badge.badge-pill= limited_counter_with_delimiter(User.deactivated)
= nav_link(html_options: { class: active_when(params[:filter] == 'wop') }) do
= link_to admin_users_path(filter: "wop") do
= s_('AdminUsers|Without projects')
%small.badge.badge-pill= limited_counter_with_delimiter(User.without_projects)
.nav-controls
= render_if_exists 'admin/users/admin_email_users'
= render_if_exists 'admin/users/admin_export_user_permissions'
= link_to s_('AdminUsers|New user'), new_admin_user_path, class: 'btn gl-button btn-success btn-search float-right'
%ul.nav-links.nav-tabs.nav.js-users-tabs{ role: 'tablist' }
%li.nav-item.js-users-tab-item{ role: 'presentation' }
%a.nav-link{ href: '#users', class: active_when(params[:tab] != 'cohorts'), data: { toggle: 'tab' }, role: 'tab' }
= s_('AdminUsers|Users')
%li.nav-item.js-users-tab-item{ role: 'presentation' }
%a.nav-link{ href: '#cohorts', class: active_when(params[:tab] == 'cohorts'), data: { toggle: 'tab', track: { event: 'i_analytics_cohorts', action: 'click_tab' } }, role: 'tab' }
= s_('AdminUsers|Cohorts')
.filtered-search-block.row-content-block.border-top-0
= form_tag admin_users_path, method: :get do
- if params[:filter].present?
= hidden_field_tag "filter", h(params[:filter])
.search-holder
.search-field-holder.gl-mb-4
= search_field_tag :search_query, params[:search_query], placeholder: s_('AdminUsers|Search by name, email or username'), class: 'form-control search-text-input js-search-input', spellcheck: false, data: { qa_selector: 'user_search_field' }
- if @sort.present?
= hidden_field_tag :sort, @sort
= sprite_icon('search', css_class: 'search-icon')
= button_tag s_('AdminUsers|Search users') if Rails.env.test?
.dropdown.user-sort-dropdown
= label_tag 'Sort by', nil, class: 'label-bold'
- toggle_text = @sort.present? ? users_sort_options_hash[@sort] : sort_title_name
= dropdown_toggle(toggle_text, { toggle: 'dropdown' })
%ul.dropdown-menu.dropdown-menu-right
%li.dropdown-header
= s_('AdminUsers|Sort by')
%li
- users_sort_options_hash.each do |value, title|
= link_to admin_users_path(sort: value, filter: params[:filter], search_query: params[:search_query]) do
= title
.tab-content
.tab-pane{ id: 'users', class: ('active' if params[:tab] != 'cohorts') }
= render 'users'
.tab-pane{ id: 'cohorts', class: ('active' if params[:tab] == 'cohorts') }
= render 'cohorts'
- if Feature.enabled?(:vue_admin_users)
#js-admin-users-app{ data: admin_users_data_attributes(@users) }
.gl-spinner-container.gl-my-7
%span.gl-vertical-align-bottom.gl-spinner.gl-spinner-dark.gl-spinner-lg{ aria: { label: _('Loading') } }
- elsif @users.empty?
.nothing-here-block.border-top-0
= s_('AdminUsers|No users found')
- else
.table-holder
.thead-white.text-nowrap.gl-responsive-table-row.table-row-header{ role: 'row' }
.table-section.section-40{ role: 'rowheader' }= _('Name')
.table-section.section-10{ role: 'rowheader' }= _('Projects')
.table-section.section-15{ role: 'rowheader' }= _('Created on')
.table-section.section-15{ role: 'rowheader' }= _('Last activity')
= render partial: 'admin/users/user', collection: @users
= paginate @users, theme: "gitlab"
= render partial: 'admin/users/modals'
......@@ -65,10 +65,6 @@
= link_to admin_dev_ops_report_path, title: _('DevOps Report') do
%span
= _('DevOps Report')
= nav_link(controller: :cohorts) do
= link_to admin_cohorts_path, title: _('Cohorts') do
%span
= _('Cohorts')
- if Feature.enabled?(:instance_statistics, default_enabled: true)
= nav_link(controller: :instance_statistics) do
= link_to admin_instance_statistics_path, title: _('Usage Trends') do
......
---
title: Move Cohorts page to Overiew-Users
merge_request: 51707
author:
type: changed
......@@ -9,14 +9,14 @@ info: To determine the technical writer assigned to the Stage/Group associated w
As a benefit of having the [usage ping active](../settings/usage_statistics.md),
you can analyze your users' GitLab activities over time.
To see user cohorts, go to **Admin Area > Analytics > Cohorts**.
To see user cohorts, go to **Admin Area > Overview > Users**.
## Overview
How do you interpret the user cohorts table? Let's review an example with the
following user cohorts:
![User cohort example](img/cohorts_v13_4.png)
![User cohort example](img/cohorts_v13_9.png)
For the cohort of March 2020, three users were added to this server and have
been active since this month. One month later (April 2020), two users are still
......
......@@ -2203,6 +2203,9 @@ msgstr ""
msgid "AdminUsers|Cannot unblock LDAP blocked users"
msgstr ""
msgid "AdminUsers|Cohorts"
msgstr ""
msgid "AdminUsers|Deactivate"
msgstr ""
......@@ -2341,6 +2344,9 @@ msgstr ""
msgid "AdminUsers|User will not be able to login"
msgstr ""
msgid "AdminUsers|Users"
msgstr ""
msgid "AdminUsers|When the user logs back in, their account will reactivate as a fully active account"
msgstr ""
......@@ -7063,9 +7069,6 @@ msgstr ""
msgid "CodeOwner|Pattern"
msgstr ""
msgid "Cohorts"
msgstr ""
msgid "Cohorts|Inactive users"
msgstr ""
......
......@@ -6,7 +6,7 @@ module QA
module Overview
module Users
class Index < QA::Page::Base
view 'app/views/admin/users/index.html.haml' do
view 'app/views/admin/users/_users.html.haml' do
element :user_search_field
element :pending_approval_tab
end
......
......@@ -3,37 +3,15 @@
require 'spec_helper'
RSpec.describe Admin::CohortsController do
context 'as admin' do
let(:user) { create(:admin) }
let(:user) { create(:admin) }
before do
sign_in(user)
end
it 'renders 200' do
get :index
expect(response).to have_gitlab_http_status(:success)
end
describe 'GET #index' do
it_behaves_like 'tracking unique visits', :index do
let(:target_id) { 'i_analytics_cohorts' }
end
end
before do
sign_in(user)
end
context 'as normal user' do
let(:user) { create(:user) }
before do
sign_in(user)
end
it 'renders a 404' do
get :index
it 'redirects to Overview->Users' do
get :index
expect(response).to have_gitlab_http_status(:not_found)
end
expect(response).to redirect_to(admin_users_path(tab: 'cohorts'))
end
end
......@@ -29,6 +29,11 @@ RSpec.describe Admin::UsersController do
expect(assigns(:users).first.association(:authorized_projects)).to be_loaded
end
it_behaves_like 'tracking unique visits', :index do
let(:target_id) { 'i_analytics_cohorts' }
let(:request_params) { { tab: 'cohorts' } }
end
end
describe 'GET :id' do
......
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe 'Cohorts page' do
before do
admin = create(:admin)
sign_in(admin)
gitlab_enable_admin_mode_sign_in(admin)
end
context 'with usage ping enabled' do
it 'shows users count per month' do
stub_application_setting(usage_ping_enabled: true)
create_list(:user, 2)
visit admin_cohorts_path
expect(page).to have_content("#{Time.now.strftime('%b %Y')} 3 0")
end
end
context 'with usage ping disabled' do
it 'shows empty state', :js do
stub_application_setting(usage_ping_enabled: false)
visit admin_cohorts_path
expect(page).to have_selector(".js-empty-state")
end
end
end
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe "Admin::Users" do
let(:current_user) { create(:admin) }
before do
sign_in(current_user)
gitlab_enable_admin_mode_sign_in(current_user)
end
describe 'Tabs', :js do
let(:tabs_selector) { '.js-users-tabs' }
let(:active_tab_selector) { '.nav-link.active' }
it 'does not add the tab param when the Users tab is selected' do
visit admin_users_path
within tabs_selector do
click_link 'Users'
end
expect(page).to have_current_path(admin_users_path)
end
it 'adds the ?tab=cohorts param when the Cohorts tab is selected' do
visit admin_users_path
within tabs_selector do
click_link 'Cohorts'
end
expect(page).to have_current_path(admin_users_path(tab: 'cohorts'))
end
it 'shows the cohorts tab when the tab param is set' do
visit admin_users_path(tab: 'cohorts')
within tabs_selector do
expect(page).to have_selector active_tab_selector, text: 'Cohorts'
end
end
end
describe 'Cohorts tab content' do
context 'with usage ping enabled' do
it 'shows users count per month' do
stub_application_setting(usage_ping_enabled: true)
create_list(:user, 2)
visit admin_users_path(tab: 'cohorts')
expect(page).to have_content("#{Time.now.strftime('%b %Y')} 3 0")
end
end
context 'with usage ping disabled' do
it 'shows empty state', :js do
stub_application_setting(usage_ping_enabled: false)
visit admin_users_path(tab: 'cohorts')
expect(page).to have_selector(".js-empty-state")
expect(page).to have_content("Activate user activity analysis")
end
end
end
end
import { createWrapper } from '@vue/test-utils';
import initAdminUsers from '~/admin/users';
import { initAdminUsersApp } from '~/admin/users';
import AdminUsersApp from '~/admin/users/components/app.vue';
import { users, paths } from './mock_data';
......@@ -16,7 +16,7 @@ describe('initAdminUsersApp', () => {
document.body.appendChild(el);
wrapper = createWrapper(initAdminUsers(el));
wrapper = createWrapper(initAdminUsersApp(el));
});
afterEach(() => {
......
......@@ -141,13 +141,6 @@ RSpec.describe Admin::DevOpsReportController, "routing" do
end
end
# admin_cohorts GET /admin/cohorts(.:format) admin/cohorst#index
RSpec.describe Admin::CohortsController, "routing" do
it "to #index" do
expect(get("/admin/cohorts")).to route_to('admin/cohorts#index')
end
end
RSpec.describe Admin::GroupsController, "routing" do
let(:name) { 'complex.group-namegit' }
......
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