Commit 02f9edfb authored by Jose Ivan Vargas's avatar Jose Ivan Vargas

Merge branch 'usage-quotas-table-without-details-row' into 'master'

Update usage quotas table

See merge request gitlab-org/gitlab!43644
parents c2bf084d fd387da1
<script>
import { GlLink, GlSprintf, GlModalDirective, GlButton, GlIcon } from '@gitlab/ui';
import Project from './project.vue';
import ProjectsTable from './projects_table.vue';
import UsageGraph from './usage_graph.vue';
import query from '../queries/storage.query.graphql';
import TemporaryStorageIncreaseModal from './temporary_storage_increase_modal.vue';
......@@ -8,8 +8,9 @@ import { numberToHumanSize } from '~/lib/utils/number_utils';
import { parseBoolean } from '~/lib/utils/common_utils';
export default {
name: 'StorageCounterApp',
components: {
Project,
ProjectsTable,
GlLink,
GlButton,
GlSprintf,
......@@ -71,6 +72,9 @@ export default {
};
},
computed: {
namespaceProjects() {
return this.namespace?.projects ?? [];
},
isStorageIncreaseModalVisible() {
return parseBoolean(this.isTemporaryStorageIncreaseVisible);
},
......@@ -142,21 +146,7 @@ export default {
</div>
</div>
</div>
<div class="ci-table" role="grid">
<div
class="gl-responsive-table-row table-row-header gl-pl-3 gl-border-t-solid gl-border-t-1 gl-border-gray-100 gl-mt-5 gl-line-height-normal gl-text-black-normal gl-font-base"
role="row"
>
<div class="table-section section-70 gl-font-weight-bold" role="columnheader">
{{ __('Project') }}
</div>
<div class="table-section section-30 gl-font-weight-bold" role="columnheader">
{{ __('Usage') }}
</div>
</div>
<project v-for="project in namespace.projects" :key="project.id" :project="project" />
</div>
<projects-table :projects="namespaceProjects" />
<temporary-storage-increase-modal
v-if="isStorageIncreaseModalVisible"
:limit="formatSize(namespace.limit)"
......
<script>
/**
* project_with_excess_storage.vue component is rendered behind
* `additional_repo_storage_by_namespace` feature flag. The component
* looks similar to project.vue component so that once the flag is
* lifted this component could replace and be used mainstream.
*/
import { GlLink, GlIcon } from '@gitlab/ui';
import ProjectAvatar from '~/vue_shared/components/project_avatar/default.vue';
import { numberToHumanSize } from '~/lib/utils/number_utils';
import { getIdFromGraphQLId } from '~/graphql_shared/utils';
export default {
components: {
GlIcon,
GlLink,
ProjectAvatar,
},
props: {
project: {
required: true,
type: Object,
},
},
computed: {
projectAvatar() {
const { name, id, avatarUrl, webUrl } = this.project;
return {
name,
id: Number(getIdFromGraphQLId(id)),
avatar_url: avatarUrl,
path: webUrl,
};
},
name() {
return this.project.nameWithNamespace;
},
storageSize() {
return numberToHumanSize(this.project.statistics.storageSize);
},
excessStorageSize() {
return numberToHumanSize(this.project.statistics?.excessStorageSize ?? 0);
},
hasError() {
// The project default limit will be sent by backend.
// This is being added here just for testing purposes.
// This entire component is rendered behind the
// additional_repo_storage_by_namespace feature flag. This
// piece will be removed along with the flag.
const PROJECT_DEFAULT_LIMIT = 10000000000;
const projectLimit = this.project.statistics?.projectLimit ?? PROJECT_DEFAULT_LIMIT;
return this.project.statistics.storageSize > projectLimit;
},
},
};
</script>
<template>
<div
class="gl-responsive-table-row gl-border-solid gl-border-b-1 gl-pt-3 gl-pb-3 gl-border-b-gray-100"
:class="{ 'gl-bg-red-50': hasError }"
role="row"
data-testid="projectTableRow"
>
<div
class="table-section gl-white-space-normal! gl-flex-sm-wrap section-50 gl-text-truncate"
role="gridcell"
>
<div class="table-mobile-header gl-font-weight-bold" role="rowheader">
{{ __('Project') }}
</div>
<div class="table-mobile-content gl-display-flex gl-align-items-center">
<div class="gl-display-flex gl-mr-3 gl-ml-5 gl-align-items-center">
<gl-icon name="bookmark" />
</div>
<div>
<project-avatar :project="projectAvatar" :size="32" />
</div>
<div v-if="hasError">
<gl-icon name="status_warning" class="gl-text-red-500 gl-mr-3" />
</div>
<gl-link
:href="project.webUrl"
class="gl-font-weight-bold gl-text-gray-900!"
:class="{ 'gl-text-red-500!': hasError }"
>{{ name }}</gl-link
>
</div>
</div>
<div
class="table-section gl-white-space-normal! gl-flex-sm-wrap section-25 gl-text-truncate"
role="gridcell"
>
<div class="table-mobile-header gl-font-weight-bold" role="rowheader">
{{ __('Usage') }}
</div>
<div class="table-mobile-content gl-text-gray-900">{{ storageSize }}</div>
</div>
<div
class="table-section gl-white-space-normal! gl-flex-sm-wrap section-25 gl-text-truncate"
role="gridcell"
>
<div class="table-mobile-header gl-font-weight-bold" role="rowheader">
{{ __('Excess storage') }}
</div>
<div class="table-mobile-content gl-text-gray-900">{{ excessStorageSize }}</div>
</div>
</div>
</template>
<script>
import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import Project from './project.vue';
import ProjectWithExcessStorage from './project_with_excess_storage.vue';
export default {
components: {
Project,
ProjectWithExcessStorage,
},
mixins: [glFeatureFlagsMixin()],
props: {
projects: {
type: Array,
required: true,
},
},
computed: {
isAdditionalStorageFlagEnabled() {
return this.glFeatures.additionalRepoStorageByNamespace;
},
projectRowComponent() {
if (this.isAdditionalStorageFlagEnabled) {
return ProjectWithExcessStorage;
}
return Project;
},
},
};
</script>
<template>
<div>
<div
class="gl-responsive-table-row table-row-header gl-pl-5 gl-border-t-solid gl-border-t-1 gl-border-gray-100 gl-mt-5 gl-line-height-normal gl-text-black-normal gl-font-base"
role="row"
>
<template v-if="isAdditionalStorageFlagEnabled">
<div class="table-section section-50 gl-font-weight-bold" role="columnheader">
{{ __('Project') }}
</div>
<div class="table-section section-25 gl-font-weight-bold" role="columnheader">
{{ __('Usage') }}
</div>
<div class="table-section section-25 gl-font-weight-bold" role="columnheader">
{{ __('Excess storage') }}
</div>
</template>
<template v-else>
<div class="table-section section-70 gl-font-weight-bold" role="columnheader">
{{ __('Project') }}
</div>
<div class="table-section section-30 gl-font-weight-bold" role="columnheader">
{{ __('Usage') }}
</div>
</template>
</div>
<component
:is="projectRowComponent"
v-for="project in projects"
:key="project.id"
:project="project"
/>
</div>
</template>
......@@ -3,6 +3,9 @@
class Groups::UsageQuotasController < Groups::ApplicationController
before_action :authorize_admin_group!
before_action :verify_usage_quotas_enabled!
before_action do
push_frontend_feature_flag(:additional_repo_storage_by_namespace, @group)
end
layout 'group_settings'
......
# frozen_string_literal: true
class Profiles::UsageQuotasController < Profiles::ApplicationController
before_action do
push_frontend_feature_flag(:additional_repo_storage_by_namespace, @group)
end
def index
@namespace = current_user.namespace
@projects = @namespace.projects.with_shared_runners_limit_enabled.page(params[:page])
......
......@@ -9,12 +9,35 @@ RSpec.describe 'Groups > Usage Quotas' do
let(:gitlab_dot_com) { true }
before do
stub_feature_flags(additional_repo_storage_by_namespace: true)
allow(Gitlab).to receive(:com?).and_return(gitlab_dot_com)
group.add_owner(user)
sign_in(user)
end
it 'pushes frontend feature flags' do
visit visit_pipeline_quota_page
expect(page).to have_pushed_frontend_feature_flags(
additionalRepoStorageByNamespace: true
)
end
context 'when `additional_repo_storage_by_namespace` is disabled for a group' do
before do
stub_feature_flags(additional_repo_storage_by_namespace: false, thing: group)
end
it 'pushes disabled feature flag to the frontend' do
visit visit_pipeline_quota_page
expect(page).to have_pushed_frontend_feature_flags(
additionalRepoStorageByNamespace: false
)
end
end
shared_examples 'linked in group settings dropdown' do
it 'is linked within the group settings dropdown' do
visit edit_group_path(group)
......
......@@ -12,9 +12,32 @@ RSpec.describe 'Profile > Usage Quota' do
let_it_be(:other_project) { create(:project, namespace: namespace, shared_runners_enabled: false) }
before do
stub_feature_flags(additional_repo_storage_by_namespace: true)
gitlab_sign_in(user)
end
it 'pushes frontend feature flags' do
visit profile_usage_quotas_path
expect(page).to have_pushed_frontend_feature_flags(
additionalRepoStorageByNamespace: true
)
end
context 'when `additional_repo_storage_by_namespace` is disabled for a namespace' do
before do
stub_feature_flags(additional_repo_storage_by_namespace: false, thing: namespace)
end
it 'pushes disabled feature flag to the frontend' do
visit profile_usage_quotas_path
expect(page).to have_pushed_frontend_feature_flags(
additionalRepoStorageByNamespace: false
)
end
end
it 'is linked within the profile page' do
visit profile_path
......
......@@ -3,7 +3,7 @@ import StorageApp from 'ee/storage_counter/components/app.vue';
import Project from 'ee/storage_counter/components/project.vue';
import TemporaryStorageIncreaseModal from 'ee/storage_counter/components/temporary_storage_increase_modal.vue';
import { createMockDirective, getBinding } from 'helpers/vue_mock_directive';
import { projects, withRootStorageStatistics } from '../data';
import { namespaceData, withRootStorageStatistics } from '../mock_data';
import { numberToHumanSize } from '~/lib/utils/number_utils';
const TEST_LIMIT = 1000;
......@@ -44,28 +44,28 @@ describe('Storage counter app', () => {
it('renders the 2 projects', async () => {
wrapper.setData({
namespace: projects,
namespace: namespaceData,
});
await wrapper.vm.$nextTick();
expect(wrapper.findAll(Project)).toHaveLength(2);
expect(wrapper.findAll(Project)).toHaveLength(3);
});
describe('limit', () => {
it('when limit is set it renders limit information', async () => {
wrapper.setData({
namespace: projects,
namespace: namespaceData,
});
await wrapper.vm.$nextTick();
expect(wrapper.text()).toContain(numberToHumanSize(projects.limit));
expect(wrapper.text()).toContain(numberToHumanSize(namespaceData.limit));
});
it('when limit is 0 it does not render limit information', async () => {
wrapper.setData({
namespace: { ...projects, limit: 0 },
namespace: { ...namespaceData, limit: 0 },
});
await wrapper.vm.$nextTick();
......@@ -89,7 +89,7 @@ describe('Storage counter app', () => {
describe('without rootStorageStatistics information', () => {
it('renders N/A', async () => {
wrapper.setData({
namespace: projects,
namespace: namespaceData,
});
await wrapper.vm.$nextTick();
......@@ -140,7 +140,7 @@ describe('Storage counter app', () => {
createComponent({ isTemporaryStorageIncreaseVisible: 'true' });
wrapper.setData({
namespace: {
...projects,
...namespaceData,
limit: TEST_LIMIT,
},
});
......
......@@ -3,41 +3,23 @@ import Project from 'ee/storage_counter/components/project.vue';
import StorageRow from 'ee/storage_counter/components/storage_row.vue';
import ProjectAvatar from '~/vue_shared/components/project_avatar/default.vue';
import { numberToHumanSize } from '~/lib/utils/number_utils';
import { projects } from '../mock_data';
let wrapper;
const data = {
id: '8',
fullPath: 'h5bp/html5-boilerplate',
nameWithNamespace: 'H5bp / Html5 Boilerplate',
avatarUrl: null,
webUrl: 'http://localhost:3001/h5bp/html5-boilerplate',
name: 'Html5 Boilerplate',
statistics: {
commitCount: 0,
storageSize: 1293346,
repositorySize: 0,
lfsObjectsSize: 0,
buildArtifactsSize: 1272375,
packagesSize: 0,
wikiSize: 2048,
snippetsSize: 1024,
},
};
function factory(project) {
const createComponent = () => {
wrapper = shallowMount(Project, {
propsData: {
project,
project: projects[1],
},
});
}
};
const findTableRow = () => wrapper.find('[data-testid="projectTableRow"]');
const findStorageRow = () => wrapper.find(StorageRow);
describe('Storage Counter project component', () => {
beforeEach(() => {
factory(data);
createComponent();
});
it('renders project avatar', () => {
......@@ -45,11 +27,11 @@ describe('Storage Counter project component', () => {
});
it('renders project name', () => {
expect(wrapper.text()).toContain(data.nameWithNamespace);
expect(wrapper.text()).toContain(projects[1].nameWithNamespace);
});
it('renders formatted storage size', () => {
expect(wrapper.text()).toContain(numberToHumanSize(data.statistics.storageSize));
expect(wrapper.text()).toContain(numberToHumanSize(projects[1].statistics.storageSize));
});
describe('toggle row', () => {
......
import { shallowMount } from '@vue/test-utils';
import { GlLink } from '@gitlab/ui';
import ProjectWithExcessStorage from 'ee/storage_counter/components/project_with_excess_storage.vue';
import ProjectAvatar from '~/vue_shared/components/project_avatar/default.vue';
import { numberToHumanSize } from '~/lib/utils/number_utils';
import { projects } from '../mock_data';
let wrapper;
const createComponent = (propsData = {}) => {
wrapper = shallowMount(ProjectWithExcessStorage, {
propsData: {
project: projects[1],
...propsData,
},
});
};
const findTableRow = () => wrapper.find('[data-testid="projectTableRow"]');
const findWarningIcon = () => wrapper.find({ name: 'status_warning' });
const findProjectLink = () => wrapper.find(GlLink);
describe('Storage Counter project component', () => {
beforeEach(() => {
createComponent();
});
afterEach(() => {
wrapper.destroy();
});
it('renders project avatar', () => {
expect(wrapper.find(ProjectAvatar).exists()).toBe(true);
});
it('renders project name', () => {
expect(wrapper.text()).toContain(projects[1].nameWithNamespace);
});
it('renders formatted storage size', () => {
expect(wrapper.text()).toContain(numberToHumanSize(projects[1].statistics.storageSize));
});
it('does not render the warning icon if project is not in error state', () => {
expect(findWarningIcon().exists()).toBe(false);
});
it('render row without error state background', () => {
expect(findTableRow().classes('gl-bg-red-50')).toBe(false);
});
describe('renders the row in error state', () => {
beforeEach(() => {
createComponent({ project: projects[2] });
});
it('with error state background', () => {
expect(findTableRow().classes('gl-bg-red-50')).toBe(true);
});
it('with project link in error state', () => {
expect(findProjectLink().classes('gl-text-red-500!')).toBe(true);
});
it('with error icon', () => {
expect(findWarningIcon().exists()).toBe(true);
});
});
});
import { shallowMount } from '@vue/test-utils';
import ProjectsTable from 'ee/storage_counter/components/projects_table.vue';
import Project from 'ee/storage_counter/components/project.vue';
import ProjectWithExcessStorage from 'ee/storage_counter/components/project_with_excess_storage.vue';
import { projects } from '../mock_data';
let wrapper;
const createComponent = ({ additionalRepoStorageByNamespace = false } = {}) => {
const stubs = {
'anonymous-stub': additionalRepoStorageByNamespace ? ProjectWithExcessStorage : Project,
};
wrapper = shallowMount(ProjectsTable, {
propsData: {
projects,
},
stubs,
provide: {
glFeatures: {
additionalRepoStorageByNamespace,
},
},
});
};
const findTableRows = () => wrapper.findAll(Project);
const findTableRowsWithExcessStorage = () => wrapper.findAll(ProjectWithExcessStorage);
describe('Usage Quotas project table component', () => {
beforeEach(() => {
createComponent();
});
afterEach(() => {
wrapper.destroy();
});
it('renders regular project rows by default', () => {
expect(findTableRows()).toHaveLength(3);
expect(findTableRowsWithExcessStorage()).toHaveLength(0);
});
describe('with additional repo storage feature flag ', () => {
beforeEach(() => {
createComponent({ additionalRepoStorageByNamespace: true });
});
it('renders table row with excess storage', () => {
expect(findTableRowsWithExcessStorage()).toHaveLength(3);
});
it('renders excess storage rows with error state', () => {
const rowsWithError = findTableRowsWithExcessStorage().filter(r => r.classes('gl-bg-red-50'));
expect(rowsWithError).toHaveLength(1);
});
});
});
export const projects = {
totalUsage: 'N/A',
limit: 10000000,
projects: [
{
id: '24',
fullPath: 'h5bp/dummy-project',
nameWithNamespace: 'H5bp / dummy project',
avatarUrl: null,
webUrl: 'http://localhost:3001/h5bp/dummy-project',
name: 'dummy project',
statistics: {
commitCount: 1,
storageSize: 41943,
repositorySize: 41943,
lfsObjectsSize: 0,
buildArtifactsSize: 0,
packagesSize: 0,
},
},
{
id: '8',
fullPath: 'h5bp/html5-boilerplate',
nameWithNamespace: 'H5bp / Html5 Boilerplate',
avatarUrl: null,
webUrl: 'http://localhost:3001/h5bp/html5-boilerplate',
name: 'Html5 Boilerplate',
statistics: {
commitCount: 0,
storageSize: 1293346,
repositorySize: 0,
lfsObjectsSize: 0,
buildArtifactsSize: 1272375,
packagesSize: 0,
},
},
],
};
export const withRootStorageStatistics = { ...projects, totalUsage: 3261070 };
export const projects = [
{
id: '24',
fullPath: 'h5bp/dummy-project',
nameWithNamespace: 'H5bp / dummy project',
avatarUrl: null,
webUrl: 'http://localhost:3001/h5bp/dummy-project',
name: 'dummy project',
statistics: {
commitCount: 1,
storageSize: 41943,
repositorySize: 41943,
lfsObjectsSize: 0,
buildArtifactsSize: 0,
packagesSize: 0,
},
},
{
id: '8',
fullPath: 'h5bp/html5-boilerplate',
nameWithNamespace: 'H5bp / Html5 Boilerplate',
avatarUrl: null,
webUrl: 'http://localhost:3001/h5bp/html5-boilerplate',
name: 'Html5 Boilerplate',
statistics: {
commitCount: 0,
storageSize: 1293346,
repositorySize: 0,
lfsObjectsSize: 0,
buildArtifactsSize: 1272375,
packagesSize: 0,
},
},
{
id: '80',
fullPath: 'twit/twitter',
nameWithNamespace: 'Twitter',
avatarUrl: null,
webUrl: 'http://localhost:3001/twit/twitter',
name: 'Twitter',
statistics: {
commitCount: 0,
storageSize: 129334601203,
repositorySize: 0,
lfsObjectsSize: 0,
buildArtifactsSize: 1272375,
packagesSize: 0,
},
},
];
export const namespaceData = {
totalUsage: 'N/A',
limit: 10000000,
projects,
};
export const withRootStorageStatistics = { ...projects, totalUsage: 3261070 };
......@@ -10490,6 +10490,9 @@ msgstr ""
msgid "Except policy:"
msgstr ""
msgid "Excess storage"
msgstr ""
msgid "Excluding merge commits. Limited to %{limit} commits."
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