diff --git a/ee/app/assets/javascripts/usage_quotas/storage/components/namespace_storage_app.stories.js b/ee/app/assets/javascripts/usage_quotas/storage/components/namespace_storage_app.stories.js index 1dfe02c6d9d73ed8b6ba9a864166352379698bb1..db66730a03a155330396606c4b61f6912ae9db40 100644 --- a/ee/app/assets/javascripts/usage_quotas/storage/components/namespace_storage_app.stories.js +++ b/ee/app/assets/javascripts/usage_quotas/storage/components/namespace_storage_app.stories.js @@ -86,6 +86,18 @@ export const SaasWithProjectLimits = { }), }; +export const SaasWithNoLimits = { + render: createTemplate({ + provide: { + enforcementType: 'project_repository_limit', + isUsingNamespaceEnforcement: false, + isUsingProjectEnforcement: true, + totalRepositorySizeExcess: 0, + namespacePlanStorageIncluded: 0, + }, + }), +}; + export const SaasWithProjectLimitsLoading = { render: (...args) => { const apolloProvider = createMockApollo([ diff --git a/ee/app/assets/javascripts/usage_quotas/storage/components/no_limits_purchased_storage_breakdown_card.vue b/ee/app/assets/javascripts/usage_quotas/storage/components/no_limits_purchased_storage_breakdown_card.vue new file mode 100644 index 0000000000000000000000000000000000000000..3da32d872705ded65eae4255b699c6354f0d53af --- /dev/null +++ b/ee/app/assets/javascripts/usage_quotas/storage/components/no_limits_purchased_storage_breakdown_card.vue @@ -0,0 +1,72 @@ +<script> +import { GlIcon, GlLink, GlCard, GlSkeletonLoader } from '@gitlab/ui'; +import { s__ } from '~/locale'; +import { usageQuotasHelpPaths } from '~/usage_quotas/storage/constants'; +import { STORAGE_STATISTICS_USAGE_QUOTA_LEARN_MORE } from '../constants'; +import NumberToHumanSize from './number_to_human_size.vue'; + +export default { + name: 'NoLimitsPurchasedStorageBreakdownCard', + components: { + GlIcon, + GlLink, + GlCard, + GlSkeletonLoader, + NumberToHumanSize, + }, + props: { + loading: { + type: Boolean, + required: true, + }, + purchasedStorage: { + type: Number, + required: true, + }, + }, + i18n: { + PROJECT_ENFORCEMENT_PURCHASE_CARD_TITLE: s__('UsageQuota|Purchased storage'), + STORAGE_STATISTICS_USAGE_QUOTA_LEARN_MORE, + PROJECT_ENFORCEMENT_PURCHASE_CARD_SUBTITLE: s__( + 'UsageQuota|Any additional purchased storage will be displayed here.', + ), + }, + usageQuotasHelpPaths, +}; +</script> + +<template> + <gl-card> + <gl-skeleton-loader v-if="loading" :height="64"> + <rect width="140" height="30" x="5" y="0" rx="4" /> + <rect width="240" height="10" x="5" y="40" rx="4" /> + <rect width="340" height="10" x="5" y="54" rx="4" /> + </gl-skeleton-loader> + <div v-else> + <div class="gl-display-flex gl-align-items-center gl-justify-content-space-between"> + <div class="gl-font-weight-bold" data-testid="purchased-storage-card-title"> + {{ $options.i18n.PROJECT_ENFORCEMENT_PURCHASE_CARD_TITLE }} + + <gl-link + :href="$options.usageQuotasHelpPaths.usageQuotasNamespaceStorageLimit" + target="_blank" + class="gl-ml-2" + :aria-label="$options.i18n.STORAGE_STATISTICS_USAGE_QUOTA_LEARN_MORE" + > + <gl-icon name="question-o" /> + </gl-link> + </div> + </div> + <div class="gl-font-size-h-display gl-font-weight-bold gl-line-height-ratio-1000 gl-my-3"> + <number-to-human-size + label-class="gl-font-lg" + :value="Number(purchasedStorage)" + plain-zero + data-testid="storage-purchased" + /> + </div> + <hr class="gl-my-4" /> + <p>{{ $options.i18n.PROJECT_ENFORCEMENT_PURCHASE_CARD_SUBTITLE }}</p> + </div> + </gl-card> +</template> diff --git a/ee/app/assets/javascripts/usage_quotas/storage/components/storage_usage_statistics.vue b/ee/app/assets/javascripts/usage_quotas/storage/components/storage_usage_statistics.vue index 5ae6527c7ec739699a93a885e54a8c60354bd89c..92f20ce72a3bf080d2900672c661fa96f1b6f2a5 100644 --- a/ee/app/assets/javascripts/usage_quotas/storage/components/storage_usage_statistics.vue +++ b/ee/app/assets/javascripts/usage_quotas/storage/components/storage_usage_statistics.vue @@ -10,6 +10,7 @@ import NamespaceLimitsTotalStorageAvailableBreakdownCard from './namespace_limit import StorageUsageOverviewCard from './storage_usage_overview_card.vue'; import ProjectLimitsExcessStorageBreakdownCard from './project_limits_excess_storage_breakdown_card.vue'; import NumberToHumanSize from './number_to_human_size.vue'; +import NoLimitsPurchasedStorageBreakdownCard from './no_limits_purchased_storage_breakdown_card.vue'; export default { components: { @@ -22,6 +23,7 @@ export default { StorageUsageOverviewCard, ProjectLimitsExcessStorageBreakdownCard, NumberToHumanSize, + NoLimitsPurchasedStorageBreakdownCard, }, directives: { GlModalDirective, @@ -82,6 +84,12 @@ export default { namespaceStorageOverviewSubtitle: NAMESPACE_STORAGE_OVERVIEW_SUBTITLE, }, computed: { + isUsingProjectEnforcementWithLimits() { + return this.isUsingProjectEnforcement && this.namespacePlanStorageIncluded !== 0; + }, + isUsingProjectEnforcementWithNoLimits() { + return this.isUsingProjectEnforcement && this.namespacePlanStorageIncluded === 0; + }, totalStorage() { return this.namespacePlanStorageIncluded + this.additionalPurchasedStorageSize; }, @@ -174,14 +182,22 @@ export default { /> <template v-if="namespacePlanName"> + <no-limits-purchased-storage-breakdown-card + v-if="isUsingProjectEnforcementWithNoLimits" + :purchased-storage="additionalPurchasedStorageSize" + :limited-access-mode-enabled="shouldShowLimitedAccessModal" + :loading="loading" + /> + <project-limits-excess-storage-breakdown-card - v-if="isUsingProjectEnforcement" + v-else-if="isUsingProjectEnforcementWithLimits" :purchased-storage="additionalPurchasedStorageSize" :limited-access-mode-enabled="shouldShowLimitedAccessModal" :loading="loading" /> + <namespace-limits-total-storage-available-breakdown-card - v-else + v-else-if="isUsingNamespaceEnforcement" :included-storage="namespacePlanStorageIncluded" :purchased-storage="additionalPurchasedStorageSize" :total-storage="totalStorage" diff --git a/ee/spec/features/groups/usage_quotas/storage_tab_spec.rb b/ee/spec/features/groups/usage_quotas/storage_tab_spec.rb index e5cdfc12dc3dc3225dd95fc05ce548cd6b9d3c57..975147e541e5ea66c0f2a1828a0ec259a54596fa 100644 --- a/ee/spec/features/groups/usage_quotas/storage_tab_spec.rb +++ b/ee/spec/features/groups/usage_quotas/storage_tab_spec.rb @@ -112,6 +112,8 @@ stub_signing_key stub_feature_flags(limited_access_modal: true) stub_subscription_permissions_data(group.id, can_add_seats: false) + enforce_namespace_storage_limit(group) + set_enforcement_limit(group, megabytes: 100) visit_usage_quotas_page('storage-quota-tab') wait_for_requests diff --git a/ee/spec/frontend/usage_quotas/storage/components/no_limits_purchased_storage_breakdown_card_spec.js b/ee/spec/frontend/usage_quotas/storage/components/no_limits_purchased_storage_breakdown_card_spec.js new file mode 100644 index 0000000000000000000000000000000000000000..aa414900fd07f5747d107bc471ea383f75e18dda --- /dev/null +++ b/ee/spec/frontend/usage_quotas/storage/components/no_limits_purchased_storage_breakdown_card_spec.js @@ -0,0 +1,46 @@ +import { GlSkeletonLoader } from '@gitlab/ui'; +import NoLimitsPurchasedStorageBreakdownCard from 'ee/usage_quotas/storage/components/no_limits_purchased_storage_breakdown_card.vue'; +import { numberToHumanSize } from '~/lib/utils/number_utils'; +import NumberToHumanSize from 'ee/usage_quotas/storage/components/number_to_human_size.vue'; +import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; + +describe('NoLimitsPurchasedStorageBreakdownCard', () => { + /** @type { import('helpers/vue_test_utils_helper').ExtendedWrapper } */ + let wrapper; + + const defaultProps = { + purchasedStorage: 256, + loading: false, + }; + + const createComponent = ({ props = {} } = {}) => { + wrapper = shallowMountExtended(NoLimitsPurchasedStorageBreakdownCard, { + propsData: { ...defaultProps, ...props }, + stubs: { + NumberToHumanSize, + }, + }); + }; + + const findPurchacedStorage = () => wrapper.findByTestId('storage-purchased'); + const findSkeletonLoader = () => wrapper.findComponent(GlSkeletonLoader); + + it('renders the purchaced storage value', () => { + createComponent(); + expect(findPurchacedStorage().text()).toContain( + numberToHumanSize(defaultProps.purchasedStorage, 1), + ); + }); + + describe('skeleton loader', () => { + it('renders skeleton loader when loading prop is true', () => { + createComponent({ props: { loading: true } }); + expect(findSkeletonLoader().exists()).toBe(true); + }); + + it('does not render skeleton loader when loading prop is false', () => { + createComponent({ props: { loading: false } }); + expect(findSkeletonLoader().exists()).toBe(false); + }); + }); +}); diff --git a/ee/spec/frontend/usage_quotas/storage/components/storage_usage_statistics_spec.js b/ee/spec/frontend/usage_quotas/storage/components/storage_usage_statistics_spec.js index 061b94ff97b3949751416a40ac774c9c9c896ba0..899b1751ea011f9817d37d4faef7eb1c849d749a 100644 --- a/ee/spec/frontend/usage_quotas/storage/components/storage_usage_statistics_spec.js +++ b/ee/spec/frontend/usage_quotas/storage/components/storage_usage_statistics_spec.js @@ -4,6 +4,7 @@ import { GlButton, GlLink, GlSprintf, GlProgressBar } from '@gitlab/ui'; import StorageUsageOverviewCard from 'ee/usage_quotas/storage/components/storage_usage_overview_card.vue'; import NamespaceLimitsStorageUsageOverviewCard from 'ee/usage_quotas/storage/components/namespace_limits_storage_usage_overview_card.vue'; import NamespaceLimitsTotalStorageAvailableBreakdownCard from 'ee/usage_quotas/storage/components/namespace_limits_total_storage_available_breakdown_card.vue'; +import NoLimitsPurchasedStorageBreakdownCard from 'ee/usage_quotas/storage/components/no_limits_purchased_storage_breakdown_card.vue'; import ProjectLimitsExcessStorageBreakdownCard from 'ee/usage_quotas/storage/components/project_limits_excess_storage_breakdown_card.vue'; import NumberToHumanSize from 'ee/usage_quotas/storage/components/number_to_human_size.vue'; import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; @@ -72,6 +73,8 @@ describe('StorageUsageStatistics', () => { wrapper.findComponent(NamespaceLimitsTotalStorageAvailableBreakdownCard); const findProjectLimitsExcessStorageBreakdownCard = () => wrapper.findComponent(ProjectLimitsExcessStorageBreakdownCard); + const findNoLimitsPurchasedStorageBreakdownCard = () => + wrapper.findComponent(NoLimitsPurchasedStorageBreakdownCard); const findOverviewSubtitle = () => wrapper.findByTestId('overview-subtitle'); const findPurchaseButton = () => wrapper.findComponent(GlButton); const findLimitedAccessModal = () => wrapper.findComponent(LimitedAccessModal); @@ -201,15 +204,26 @@ describe('StorageUsageStatistics', () => { }); describe('NamespaceLimitsTotalStorageAvailableBreakdownCard', () => { - it('does not render NamespaceLimitsTotalStorageAvailableBreakdownCard when namespace is using project enforcement', () => { + it('does not render when the namespace is using project enforcement', () => { createComponent(); expect(findNamespaceLimitsTotalStorageAvailableBreakdownCard().exists()).toBe(false); }); - it('passes the correct props to NamespaceLimitsTotalStorageAvailableBreakdownCard when namespace is NOT using project enforcement', () => { + it('does not render if there is no plan information', () => { + createComponent({ + provide: { + namespacePlanName: null, + }, + }); + + expect(findNamespaceLimitsTotalStorageAvailableBreakdownCard().exists()).toBe(false); + }); + + it('passes correct props when the namespace is NOT using project enforcement', () => { createComponent({ provide: { isUsingProjectEnforcement: false, + isUsingNamespaceEnforcement: true, }, }); @@ -222,20 +236,45 @@ describe('StorageUsageStatistics', () => { loading: false, }); }); + }); - it('does not render storage card if there is no plan information', () => { + describe('NoLimitsPurchasedStorageBreakdownCard', () => { + it('does not render when namespace is NOT using project enforcement', () => { createComponent({ provide: { - namespacePlanName: null, + isUsingProjectEnforcement: false, }, }); + expect(findNoLimitsPurchasedStorageBreakdownCard().exists()).toBe(false); + }); - expect(findNamespaceLimitsTotalStorageAvailableBreakdownCard().exists()).toBe(false); + it('does not render when namespace IS using project enforcement with limits', () => { + createComponent({ + provide: { + isUsingProjectEnforcement: true, + namespacePlanStorageIncluded: 1, + }, + }); + expect(findNoLimitsPurchasedStorageBreakdownCard().exists()).toBe(false); + }); + + it('passes correct props when namespace IS using project enforcement', () => { + createComponent({ + provide: { + isUsingProjectEnforcement: true, + namespacePlanStorageIncluded: 0, + }, + }); + + expect(findNoLimitsPurchasedStorageBreakdownCard().props()).toEqual({ + purchasedStorage: withRootStorageStatistics.additionalPurchasedStorageSize, + loading: false, + }); }); }); describe('ProjectLimitsExcessStorageBreakdownCard', () => { - it('does not render ProjectLimitsExcessStorageBreakdownCard when namespace is NOT using project enforcement', () => { + it('does not render when the namespace is NOT using project enforcement', () => { createComponent({ provide: { isUsingProjectEnforcement: false, @@ -244,7 +283,17 @@ describe('StorageUsageStatistics', () => { expect(findProjectLimitsExcessStorageBreakdownCard().exists()).toBe(false); }); - it('passes the correct props to ProjectLimitsExcessStorageBreakdownCard when namespace is using project enforcement', () => { + it('does not render when the namespace IS using project enforcement with no limits', () => { + createComponent({ + provide: { + isUsingProjectEnforcement: true, + namespacePlanStorageIncluded: 0, + }, + }); + expect(findProjectLimitsExcessStorageBreakdownCard().exists()).toBe(false); + }); + + it('passes correct props when the namespace IS using project enforcement', () => { createComponent(); expect(findProjectLimitsExcessStorageBreakdownCard().props()).toEqual({ diff --git a/locale/gitlab.pot b/locale/gitlab.pot index dd1d5be8a11a52372936b0d1fd23b55b221c4153..92b0cb58e9d7b3e47f14814dfb097af6ce73c2d0 100644 --- a/locale/gitlab.pot +++ b/locale/gitlab.pot @@ -51401,6 +51401,9 @@ msgstr "" msgid "UsageQuota|%{storage_limit_link_start}A namespace storage limit%{link_end} will soon be enforced for the %{strong_start}%{namespace_name}%{strong_end} namespace. %{extra_message}" msgstr "" +msgid "UsageQuota|Any additional purchased storage will be displayed here." +msgstr "" + msgid "UsageQuota|Audio samples, videos, datasets, and graphics." msgstr "" @@ -51515,6 +51518,9 @@ msgstr "" msgid "UsageQuota|Projects under this namespace have %{planLimit} of storage." msgstr "" +msgid "UsageQuota|Purchased storage" +msgstr "" + msgid "UsageQuota|Recalculate repository usage" msgstr ""