Commit 90d412aa authored by Sheldon Led's avatar Sheldon Led Committed by Mayra Cabrera

Move `seats usage` from billing to usage quotas

parent 1414d99d
......@@ -94,6 +94,8 @@ The following table describes details of your subscription:
To view a list of seats being used, go to **Settings > Billing**.
Under **Seats currently in use**, select **See usage**.
You can also see this information in your group settings by going to **Menu > Groups > Your Group > Settings > Usage Quotas**, and the information about **Seat usage** will be under the **Seats** tab.
The **Seat usage** page lists all users occupying seats. Details for each user include:
- Full name
......
......@@ -2,11 +2,6 @@ export const TABLE_TYPE_DEFAULT = 'default';
export const TABLE_TYPE_FREE = 'free';
export const TABLE_TYPE_TRIAL = 'trial';
// Billable Seats HTTP headers
export const HEADER_TOTAL_ENTRIES = 'x-total';
export const HEADER_PAGE_NUMBER = 'x-page';
export const HEADER_ITEMS_PER_PAGE = 'x-per-page';
export const DAYS_FOR_RENEWAL = 15;
export const PLAN_TITLE_TRIAL_TEXT = ' Trial';
import initSeatUsage from 'ee/billings/seat_usage';
initSeatUsage();
import otherStorageCounter from 'ee/other_storage_counter';
import SeatUsageApp from 'ee/seat_usage';
import storageCounter from 'ee/storage_counter';
import LinkedTabs from '~/lib/utils/bootstrap_linked_tabs';
import initSearchSettings from '~/search_settings';
if (document.querySelector('#js-storage-counter-app')) {
storageCounter();
const initLinkedTabs = () => {
if (!document.querySelector('.js-storage-tabs')) {
return false;
}
// eslint-disable-next-line no-new
new LinkedTabs({
defaultAction: '#pipelines-quota-tab',
return new LinkedTabs({
defaultAction: '#seats-quota-tab',
parentEl: '.js-storage-tabs',
hashedTabs: true,
});
}
};
if (document.querySelector('#js-other-storage-counter-app')) {
otherStorageCounter();
const initVueApps = () => {
if (document.querySelector('#js-seat-usage-app')) {
SeatUsageApp();
}
// eslint-disable-next-line no-new
new LinkedTabs({
defaultAction: '#pipelines-quota-tab',
parentEl: '.js-other-storage-tabs',
hashedTabs: true,
});
}
if (document.querySelector('#js-storage-counter-app')) {
storageCounter();
}
if (document.querySelector('#js-other-storage-counter-app')) {
otherStorageCounter();
}
};
initVueApps();
initLinkedTabs();
initSearchSettings();
......@@ -10,7 +10,7 @@ import { mapActions, mapState } from 'vuex';
import {
REMOVE_BILLABLE_MEMBER_MODAL_ID,
REMOVE_BILLABLE_MEMBER_MODAL_CONTENT_TEXT_TEMPLATE,
} from 'ee/billings/seat_usage/constants';
} from 'ee/seat_usage/constants';
import csrf from '~/lib/utils/csrf';
import { __, s__, sprintf } from '~/locale';
......
......@@ -22,7 +22,7 @@ import {
CANNOT_REMOVE_BILLABLE_MEMBER_MODAL_TITLE,
CANNOT_REMOVE_BILLABLE_MEMBER_MODAL_CONTENT,
SORT_OPTIONS,
} from 'ee/billings/seat_usage/constants';
} from 'ee/seat_usage/constants';
import { s__, __ } from '~/locale';
import FilterSortContainerRoot from '~/vue_shared/components/filtered_search_bar/filtered_search_bar_root.vue';
import RemoveBillableMemberModal from './remove_billable_member_modal.vue';
......@@ -99,9 +99,6 @@ export default {
}, '');
this.setSearchQuery(searchQuery.trim() || null);
},
handleSortOptionChange(sortOption) {
this.setSortOption(sortOption);
},
displayRemoveMemberModal(user) {
if (user.removable) {
this.setBillableMemberToRemove(user);
......@@ -161,7 +158,7 @@ export default {
:sort-options="$options.sortOptions"
initial-sort-by="last_activity_on_desc"
@onFilter="applyFilter"
@onSort="handleSortOptionChange"
@onSort="setSortOption"
/>
</div>
......
......@@ -2,6 +2,11 @@ import { DEFAULT_DEBOUNCE_AND_THROTTLE_MS } from '~/lib/utils/constants';
import { thWidthClass } from '~/lib/utils/table_utility';
import { __, s__ } from '~/locale';
// Billable Seats HTTP headers
export const HEADER_TOTAL_ENTRIES = 'x-total';
export const HEADER_PAGE_NUMBER = 'x-page';
export const HEADER_ITEMS_PER_PAGE = 'x-per-page';
export const FIELDS = [
{
key: 'user',
......
......@@ -5,17 +5,18 @@ import initialStore from './store';
Vue.use(Vuex);
export default (containerId = 'js-seat-usage') => {
const containerEl = document.getElementById(containerId);
export default (containerId = 'js-seat-usage-app') => {
const el = document.getElementById(containerId);
if (!containerEl) {
if (!el) {
return false;
}
const { namespaceId, namespaceName, seatUsageExportPath } = containerEl.dataset;
const { namespaceId, namespaceName, seatUsageExportPath } = el.dataset;
return new Vue({
el: containerEl,
el,
apolloProvider: {},
store: new Vuex.Store(initialStore({ namespaceId, namespaceName, seatUsageExportPath })),
render(createElement) {
return createElement(SubscriptionSeats);
......
......@@ -3,7 +3,7 @@ import {
HEADER_TOTAL_ENTRIES,
HEADER_PAGE_NUMBER,
HEADER_ITEMS_PER_PAGE,
} from 'ee/billings/constants';
} from 'ee/seat_usage/constants';
import * as types from './mutation_types';
export default {
......
......@@ -12,6 +12,7 @@ class Groups::SeatUsageController < Groups::ApplicationController
def show
respond_to do |format|
format.html do
redirect_to_seat_usage_tab
end
format.csv do
......@@ -24,7 +25,7 @@ class Groups::SeatUsageController < Groups::ApplicationController
else
flash[:alert] = _('Failed to generate export, please try again later.')
redirect_to group_seat_usage_path(group)
redirect_to_seat_usage_tab
end
end
end
......@@ -32,6 +33,10 @@ class Groups::SeatUsageController < Groups::ApplicationController
private
def redirect_to_seat_usage_tab
redirect_to group_usage_quotas_path(group, anchor: 'seats-quota-tab')
end
def csv_filename
"seat-usage-export-#{Time.current.to_s(:number)}.csv"
end
......
......@@ -172,7 +172,7 @@ module BillingPlansHelper
def billable_seats_href(namespace)
return unless namespace.group?
group_seat_usage_path(namespace)
group_usage_quotas_path(namespace, anchor: 'seats-quota-tab')
end
def offer_from_previous_tier?(namespace_id, plan_id)
......
- page_title s_('SeatUsage|Seat usage')
- add_to_breadcrumbs _('Billing'), group_billings_path(@group)
#js-seat-usage{ data: { namespace_id: @group.id, namespace_name: @group.name, seat_usage_export_path: group_seat_usage_path(@group, format: :csv) } }
......@@ -15,7 +15,10 @@
.top-area.scrolling-tabs-container.inner-page-scroll-tabs
%ul.nav.nav-tabs.nav-links.scrolling-tabs.separator.js-storage-tabs{ role: 'tablist' }
%li.nav-item
%a.nav-link#pipelines-quota{ data: { toggle: "tab", action: '#pipelines-quota-tab' }, href: '#pipelines-quota-tab', 'aria-controls': '#pipelines-quota-tab', 'aria-selected': true }
%a.nav-link#seats-quota{ data: { toggle: "tab", action: '#seats-quota-tab' }, href: '#seats-quota-tab', 'aria-controls': '#seats-quota-tab', 'aria-selected': true }
= s_('UsageQuota|Seats')
%li.nav-item
%a.nav-link#pipelines-quota{ data: { toggle: "tab", action: '#pipelines-quota-tab' }, href: '#pipelines-quota-tab', 'aria-controls': '#pipelines-quota-tab', 'aria-selected': false }
= s_('UsageQuota|Pipelines')
%li.nav-item
%a.nav-link#storage-quota{ data: { toggle: "tab", action: '#storage-quota-tab' }, href: '#storage-quota-tab', 'aria-controls': '#storage-quota-tab', 'aria-selected': false }
......@@ -25,6 +28,8 @@
%a.nav-link#storage-quota{ data: { toggle: "tab", action: '#other-storage-quota-tab' }, href: '#other-storage-quota-tab', 'aria-controls': '#other-storage-quota-tab', 'aria-selected': false }
= s_('UsageQuota|Other Storage')
.tab-content
.tab-pane#seats-quota-tab
#js-seat-usage-app{ data: { namespace_id: @group.id, namespace_name: @group.name, seat_usage_export_path: group_seat_usage_path(@group, format: :csv) } }
.tab-pane#pipelines-quota-tab
= render "namespaces/pipelines_quota/list",
locals: { namespace: @group, projects: @projects }
......
......@@ -24,11 +24,10 @@ RSpec.describe Groups::SeatUsageController do
end
context 'when html format' do
it 'renders show with 200 status code' do
it 'redirects to /groups/%{group_id}/-/seat_usage' do
get_show
expect(response).to have_gitlab_http_status(:ok)
expect(response).to render_template(:show)
expect(response).to redirect_to(group_usage_quotas_path(group, anchor: 'seats-quota-tab'))
end
it 'responds with 404 Not Found if the group is not top-level group' do
......@@ -81,7 +80,7 @@ RSpec.describe Groups::SeatUsageController do
get_show(format: :csv)
expect(flash[:alert]).to eq 'Failed to generate export, please try again later.'
expect(response).to redirect_to(group_seat_usage_path(group))
expect(response).to redirect_to(group_usage_quotas_path(group, anchor: 'seats-quota-tab'))
end
end
end
......
......@@ -88,7 +88,7 @@ RSpec.describe 'Groups > Billing', :js do
expect(page).to have_link("Manage", href: "#{EE::SUBSCRIPTIONS_URL}/subscriptions")
expect(page).to have_link("Add seats", href: extra_seats_url)
expect(page).to have_link("Renew", href: renew_url)
expect(page).to have_link("See usage", href: group_seat_usage_path(group))
expect(page).to have_link("See usage", href: group_usage_quotas_path(group, anchor: 'seats-quota-tab'))
end
end
......
......@@ -2,7 +2,7 @@
require 'spec_helper'
RSpec.describe 'Groups > Billing > Seat Usage', :js do
RSpec.describe 'Groups > Usage Quotas > Seat Usage', :js do
let_it_be(:user) { create(:user) }
let_it_be(:group) { create(:group) }
let_it_be(:sub_group) { create(:group, parent: group) }
......
import {
HEADER_TOTAL_ENTRIES,
HEADER_PAGE_NUMBER,
HEADER_ITEMS_PER_PAGE,
} from 'ee/billings/constants';
export const mockDataSubscription = {
gold: {
plan: {
......@@ -64,123 +58,3 @@ export const mockDataSubscription = {
},
},
};
export const mockDataSeats = {
data: [
{
id: 2,
name: 'Administrator',
username: 'root',
avatar_url: 'path/to/img_administrator',
web_url: 'path/to/administrator',
email: 'administrator@email.com',
last_activity_on: '2020-03-01',
membership_type: 'group_member',
removable: true,
},
{
id: 3,
name: 'Agustin Walker',
username: 'lester.orn',
avatar_url: 'path/to/img_agustin_walker',
web_url: 'path/to/agustin_walker',
email: 'agustin_walker@email.com',
last_activity_on: '2020-03-01',
membership_type: 'group_member',
removable: true,
},
{
id: 4,
name: 'Joella Miller',
username: 'era',
avatar_url: 'path/to/img_joella_miller',
web_url: 'path/to/joella_miller',
last_activity_on: null,
email: null,
membership_type: 'group_invite',
removable: false,
},
{
id: 5,
name: 'John Doe',
username: 'jdoe',
avatar_url: 'path/to/img_john_doe',
web_url: 'path/to/john_doe',
last_activity_on: null,
email: 'jdoe@email.com',
membership_type: 'project_invite',
removable: false,
},
],
headers: {
[HEADER_TOTAL_ENTRIES]: '3',
[HEADER_PAGE_NUMBER]: '1',
[HEADER_ITEMS_PER_PAGE]: '1',
},
};
export const mockMemberDetails = [
{
id: 173,
source_id: 155,
source_full_name: 'group_with_ultimate_plan / subgroup',
created_at: '2021-02-25T08:21:32.257Z',
expires_at: null,
access_level: { string_value: 'Owner', integer_value: 50 },
},
];
export const mockTableItems = [
{
email: 'administrator@email.com',
user: {
id: 2,
avatar_url: 'path/to/img_administrator',
name: 'Administrator',
username: '@root',
web_url: 'path/to/administrator',
last_activity_on: '2020-03-01',
membership_type: 'group_member',
removable: true,
},
},
{
email: 'agustin_walker@email.com',
user: {
id: 3,
avatar_url: 'path/to/img_agustin_walker',
name: 'Agustin Walker',
username: '@lester.orn',
web_url: 'path/to/agustin_walker',
last_activity_on: '2020-03-01',
membership_type: 'group_member',
removable: true,
},
},
{
email: null,
user: {
id: 4,
avatar_url: 'path/to/img_joella_miller',
name: 'Joella Miller',
username: '@era',
web_url: 'path/to/joella_miller',
last_activity_on: null,
membership_type: 'group_invite',
removable: false,
},
},
{
email: 'jdoe@email.com',
user: {
id: 5,
avatar_url: 'path/to/img_john_doe',
name: 'John Doe',
username: '@jdoe',
web_url: 'path/to/john_doe',
last_activity_on: null,
membership_type: 'project_invite',
removable: false,
},
},
];
......@@ -2,7 +2,7 @@ import { GlSprintf } from '@gitlab/ui';
import { shallowMount, createLocalVue } from '@vue/test-utils';
import { nextTick } from 'vue';
import Vuex from 'vuex';
import RemoveBillableMemberModal from 'ee/billings/seat_usage/components/remove_billable_member_modal.vue';
import RemoveBillableMemberModal from 'ee/seat_usage/components/remove_billable_member_modal.vue';
const localVue = createLocalVue();
localVue.use(Vuex);
......
......@@ -2,11 +2,11 @@ import { GlTable } from '@gitlab/ui';
import { shallowMount, createLocalVue } from '@vue/test-utils';
import Vuex from 'vuex';
import Api from 'ee/api';
import SubscriptionSeatDetails from 'ee/billings/seat_usage/components/subscription_seat_details.vue';
import SubscriptionSeatDetailsLoader from 'ee/billings/seat_usage/components/subscription_seat_details_loader.vue';
import createStore from 'ee/billings/seat_usage/store';
import initState from 'ee/billings/seat_usage/store/state';
import { mockMemberDetails } from 'ee_jest/billings/mock_data';
import SubscriptionSeatDetails from 'ee/seat_usage/components/subscription_seat_details.vue';
import SubscriptionSeatDetailsLoader from 'ee/seat_usage/components/subscription_seat_details_loader.vue';
import createStore from 'ee/seat_usage/store';
import initState from 'ee/seat_usage/store/state';
import { mockMemberDetails } from 'ee_jest/seat_usage/mock_data';
import { stubComponent } from 'helpers/stub_component';
const localVue = createLocalVue();
......
......@@ -9,9 +9,9 @@ import {
} from '@gitlab/ui';
import { mount, shallowMount, createLocalVue } from '@vue/test-utils';
import Vuex from 'vuex';
import SubscriptionSeats from 'ee/billings/seat_usage/components/subscription_seats.vue';
import { CANNOT_REMOVE_BILLABLE_MEMBER_MODAL_CONTENT } from 'ee/billings/seat_usage/constants';
import { mockDataSeats, mockTableItems } from 'ee_jest/billings/mock_data';
import SubscriptionSeats from 'ee/seat_usage/components/subscription_seats.vue';
import { CANNOT_REMOVE_BILLABLE_MEMBER_MODAL_CONTENT } from 'ee/seat_usage/constants';
import { mockDataSeats, mockTableItems } from 'ee_jest/seat_usage/mock_data';
import { extendedWrapper } from 'helpers/vue_test_utils_helper';
import FilterSortContainerRoot from '~/vue_shared/components/filtered_search_bar/filtered_search_bar_root.vue';
......
import {
HEADER_TOTAL_ENTRIES,
HEADER_PAGE_NUMBER,
HEADER_ITEMS_PER_PAGE,
} from 'ee/seat_usage/constants';
export const mockDataSeats = {
data: [
{
id: 2,
name: 'Administrator',
username: 'root',
avatar_url: 'path/to/img_administrator',
web_url: 'path/to/administrator',
email: 'administrator@email.com',
last_activity_on: '2020-03-01',
membership_type: 'group_member',
removable: true,
},
{
id: 3,
name: 'Agustin Walker',
username: 'lester.orn',
avatar_url: 'path/to/img_agustin_walker',
web_url: 'path/to/agustin_walker',
email: 'agustin_walker@email.com',
last_activity_on: '2020-03-01',
membership_type: 'group_member',
removable: true,
},
{
id: 4,
name: 'Joella Miller',
username: 'era',
avatar_url: 'path/to/img_joella_miller',
web_url: 'path/to/joella_miller',
last_activity_on: null,
email: null,
membership_type: 'group_invite',
removable: false,
},
{
id: 5,
name: 'John Doe',
username: 'jdoe',
avatar_url: 'path/to/img_john_doe',
web_url: 'path/to/john_doe',
last_activity_on: null,
email: 'jdoe@email.com',
membership_type: 'project_invite',
removable: false,
},
],
headers: {
[HEADER_TOTAL_ENTRIES]: '3',
[HEADER_PAGE_NUMBER]: '1',
[HEADER_ITEMS_PER_PAGE]: '1',
},
};
export const mockMemberDetails = [
{
id: 173,
source_id: 155,
source_full_name: 'group_with_ultimate_plan / subgroup',
created_at: '2021-02-25T08:21:32.257Z',
expires_at: null,
access_level: { string_value: 'Owner', integer_value: 50 },
},
];
export const mockTableItems = [
{
email: 'administrator@email.com',
user: {
id: 2,
avatar_url: 'path/to/img_administrator',
name: 'Administrator',
username: '@root',
web_url: 'path/to/administrator',
last_activity_on: '2020-03-01',
membership_type: 'group_member',
removable: true,
},
},
{
email: 'agustin_walker@email.com',
user: {
id: 3,
avatar_url: 'path/to/img_agustin_walker',
name: 'Agustin Walker',
username: '@lester.orn',
web_url: 'path/to/agustin_walker',
last_activity_on: '2020-03-01',
membership_type: 'group_member',
removable: true,
},
},
{
email: null,
user: {
id: 4,
avatar_url: 'path/to/img_joella_miller',
name: 'Joella Miller',
username: '@era',
web_url: 'path/to/joella_miller',
last_activity_on: null,
membership_type: 'group_invite',
removable: false,
},
},
{
email: 'jdoe@email.com',
user: {
id: 5,
avatar_url: 'path/to/img_john_doe',
name: 'John Doe',
username: '@jdoe',
web_url: 'path/to/john_doe',
last_activity_on: null,
membership_type: 'project_invite',
removable: false,
},
},
];
import MockAdapter from 'axios-mock-adapter';
import * as GroupsApi from 'ee/api/groups_api';
import * as actions from 'ee/billings/seat_usage/store/actions';
import * as types from 'ee/billings/seat_usage/store/mutation_types';
import State from 'ee/billings/seat_usage/store/state';
import { mockDataSeats, mockMemberDetails } from 'ee_jest/billings/mock_data';
import * as actions from 'ee/seat_usage/store/actions';
import * as types from 'ee/seat_usage/store/mutation_types';
import State from 'ee/seat_usage/store/state';
import { mockDataSeats, mockMemberDetails } from 'ee_jest/seat_usage/mock_data';
import testAction from 'helpers/vuex_action_helper';
import createFlash, { FLASH_TYPES } from '~/flash';
import axios from '~/lib/utils/axios_utils';
......
import * as getters from 'ee/billings/seat_usage/store/getters';
import State from 'ee/billings/seat_usage/store/state';
import { mockDataSeats, mockTableItems, mockMemberDetails } from 'ee_jest/billings/mock_data';
import * as getters from 'ee/seat_usage/store/getters';
import State from 'ee/seat_usage/store/state';
import { mockDataSeats, mockTableItems, mockMemberDetails } from 'ee_jest/seat_usage/mock_data';
describe('Seat usage table getters', () => {
let state;
......
import * as types from 'ee/billings/seat_usage/store/mutation_types';
import mutations from 'ee/billings/seat_usage/store/mutations';
import createState from 'ee/billings/seat_usage/store/state';
import { mockDataSeats, mockMemberDetails } from 'ee_jest/billings/mock_data';
import * as types from 'ee/seat_usage/store/mutation_types';
import mutations from 'ee/seat_usage/store/mutations';
import createState from 'ee/seat_usage/store/state';
import { mockDataSeats, mockMemberDetails } from 'ee_jest/seat_usage/mock_data';
describe('EE billings seats module mutations', () => {
describe('EE seats module mutations', () => {
let state;
beforeEach(() => {
......
......@@ -10,7 +10,7 @@ RSpec.describe BillingPlansHelper do
let(:customer_portal_url) { "#{EE::SUBSCRIPTIONS_URL}/subscriptions" }
let(:add_seats_href) { "#{EE::SUBSCRIPTIONS_URL}/gitlab/namespaces/#{group.id}/extra_seats" }
let(:plan_renew_href) { "#{EE::SUBSCRIPTIONS_URL}/gitlab/namespaces/#{group.id}/renew" }
let(:billable_seats_href) { helper.group_seat_usage_path(group) }
let(:billable_seats_href) { helper.group_usage_quotas_path(group, anchor: 'seats-quota-tab') }
let(:refresh_seats_href) { helper.refresh_seats_group_billings_url(group) }
let(:plan) do
......@@ -132,7 +132,7 @@ RSpec.describe BillingPlansHelper do
let(:namespace) { build(:namespace) }
it 'does not return billable_seats_href' do
expect(subject).not_to include(billable_seats_href: helper.group_seat_usage_path(namespace))
expect(subject).not_to include(billable_seats_href: helper.group_usage_quotas_path(namespace, anchor: 'seats-quota-tab'))
end
end
......@@ -140,7 +140,7 @@ RSpec.describe BillingPlansHelper do
let(:namespace) { build(:group) }
it 'returns billable_seats_href for group' do
expect(subject).to include(billable_seats_href: helper.group_seat_usage_path(namespace))
expect(subject).to include(billable_seats_href: helper.group_usage_quotas_path(namespace, anchor: 'seats-quota-tab'))
end
end
end
......
......@@ -29349,9 +29349,6 @@ msgstr[1] ""
msgid "Searching by both author and message is currently not supported."
msgstr ""
msgid "SeatUsage|Seat usage"
msgstr ""
msgid "Seats usage data as of %{last_enqueue_time} (Updated daily)"
msgstr ""
......@@ -35950,6 +35947,9 @@ msgstr ""
msgid "UsageQuota|Repository"
msgstr ""
msgid "UsageQuota|Seats"
msgstr ""
msgid "UsageQuota|Snippets"
msgstr ""
......
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