Commit cd0f5c42 authored by Frédéric Caplette's avatar Frédéric Caplette

Merge branch '343542-decouple-group-stroage-from-feature-flag' into 'master'

Decouple group's storage table from feature flag

See merge request gitlab-org/gitlab!73237
parents bee7ab2a 0aa7613a
<script>
import { GlLink, GlIcon, GlTooltipDirective } from '@gitlab/ui';
import { getIdFromGraphQLId } from '~/graphql_shared/utils';
import { s__, sprintf } from '~/locale';
import ProjectAvatar from '~/vue_shared/components/deprecated_project_avatar/default.vue';
import { ALERT_THRESHOLD, ERROR_THRESHOLD, WARNING_THRESHOLD } from '../constants';
import { formatUsageSize, usageRatioToThresholdLevel } from '../utils';
export default {
i18n: {
warningWithNoPurchasedStorageText: s__(
'UsageQuota|This project is near the free %{actualRepositorySizeLimit} limit and at risk of being locked.',
),
lockedWithNoPurchasedStorageText: s__(
'UsageQuota|This project is locked because it is using %{actualRepositorySizeLimit} of free storage and there is no purchased storage available.',
),
warningWithPurchasedStorageText: s__(
'UsageQuota|This project is at risk of being locked because purchased storage is running low.',
),
lockedWithPurchasedStorageText: s__(
'UsageQuota|This project is locked because it used %{actualRepositorySizeLimit} of free storage and all the purchased storage.',
),
},
components: {
GlIcon,
GlLink,
ProjectAvatar,
},
directives: {
GlTooltip: GlTooltipDirective,
},
props: {
project: {
required: true,
type: Object,
},
additionalPurchasedStorageSize: {
type: Number,
required: true,
},
},
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;
},
hasPurchasedStorage() {
return this.additionalPurchasedStorageSize > 0;
},
storageSize() {
return formatUsageSize(this.project.totalCalculatedUsedStorage);
},
excessStorageSize() {
return formatUsageSize(this.project.repositorySizeExcess);
},
excessStorageRatio() {
return this.project.totalCalculatedUsedStorage / this.project.totalCalculatedStorageLimit;
},
thresholdLevel() {
return usageRatioToThresholdLevel(this.excessStorageRatio);
},
status() {
const i18nTextOpts = {
actualRepositorySizeLimit: formatUsageSize(this.project.actualRepositorySizeLimit),
};
if (this.thresholdLevel === ERROR_THRESHOLD) {
const tooltipText = this.hasPurchasedStorage
? this.$options.i18n.lockedWithPurchasedStorageText
: this.$options.i18n.lockedWithNoPurchasedStorageText;
return {
bgColor: { 'gl-bg-red-50': true },
iconClass: { 'gl-text-red-500': true },
linkClass: 'gl-text-red-500!',
tooltipText: sprintf(tooltipText, i18nTextOpts),
};
} else if (
this.thresholdLevel === WARNING_THRESHOLD ||
this.thresholdLevel === ALERT_THRESHOLD
) {
const tooltipText = this.hasPurchasedStorage
? this.$options.i18n.warningWithPurchasedStorageText
: this.$options.i18n.warningWithNoPurchasedStorageText;
return {
bgColor: { 'gl-bg-orange-50': true },
iconClass: 'gl-text-orange-500',
tooltipText: sprintf(tooltipText, i18nTextOpts),
};
}
return {};
},
},
};
</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="status.bgColor"
role="row"
data-testid="projectTableRow"
>
<div
class="table-section gl-white-space-normal! gl-sm-flex-wrap section-50 gl-text-truncate gl-pr-5"
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="status.iconClass">
<gl-icon
v-gl-tooltip="{ title: status.tooltipText }"
name="status_warning"
class="gl-mr-3"
:class="status.iconClass"
/>
</div>
<gl-link
:href="project.webUrl"
class="gl-font-weight-bold gl-text-gray-900!"
:class="status.linkClass"
>{{ name }}</gl-link
>
</div>
</div>
<div
class="table-section gl-white-space-normal! gl-sm-flex-wrap section-15 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-sm-flex-wrap section-15 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>
......@@ -12,14 +12,13 @@ export default {
<div class="gl-border-b-solid gl-border-b-1 gl-border-gray-100">
<div class="gl-flex-direction-column gl-md-display-none" data-testid="mobile-loader">
<div
v-for="index in $options.SKELETON_LOADER_ROWS.mobile"
v-for="index in $options.SKELETON_LOADER_ROWS"
:key="index"
class="gl-responsive-table-row gl-border-solid gl-border-b-1 gl-pt-3 gl-pb-3 gl-border-b-gray-100"
>
<gl-skeleton-loader :width="500" :height="172">
<gl-skeleton-loader :width="500" :height="110">
<rect width="480" height="20" x="10" y="15" rx="4" />
<rect width="480" height="20" x="10" y="80" rx="4" />
<rect width="480" height="20" x="10" y="145" rx="4" />
</gl-skeleton-loader>
</div>
</div>
......@@ -28,14 +27,13 @@ export default {
data-testid="desktop-loader"
>
<gl-skeleton-loader
v-for="index in $options.SKELETON_LOADER_ROWS.desktop"
v-for="index in $options.SKELETON_LOADER_ROWS"
:key="index"
:width="1000"
:height="39"
>
<rect rx="4" width="320" height="8" x="0" y="18" />
<rect rx="4" width="60" height="8" x="500" y="18" />
<rect rx="4" width="60" height="8" x="750" y="18" />
<rect rx="4" width="60" height="8" x="700" y="18" />
</gl-skeleton-loader>
</div>
</div>
......
<script>
import { GlSearchBoxByType } from '@gitlab/ui';
import { SEARCH_DEBOUNCE_MS } from '~/ref/constants';
import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import Project from './project.vue';
import ProjectWithExcessStorage from './project_with_excess_storage.vue';
import ProjectsSkeletonLoader from './projects_skeleton_loader.vue';
export default {
components: {
Project,
ProjectsSkeletonLoader,
ProjectWithExcessStorage,
GlSearchBoxByType,
},
mixins: [glFeatureFlagsMixin()],
props: {
......@@ -29,17 +25,6 @@ export default {
default: false,
},
},
computed: {
isAdditionalStorageFlagEnabled() {
return this.glFeatures.additionalRepoStorageByNamespace;
},
projectRowComponent() {
if (this.isAdditionalStorageFlagEnabled) {
return ProjectWithExcessStorage;
}
return Project;
},
},
searchDebounceValue: SEARCH_DEBOUNCE_MS,
};
</script>
......@@ -50,37 +35,16 @@ export default {
class="gl-responsive-table-row table-row-header 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 gl-pl-5" role="columnheader">
{{ __('Project') }}
</div>
<div class="table-section section-15 gl-font-weight-bold" role="columnheader">
{{ __('Usage') }}
</div>
<div class="table-section section-15 gl-font-weight-bold" role="columnheader">
{{ __('Excess storage') }}
</div>
<div class="table-section section-20 gl-font-weight-bold gl-pl-6" role="columnheader">
<gl-search-box-by-type
:placeholder="__('Search by name')"
:debounce="$options.searchDebounceValue"
@input="(input) => this.$emit('search', input)"
/>
</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>
<projects-skeleton-loader v-if="isAdditionalStorageFlagEnabled && isLoading" />
<projects-skeleton-loader v-if="isLoading" />
<template v-else>
<component
:is="projectRowComponent"
<project
v-for="project in projects"
:key="project.id"
:project="project"
......
......@@ -14,7 +14,4 @@ export const STORAGE_USAGE_THRESHOLDS = {
export const PROJECTS_PER_PAGE = 20;
export const SKELETON_LOADER_ROWS = {
desktop: PROJECTS_PER_PAGE,
mobile: 5,
};
export const SKELETON_LOADER_ROWS = 5;
import { GlIcon, GlLink } from '@gitlab/ui';
import { shallowMount } from '@vue/test-utils';
import ProjectWithExcessStorage from 'ee/storage_counter/components/project_with_excess_storage.vue';
import { formatUsageSize } from 'ee/storage_counter/utils';
import { createMockDirective, getBinding } from 'helpers/vue_mock_directive';
import ProjectAvatar from '~/vue_shared/components/deprecated_project_avatar/default.vue';
import { projects } from '../mock_data';
let wrapper;
const createComponent = (propsData = {}) => {
wrapper = shallowMount(ProjectWithExcessStorage, {
propsData: {
project: projects[0],
additionalPurchasedStorageSize: 0,
...propsData,
},
directives: {
GlTooltip: createMockDirective(),
},
});
};
const findTableRow = () => wrapper.find('[data-testid="projectTableRow"]');
const findWarningIcon = () =>
wrapper.findAll(GlIcon).wrappers.find((w) => w.props('name') === 'status_warning');
const findProjectLink = () => wrapper.find(GlLink);
const getWarningIconTooltipText = () => getBinding(findWarningIcon().element, 'gl-tooltip').value;
describe('Storage Counter project component', () => {
beforeEach(() => {
createComponent();
});
afterEach(() => {
wrapper.destroy();
});
describe('without extra storage purchased', () => {
it('renders project avatar', () => {
expect(wrapper.find(ProjectAvatar).exists()).toBe(true);
});
it('renders project name', () => {
expect(wrapper.text()).toContain(projects[0].nameWithNamespace);
});
it('renders formatted storage size', () => {
expect(wrapper.text()).toContain(formatUsageSize(projects[0].statistics.storageSize));
});
it('does not render the warning icon if project is not in error state', () => {
expect(findWarningIcon()).toBe(undefined);
});
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);
});
it('with tooltip', () => {
expect(getWarningIconTooltipText().title).toBe(
'This project is locked because it is using 97.7KiB of free storage and there is no purchased storage available.',
);
});
});
describe('renders the row in warning state', () => {
beforeEach(() => {
createComponent({ project: projects[1] });
});
it('with warning state background', () => {
expect(findTableRow().classes('gl-bg-orange-50')).toBe(true);
});
it('with project link in default gray state', () => {
expect(findProjectLink().classes('gl-text-gray-900!')).toBe(true);
});
it('with warning icon', () => {
expect(findWarningIcon().exists()).toBe(true);
});
it('with tooltip', () => {
expect(getWarningIconTooltipText().title).toBe(
'This project is near the free 97.7KiB limit and at risk of being locked.',
);
});
});
});
describe('with extra storage purchased', () => {
describe('if projects is in error state', () => {
beforeEach(() => {
createComponent({
project: projects[2],
additionalPurchasedStorageSize: 100000,
});
});
afterEach(() => {
wrapper.destroy();
});
it('renders purchased storage specific error tooltip ', () => {
expect(getWarningIconTooltipText().title).toBe(
'This project is locked because it used 97.7KiB of free storage and all the purchased storage.',
);
});
});
describe('if projects is in warning state', () => {
beforeEach(() => {
createComponent({
project: projects[1],
additionalPurchasedStorageSize: 100000,
});
});
afterEach(() => {
wrapper.destroy();
});
it('renders purchased storage specific warning tooltip ', () => {
expect(getWarningIconTooltipText().title).toBe(
'This project is at risk of being locked because purchased storage is running low.',
);
});
});
});
});
......@@ -23,8 +23,8 @@ describe('ProjectsSkeletonLoader', () => {
});
describe('desktop loader', () => {
it('produces 20 rows', () => {
expect(findDesktopLoader().findAll('rect[width="1000"]')).toHaveLength(20);
it('produces 5 rows', () => {
expect(findDesktopLoader().findAll('rect[width="1000"]')).toHaveLength(5);
});
it('has the correct classes', () => {
......@@ -38,7 +38,7 @@ describe('ProjectsSkeletonLoader', () => {
describe('mobile loader', () => {
it('produces 5 rows', () => {
expect(findMobileLoader().findAll('rect[height="172"]')).toHaveLength(5);
expect(findMobileLoader().findAll('rect[height="110"]')).toHaveLength(5);
});
it('has the correct classes', () => {
......
import { shallowMount } from '@vue/test-utils';
import Project from 'ee/storage_counter/components/project.vue';
import ProjectWithExcessStorage from 'ee/storage_counter/components/project_with_excess_storage.vue';
import ProjectsTable from 'ee/storage_counter/components/projects_table.vue';
import { projects } from '../mock_data';
let wrapper;
const createComponent = ({ additionalRepoStorageByNamespace = false } = {}) => {
const stubs = {
'anonymous-stub': additionalRepoStorageByNamespace ? ProjectWithExcessStorage : Project,
};
wrapper = shallowMount(ProjectsTable, {
propsData: {
projects,
additionalPurchasedStorageSize: 0,
},
stubs,
provide: {
glFeatures: {
additionalRepoStorageByNamespace,
......@@ -26,7 +20,6 @@ const createComponent = ({ additionalRepoStorageByNamespace = false } = {}) => {
};
const findTableRows = () => wrapper.findAll(Project);
const findTableRowsWithExcessStorage = () => wrapper.findAll(ProjectWithExcessStorage);
describe('Usage Quotas project table component', () => {
beforeEach(() => {
......@@ -39,7 +32,6 @@ describe('Usage Quotas project table component', () => {
it('renders regular project rows by default', () => {
expect(findTableRows()).toHaveLength(3);
expect(findTableRowsWithExcessStorage()).toHaveLength(0);
});
describe('with additional repo storage feature flag ', () => {
......@@ -47,15 +39,8 @@ describe('Usage Quotas project table component', () => {
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);
it('renders regular project rows by default', () => {
expect(findTableRows()).toHaveLength(3);
});
});
});
......@@ -13927,9 +13927,6 @@ msgstr ""
msgid "Exceptions"
msgstr ""
msgid "Excess storage"
msgstr ""
msgid "Excluding merge commits. Limited to %{limit} commits."
msgstr ""
......@@ -37263,18 +37260,6 @@ msgstr ""
msgid "UsageQuota|This namespace has no projects which use shared runners"
msgstr ""
msgid "UsageQuota|This project is at risk of being locked because purchased storage is running low."
msgstr ""
msgid "UsageQuota|This project is locked because it is using %{actualRepositorySizeLimit} of free storage and there is no purchased storage available."
msgstr ""
msgid "UsageQuota|This project is locked because it used %{actualRepositorySizeLimit} of free storage and all the purchased storage."
msgstr ""
msgid "UsageQuota|This project is near the free %{actualRepositorySizeLimit} limit and at risk of being locked."
msgstr ""
msgid "UsageQuota|Total excess storage used"
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