diff --git a/ee/app/assets/javascripts/usage_quotas/storage/components/namespace_storage_app.vue b/ee/app/assets/javascripts/usage_quotas/storage/components/namespace_storage_app.vue index 5fd004c9209732db039490d8191e4113c9714043..30e383c17a74e60e8561dea7eb0ac6ea18b1f381 100644 --- a/ee/app/assets/javascripts/usage_quotas/storage/components/namespace_storage_app.vue +++ b/ee/app/assets/javascripts/usage_quotas/storage/components/namespace_storage_app.vue @@ -1,5 +1,6 @@ <script> import { + GlAlert, GlLink, GlSprintf, GlModalDirective, @@ -7,7 +8,9 @@ import { GlIcon, GlKeysetPagination, } from '@gitlab/ui'; -import * as Sentry from '@sentry/browser'; +import { isEmpty } from 'lodash'; +import { s__ } from '~/locale'; +import { captureException } from '~/runner/sentry_utils'; import { parseBoolean } from '~/lib/utils/common_utils'; import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; import { PROJECT_TABLE_LABEL_STORAGE_USAGE } from '../constants'; @@ -26,6 +29,7 @@ import ContainerRegistryUsage from './container_registry_usage.vue'; export default { name: 'NamespaceStorageApp', components: { + GlAlert, GlLink, GlIcon, GlButton, @@ -66,6 +70,10 @@ export default { result() { this.firstFetch = false; }, + error(error) { + this.loadingError = true; + captureException({ error, component: this.$options.name }); + }, }, dependencyProxyTotalSize: { query: GetDependencyProxyTotalSizeQuery, @@ -78,10 +86,7 @@ export default { return group?.dependencyProxyTotalSize; }, error(error) { - Sentry.withScope((scope) => { - scope.setTag('component', this.$options.name); - Sentry.captureException(error); - }); + captureException({ error, component: this.$options.name }); }, }, }, @@ -104,6 +109,7 @@ export default { }, i18n: { PROJECT_TABLE_LABEL_STORAGE_USAGE, + errorMessageText: s__('UsageQuota|Something went wrong while loading usage details'), }, data() { return { @@ -111,6 +117,7 @@ export default { searchTerm: '', firstFetch: true, dependencyProxyTotalSize: '', + loadingError: false, }; }, computed: { @@ -148,6 +155,10 @@ export default { return this.namespace.projects?.pageInfo ?? {}; }, shouldShowStorageInlineAlert() { + if (isEmpty(this.namespace)) { + return false; + } + if (this.firstFetch) { // for initial load check if the data fetch is done (isQueryLoading) return this.isAdditionalStorageFlagEnabled && !this.isQueryLoading; @@ -160,6 +171,9 @@ export default { showPagination() { return Boolean(this.pageInfo?.hasPreviousPage || this.pageInfo?.hasNextPage); }, + isStorageUsageStatisticsLoading() { + return this.loadingError || this.isQueryLoading; + }, }, methods: { handleSearch(input) { @@ -196,6 +210,9 @@ export default { </script> <template> <div> + <gl-alert v-if="loadingError" variant="danger" :dismissible="false" class="gl-mt-4"> + {{ $options.i18n.errorMessageText }} + </gl-alert> <storage-inline-alert v-if="shouldShowStorageInlineAlert" :contains-locked-projects="namespace.containsLockedProjects" @@ -213,6 +230,7 @@ export default { :actual-repository-size-limit="storageStatistics.actualRepositorySizeLimit" :total-repository-size="storageStatistics.totalRepositorySize" :total-repository-size-excess="storageStatistics.totalRepositorySizeExcess" + :loading="isStorageUsageStatisticsLoading" /> <usage-statistics v-else :root-storage-statistics="storageStatistics" /> </div> diff --git a/ee/app/assets/javascripts/usage_quotas/storage/components/storage_statistics_card.vue b/ee/app/assets/javascripts/usage_quotas/storage/components/storage_statistics_card.vue index 52e6c5258884b27d0d33523d8456a72084bc73fd..a9d7d0648bfa3e64b298a086b016711036831175 100644 --- a/ee/app/assets/javascripts/usage_quotas/storage/components/storage_statistics_card.vue +++ b/ee/app/assets/javascripts/usage_quotas/storage/components/storage_statistics_card.vue @@ -1,10 +1,10 @@ <script> -import { GlProgressBar } from '@gitlab/ui'; +import { GlProgressBar, GlSkeletonLoader } from '@gitlab/ui'; import { formatSizeAndSplit } from 'ee/usage_quotas/storage/utils'; export default { name: 'StorageStatisticsCard', - components: { GlProgressBar }, + components: { GlProgressBar, GlSkeletonLoader }, props: { totalStorage: { type: Number, @@ -21,6 +21,10 @@ export default { required: false, default: false, }, + loading: { + type: Boolean, + required: true, + }, }, computed: { formattedUsage() { @@ -71,24 +75,32 @@ export default { class="gl-bg-white gl-border-1 gl-border-gray-100 gl-border-solid gl-p-5 gl-rounded-base" data-testid="container" > - <div class="gl-display-flex gl-justify-content-space-between"> - <p class="gl-font-size-h-display gl-font-weight-bold gl-mb-3" data-testid="denominator"> - {{ usageValue }} - <span class="gl-font-lg">{{ usageUnit }}</span> - <span v-if="shouldRenderTotalBlock" data-testid="denominator-total"> - / - {{ totalValue }} - <span class="gl-font-lg">{{ totalUnit }}</span> - </span> - </p> + <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 data-testid="actions"> - <slot name="actions"></slot> + <div v-else> + <div class="gl-display-flex gl-justify-content-space-between"> + <p class="gl-font-size-h-display gl-font-weight-bold gl-mb-3" data-testid="denominator"> + {{ usageValue }} + <span class="gl-font-lg">{{ usageUnit }}</span> + <span v-if="shouldRenderTotalBlock" data-testid="denominator-total"> + / + {{ totalValue }} + <span class="gl-font-lg">{{ totalUnit }}</span> + </span> + </p> + + <div data-testid="actions"> + <slot name="actions"></slot> + </div> </div> + <p class="gl-font-weight-bold" data-testid="description"> + <slot name="description"></slot> + </p> + <gl-progress-bar v-if="shouldShowProgressBar" :value="percentage" /> </div> - <p class="gl-font-weight-bold" data-testid="description"> - <slot name="description"></slot> - </p> - <gl-progress-bar v-if="shouldShowProgressBar" :value="percentage" /> </div> </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 cd18fa5215022ba9bf3e681ecc51954a4f476c94..74aa4f3f366e89de17e4b45c4c0c68c242b3f6eb 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 @@ -44,6 +44,10 @@ export default { required: false, default: false, }, + loading: { + type: Boolean, + required: true, + }, }, i18n: { purchasedUsageHelpLink: projectHelpPaths.usageQuotas, @@ -91,6 +95,7 @@ export default { :used-storage="usedStorageAmount" :total-storage="storageLimitEnforced ? repositorySizeLimit : null" :show-progress-bar="storageLimitEnforced" + :loading="loading" data-testid="namespace-usage-card" class="gl-w-half gl-md-mr-5" > @@ -113,6 +118,7 @@ export default { :used-storage="purchasedUsedStorage" :total-storage="purchasedTotalStorage" :show-progress-bar="storageLimitEnforced" + :loading="loading" data-testid="purchased-usage-card" class="gl-w-half" > diff --git a/ee/spec/frontend/usage_quotas/storage/components/namespace_storage_app_spec.js b/ee/spec/frontend/usage_quotas/storage/components/namespace_storage_app_spec.js index 9182908713b23ba2322fde2f3903a2a8270df28a..3976da245d0440b6a36e075b048a583e3ae07505 100644 --- a/ee/spec/frontend/usage_quotas/storage/components/namespace_storage_app_spec.js +++ b/ee/spec/frontend/usage_quotas/storage/components/namespace_storage_app_spec.js @@ -1,64 +1,96 @@ -import { mount } from '@vue/test-utils'; +import { GlAlert } from '@gitlab/ui'; +import VueApollo from 'vue-apollo'; +import Vue from 'vue'; +import { mountExtended } from 'helpers/vue_test_utils_helper'; +import waitForPromises from 'helpers/wait_for_promises'; +import { captureException } from '~/runner/sentry_utils'; import NamespaceStorageApp from 'ee/usage_quotas/storage/components/namespace_storage_app.vue'; import CollapsibleProjectStorageDetail from 'ee/usage_quotas/storage/components/collapsible_project_storage_detail.vue'; -import ProjectList from 'ee/usage_quotas/storage/components/project_list.vue'; -import StorageInlineAlert from 'ee/usage_quotas/storage/components/storage_inline_alert.vue'; -import TemporaryStorageIncreaseModal from 'ee/usage_quotas/storage/components/temporary_storage_increase_modal.vue'; +import { createMockDirective, getBinding } from 'helpers/vue_mock_directive'; +import getNamespaceStorageQuery from 'ee/usage_quotas/storage/queries/namespace_storage.query.graphql'; +import getDependencyProxyTotalSizeQuery from 'ee/usage_quotas/storage/queries/dependency_proxy_usage.query.graphql'; +import createMockApollo from 'helpers/mock_apollo_helper'; +import { formatUsageSize } from 'ee/usage_quotas/storage/utils'; +import { numberToHumanSize } from '~/lib/utils/number_utils'; +import UsageGraph from 'ee/usage_quotas/storage/components/usage_graph.vue'; import UsageStatistics from 'ee/usage_quotas/storage/components/usage_statistics.vue'; import StorageUsageStatistics from 'ee/usage_quotas/storage/components/storage_usage_statistics.vue'; -import UsageGraph from 'ee/usage_quotas/storage/components/usage_graph.vue'; +import StorageInlineAlert from 'ee/usage_quotas/storage/components/storage_inline_alert.vue'; import DependencyProxyUsage from 'ee/usage_quotas/storage/components/dependency_proxy_usage.vue'; import ContainerRegistryUsage from 'ee/usage_quotas/storage/components/container_registry_usage.vue'; -import { formatUsageSize } from 'ee/usage_quotas/storage/utils'; -import { createMockDirective, getBinding } from 'helpers/vue_mock_directive'; +import ProjectList from 'ee/usage_quotas/storage/components/project_list.vue'; +import TemporaryStorageIncreaseModal from 'ee/usage_quotas/storage/components/temporary_storage_increase_modal.vue'; import { - namespaceData, - withRootStorageStatistics, defaultNamespaceProvideValues, + mockedNamespaceStorageResponse, + mockDependencyProxyResponse, } from '../mock_data'; const TEST_LIMIT = 1000; +jest.mock('~/flash'); +jest.mock('~/runner/sentry_utils'); + +Vue.use(VueApollo); + describe('NamespaceStorageApp', () => { let wrapper; - let $apollo; - const findTotalUsage = () => wrapper.find("[data-testid='total-usage']"); - const findPurchaseStorageLink = () => wrapper.find("[data-testid='purchase-storage-link']"); - const findTemporaryStorageIncreaseButton = () => - wrapper.find("[data-testid='temporary-storage-increase-button']"); + function createMockApolloProvider(response = mockedNamespaceStorageResponse) { + const successHandler = jest.fn().mockResolvedValue(response); + const requestHandlers = [ + [getNamespaceStorageQuery, successHandler], + [getDependencyProxyTotalSizeQuery, jest.fn().mockResolvedValue(mockDependencyProxyResponse)], + ]; + + return createMockApollo(requestHandlers); + } + + function createPendingMockApolloProvider() { + const successHandler = new Promise(() => {}); + const requestHandlers = [ + [getNamespaceStorageQuery, successHandler], + [getDependencyProxyTotalSizeQuery, jest.fn().mockResolvedValue(mockDependencyProxyResponse)], + ]; + + return createMockApollo(requestHandlers); + } + + function createFailedMockApolloProvider() { + const failedHandler = jest.fn().mockRejectedValue(new Error('Network error!')); + const requestHandlers = [ + [getNamespaceStorageQuery, failedHandler], + [getDependencyProxyTotalSizeQuery, jest.fn().mockResolvedValue(mockDependencyProxyResponse)], + ]; + + return createMockApollo(requestHandlers); + } + + const findTotalUsage = () => wrapper.findByTestId('total-usage'); const findUsageGraph = () => wrapper.findComponent(UsageGraph); const findUsageStatistics = () => wrapper.findComponent(UsageStatistics); - const findStorageUsageStatistics = () => wrapper.findComponent(StorageUsageStatistics); const findStorageInlineAlert = () => wrapper.findComponent(StorageInlineAlert); + const findPurchaseStorageLink = () => wrapper.find("[data-testid='purchase-storage-link']"); + const findDependencyProxy = () => wrapper.findComponent(DependencyProxyUsage); + const findStorageUsageStatistics = () => wrapper.findComponent(StorageUsageStatistics); + const findTemporaryStorageIncreaseButton = () => + wrapper.find("[data-testid='temporary-storage-increase-button']"); const findProjectList = () => wrapper.findComponent(ProjectList); const findPrevButton = () => wrapper.find('[data-testid="prevButton"]'); const findNextButton = () => wrapper.find('[data-testid="nextButton"]'); - const findDependencyProxy = () => wrapper.findComponent(DependencyProxyUsage); const findContainerRegistry = () => wrapper.findComponent(ContainerRegistryUsage); + const findAlert = () => wrapper.findComponent(GlAlert); const createComponent = ({ provide = {}, storageLimitEnforced = false, - loading = false, additionalRepoStorageByNamespace = false, - namespace = {}, dependencyProxyTotalSize = '', isFreeNamespace = false, + mockApollo = {}, } = {}) => { - $apollo = { - queries: { - namespace: { - loading, - }, - dependencyProxyTotalSize: { - loading, - }, - }, - }; - - wrapper = mount(NamespaceStorageApp, { - mocks: { $apollo }, + wrapper = mountExtended(NamespaceStorageApp, { + apolloProvider: mockApollo, directives: { GlModalDirective: createMockDirective(), }, @@ -73,104 +105,84 @@ describe('NamespaceStorageApp', () => { }, data() { return { - namespace, dependencyProxyTotalSize, }; }, }); }; - beforeEach(() => { - createComponent({ - namespace: namespaceData, - }); - }); + let mockApollo; afterEach(() => { wrapper.destroy(); }); - it('renders the 2 projects', async () => { - expect(wrapper.findAllComponents(CollapsibleProjectStorageDetail)).toHaveLength(3); - }); - - describe('limit', () => { - it('when limit is set it renders limit information', async () => { - expect(wrapper.text()).toContain(formatUsageSize(namespaceData.limit)); + describe('project list', () => { + beforeEach(async () => { + mockApollo = createMockApolloProvider(); + createComponent({ mockApollo }); + await waitForPromises(); }); - it('when limit is 0 it does not render limit information', async () => { - createComponent({ - namespace: { ...namespaceData, limit: 0 }, - }); - - expect(wrapper.text()).not.toContain(formatUsageSize(0)); + it('renders the 2 projects', () => { + expect(wrapper.findAllComponents(CollapsibleProjectStorageDetail)).toHaveLength(2); }); }); - it('renders Not applicable for totalUsage when no rootStorageStatistics is provided', async () => { - expect(findTotalUsage().text()).toContain('Not applicable.'); - }); + describe('size limit', () => { + it('does not render limit information when storageSizeLimit is 0', async () => { + const namespaceWithZeroLimit = { ...mockedNamespaceStorageResponse }; + namespaceWithZeroLimit.data.namespace.storageSizeLimit = 0; + mockApollo = createMockApolloProvider(namespaceWithZeroLimit); + createComponent({ mockApollo }); + await waitForPromises(); - describe('with rootStorageStatistics information', () => { - beforeEach(() => { - createComponent({ - namespace: withRootStorageStatistics, - }); + expect(wrapper.text()).not.toContain(formatUsageSize(0)); }); - it('renders total usage', async () => { - expect(findTotalUsage().text()).toContain(withRootStorageStatistics.totalUsage); - }); + it('renders limit information when storageSizeLimit is set to other numbers', async () => { + const namespaceWithLimit = { ...mockedNamespaceStorageResponse }; + namespaceWithLimit.data.namespace.storageSizeLimit = TEST_LIMIT; + mockApollo = createMockApolloProvider(namespaceWithLimit); + createComponent({ mockApollo }); + await waitForPromises(); - describe('with additional_repo_storage_by_namespace feature', () => { - it('usage_graph component hidden is when feature is false', async () => { - expect(findUsageGraph().exists()).toBe(true); - expect(findUsageStatistics().exists()).toBe(false); - expect(findStorageInlineAlert().exists()).toBe(false); - }); - - it('usage_statistics component is rendered when feature is true', async () => { - createComponent({ - additionalRepoStorageByNamespace: true, - namespace: withRootStorageStatistics, - }); - - expect(findUsageStatistics().exists()).toBe(true); - expect(findUsageGraph().exists()).toBe(false); - expect(findStorageInlineAlert().exists()).toBe(true); - }); + expect(wrapper.text()).toContain( + formatUsageSize(namespaceWithLimit.data.namespace.storageSizeLimit), + ); }); }); describe('purchase storage link', () => { - describe('when purchaseStorageUrl is not set', () => { - it('does not render an additional link', () => { - expect(findPurchaseStorageLink().exists()).toBe(false); - }); + it('does not render an additional link when purchaseStorageUrl is not set', async () => { + mockApollo = createMockApolloProvider(); + createComponent({ mockApollo }); + await waitForPromises(); + + expect(findPurchaseStorageLink().exists()).toBe(false); }); - describe('when purchaseStorageUrl is set', () => { - beforeEach(() => { - createComponent({ provide: { purchaseStorageUrl: 'customers.gitlab.com' } }); - }); + it('does render link when purchaseStorageUrl is set', async () => { + mockApollo = createMockApolloProvider(); + createComponent({ mockApollo, provide: { purchaseStorageUrl: 'customers.gitlab.com' } }); + await waitForPromises(); - it('does render link', () => { - const link = findPurchaseStorageLink(); + const link = findPurchaseStorageLink(); - expect(link.exists()).toBe(true); - expect(link.attributes('href')).toBe('customers.gitlab.com'); - }); + expect(link.exists()).toBe(true); + expect(link.attributes('href')).toBe('customers.gitlab.com'); }); }); describe('Dependency proxy usage', () => { - beforeEach(() => { + beforeEach(async () => { + mockApollo = createMockApolloProvider(); createComponent({ + mockApollo, additionalRepoStorageByNamespace: true, - namespace: withRootStorageStatistics, dependencyProxyTotalSize: '512 bytes', }); + await waitForPromises(); }); it('should show the dependency proxy usage component', () => { @@ -179,20 +191,28 @@ describe('NamespaceStorageApp', () => { }); describe('Container registry usage', () => { - it('should show the container registry usage component', () => { + it('should show the container registry usage component', async () => { + mockApollo = createMockApolloProvider(); createComponent({ - namespace: withRootStorageStatistics, + mockApollo, + additionalRepoStorageByNamespace: true, + dependencyProxyTotalSize: '512 bytes', }); + await waitForPromises(); expect(findContainerRegistry().exists()).toBe(true); expect(findContainerRegistry().props()).toEqual({ containerRegistrySize: - withRootStorageStatistics.rootStorageStatistics.containerRegistrySize, + mockedNamespaceStorageResponse.data.namespace.rootStorageStatistics.containerRegistrySize, }); }); }); describe('temporary storage increase', () => { + const namespaceWithLimit = { ...mockedNamespaceStorageResponse }; + namespaceWithLimit.data.namespace.storageSizeLimit = TEST_LIMIT; + mockApollo = createMockApolloProvider(namespaceWithLimit); + describe.each` provide | isVisible ${{}} | ${false} @@ -200,7 +220,7 @@ describe('NamespaceStorageApp', () => { ${{ isTemporaryStorageIncreaseVisible: 'true' }} | ${true} `('with $provide', ({ provide, isVisible }) => { beforeEach(() => { - createComponent({ provide }); + createComponent({ mockApollo, provide }); }); it(`renders button = ${isVisible}`, () => { @@ -209,14 +229,14 @@ describe('NamespaceStorageApp', () => { }); describe('when temporary storage increase is visible', () => { - beforeEach(() => { + beforeEach(async () => { + namespaceWithLimit.data.namespace.storageSizeLimit = TEST_LIMIT; + mockApollo = createMockApolloProvider(namespaceWithLimit); createComponent({ + mockApollo, provide: { isTemporaryStorageIncreaseVisible: 'true' }, - namespace: { - ...namespaceData, - limit: TEST_LIMIT, - }, }); + await waitForPromises(); }); it('binds button to modal', () => { @@ -241,9 +261,10 @@ describe('NamespaceStorageApp', () => { describe('filtering projects', () => { beforeEach(() => { + mockApollo = createMockApolloProvider(); createComponent({ + mockApollo, additionalRepoStorageByNamespace: true, - namespace: withRootStorageStatistics, }); }); @@ -282,22 +303,13 @@ describe('NamespaceStorageApp', () => { }); describe('projects table pagination component', () => { - const namespaceWithPageInfo = ( - pageInfo = { - hasPreviousPage: false, - hasNextPage: true, - }, - ) => ({ - namespace: { - ...withRootStorageStatistics, - projects: { - ...withRootStorageStatistics.projects, - pageInfo, - }, - }, - }); - beforeEach(() => { - createComponent(namespaceWithPageInfo()); + const namespaceWithPageInfo = { ...mockedNamespaceStorageResponse }; + namespaceWithPageInfo.data.namespace.projects.pageInfo.hasNextPage = true; + + beforeEach(async () => { + mockApollo = createMockApolloProvider(namespaceWithPageInfo); + createComponent({ mockApollo }); + await waitForPromises(); }); it('has "Prev" button disabled', () => { @@ -309,19 +321,22 @@ describe('NamespaceStorageApp', () => { }); describe('apollo calls', () => { - beforeEach(() => { - createComponent( - namespaceWithPageInfo({ - hasPreviousPage: true, - hasNextPage: true, - }), - ); - $apollo.queries.namespace.fetchMore = jest.fn().mockResolvedValue(); + beforeEach(async () => { + namespaceWithPageInfo.data.namespace.projects.pageInfo.hasPreviousPage = true; + namespaceWithPageInfo.data.namespace.projects.pageInfo.hasNextPage = true; + mockApollo = createMockApolloProvider(namespaceWithPageInfo); + createComponent({ mockApollo }); + + jest + .spyOn(wrapper.vm.$apollo.queries.namespace, 'fetchMore') + .mockImplementation(jest.fn().mockResolvedValue({})); + + await waitForPromises(); }); - it('contains correct `first` and `last` values when clicking "Prev" button', () => { + it('contains correct `first` and `last` values when clicking "Prev" button', async () => { findPrevButton().trigger('click'); - expect($apollo.queries.namespace.fetchMore).toHaveBeenCalledWith( + expect(wrapper.vm.$apollo.queries.namespace.fetchMore).toHaveBeenCalledWith( expect.objectContaining({ variables: expect.objectContaining({ first: undefined, last: expect.any(Number) }), }), @@ -330,24 +345,46 @@ describe('NamespaceStorageApp', () => { it('contains `first` value when clicking "Next" button', () => { findNextButton().trigger('click'); - expect($apollo.queries.namespace.fetchMore).toHaveBeenCalledWith( + expect(wrapper.vm.$apollo.queries.namespace.fetchMore).toHaveBeenCalledWith( expect.objectContaining({ variables: expect.objectContaining({ first: expect.any(Number) }), }), ); }); }); + + describe('handling failed apollo requests', () => { + beforeEach(async () => { + mockApollo = createFailedMockApolloProvider(); + createComponent({ mockApollo }); + + await waitForPromises(); + }); + + it('shows gl-alert with error message', () => { + expect(findAlert().exists()).toBe(true); + expect(findAlert().text()).toBe('Something went wrong while loading usage details'); + }); + + it('captures the exception in Sentry', async () => { + await Vue.nextTick(); + expect(captureException).toHaveBeenCalledTimes(1); + }); + }); }); describe('new storage statistics usage design', () => { describe('when namespace is on free plan', () => { - beforeEach(() => { + beforeEach(async () => { + mockApollo = createMockApolloProvider(); + createComponent({ additionalRepoStorageByNamespace: true, - namespace: withRootStorageStatistics, storageLimitEnforced: true, isFreeNamespace: true, + mockApollo, }); + await waitForPromises(); }); it('renders the new storage design', () => { @@ -360,19 +397,52 @@ describe('NamespaceStorageApp', () => { it('passes storageSize as totalRepositorySize', () => { expect(findStorageUsageStatistics().props('totalRepositorySize')).toBe( - withRootStorageStatistics.rootStorageStatistics.storageSize, + mockedNamespaceStorageResponse.data.namespace.rootStorageStatistics.storageSize, + ); + }); + + describe('loading', () => { + it.each` + loadingError | queryLoading | expectedValue + ${true} | ${false} | ${true} + ${false} | ${true} | ${true} + ${false} | ${false} | ${false} + `( + 'pass loading prop as $expectedValue if loadingError is $loadingError and queryLoading is $queryLoading', + async ({ loadingError, queryLoading, expectedValue }) => { + // change mockApollo provider based on loadingError and queryLoading + if (loadingError) { + mockApollo = createFailedMockApolloProvider(); + } else if (queryLoading) { + mockApollo = createPendingMockApolloProvider(); + } else { + mockApollo = createMockApolloProvider(); + } + + createComponent({ + additionalRepoStorageByNamespace: true, + storageLimitEnforced: true, + isFreeNamespace: true, + mockApollo, + }); + + await waitForPromises(); + + expect(findStorageUsageStatistics().props('loading')).toBe(expectedValue); + }, ); }); }); describe('when namespace is not on free plan', () => { - beforeEach(() => { + beforeEach(async () => { createComponent({ additionalRepoStorageByNamespace: true, - namespace: withRootStorageStatistics, + mockApollo, storageLimitEnforced: true, isFreeNamespace: false, }); + await waitForPromises(); }); it('does not render the new storage design', () => { @@ -380,4 +450,70 @@ describe('NamespaceStorageApp', () => { }); }); }); + + describe('with rootStorageStatistics available on namespace', () => { + beforeEach(async () => { + mockApollo = createMockApolloProvider(); + createComponent({ mockApollo }); + await waitForPromises(); + }); + + it('renders total usage', async () => { + expect(findTotalUsage().text()).toContain( + numberToHumanSize( + mockedNamespaceStorageResponse.data.namespace.rootStorageStatistics.storageSize, + ), + ); + }); + + describe('with additional_repo_storage_by_namespace feature', () => { + it('usage_graph component hidden is when feature is false', async () => { + expect(findUsageGraph().exists()).toBe(true); + expect(findUsageStatistics().exists()).toBe(false); + expect(findStorageInlineAlert().exists()).toBe(false); + }); + + it('usage_statistics component is rendered when feature is true', async () => { + mockApollo = createMockApolloProvider(); + + createComponent({ + mockApollo, + additionalRepoStorageByNamespace: true, + }); + await waitForPromises(); + + expect(findUsageStatistics().exists()).toBe(true); + expect(findUsageGraph().exists()).toBe(false); + expect(findStorageInlineAlert().exists()).toBe(true); + }); + }); + + describe('findStorageInlineAlert', () => { + it('does not show storage inline alert if namespace is empty', async () => { + // creating failed mock provider will make namespace = {} + mockApollo = createFailedMockApolloProvider(); + createComponent({ + additionalRepoStorageByNamespace: true, + storageLimitEnforced: true, + isFreeNamespace: true, + mockApollo, + }); + + await waitForPromises(); + expect(findStorageInlineAlert().exists()).toBe(false); + }); + }); + }); + + describe('without rootStorageStatistics available on namespace', () => { + it('renders Not applicable for totalUsage when no rootStorageStatistics is provided', async () => { + const namespaceWithoutStatistics = { ...mockedNamespaceStorageResponse }; + namespaceWithoutStatistics.data.namespace.rootStorageStatistics = null; + mockApollo = createMockApolloProvider(namespaceWithoutStatistics); + createComponent({ mockApollo }); + await waitForPromises(); + + expect(findTotalUsage().text()).toContain('Not applicable'); + }); + }); }); diff --git a/ee/spec/frontend/usage_quotas/storage/components/storage_statistics_card_spec.js b/ee/spec/frontend/usage_quotas/storage/components/storage_statistics_card_spec.js index eb87000a2a11b13bc334f7649c8e2a907d8a0fce..2bfe9855eabe4a549725cf6493a0ad9e0d45326e 100644 --- a/ee/spec/frontend/usage_quotas/storage/components/storage_statistics_card_spec.js +++ b/ee/spec/frontend/usage_quotas/storage/components/storage_statistics_card_spec.js @@ -1,4 +1,4 @@ -import { GlProgressBar } from '@gitlab/ui'; +import { GlProgressBar, GlSkeletonLoader } from '@gitlab/ui'; import StorageStatisticsCard from 'ee/usage_quotas/storage/components/storage_statistics_card.vue'; import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; import { statisticsCardDefaultProps } from '../mock_data'; @@ -21,6 +21,11 @@ describe('StorageStatisticsCard', () => { const findDescriptionBlock = () => wrapper.findByTestId('description'); const findActionsBlock = () => wrapper.findByTestId('actions'); const findProgressBar = () => wrapper.findComponent(GlProgressBar); + const findSkeletonLoader = () => wrapper.findComponent(GlSkeletonLoader); + + afterEach(() => { + wrapper.destroy(); + }); describe('denominator block', () => { it('renders denominator block with all elements when all props are passed', () => { @@ -97,4 +102,16 @@ describe('StorageStatisticsCard', () => { expect(findProgressBar().attributes('value')).toBe(String(50)); }); }); + + describe('skeleton loader', () => { + it('renders skeleton loader when loading prop is true', () => { + createComponent({ loading: true }); + expect(findSkeletonLoader().exists()).toBe(true); + }); + + it('does not render skeleton loader when loading prop is false', () => { + createComponent({ 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 4a84c78da361872e90d51ee5bcd8f89e8e5b6dfc..eb58e5a6b372dae425d6fc1607918a4f100dc3b9 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 @@ -16,6 +16,7 @@ describe('StorageUsageStatistics', () => { totalRepositorySizeExcess: withRootStorageStatistics.totalRepositorySizeExcess, additionalPurchasedStorageSize: withRootStorageStatistics.additionalPurchasedStorageSize, storageLimitEnforced: true, + loading: false, ...props, }, provide: { @@ -51,6 +52,7 @@ describe('StorageUsageStatistics', () => { createComponent(); expect(findNamespaceStorageCard().props()).toEqual({ + loading: false, showProgressBar: true, totalStorage: withRootStorageStatistics.totalRepositorySize, usedStorage: withRootStorageStatistics.actualRepositorySizeLimit, @@ -61,6 +63,7 @@ describe('StorageUsageStatistics', () => { createComponent({ props: { storageLimitEnforced: true } }); expect(findNamespaceStorageCard().props()).toEqual({ + loading: false, showProgressBar: true, totalStorage: withRootStorageStatistics.totalRepositorySize, usedStorage: withRootStorageStatistics.totalRepositorySize, @@ -71,6 +74,7 @@ describe('StorageUsageStatistics', () => { createComponent({ props: { storageLimitEnforced: false } }); expect(findNamespaceStorageCard().props()).toEqual({ + loading: false, showProgressBar: false, totalStorage: null, usedStorage: withRootStorageStatistics.totalRepositorySize, @@ -101,6 +105,7 @@ describe('StorageUsageStatistics', () => { it('passes the correct props when storageLimitEnforced is true', () => { createComponent({ props: { storageLimitEnforced: true } }); expect(findPurchasedStorageCard().props()).toEqual({ + loading: false, showProgressBar: true, totalStorage: withRootStorageStatistics.additionalPurchasedStorageSize, usedStorage: withRootStorageStatistics.totalRepositorySizeExcess, @@ -109,6 +114,7 @@ describe('StorageUsageStatistics', () => { it('passes the correct props when storageLimitEnforced is false', () => { createComponent({ props: { storageLimitEnforced: false } }); expect(findPurchasedStorageCard().props()).toEqual({ + loading: false, showProgressBar: false, totalStorage: withRootStorageStatistics.additionalPurchasedStorageSize, usedStorage: withRootStorageStatistics.totalRepositorySizeExcess, diff --git a/ee/spec/frontend/usage_quotas/storage/mock_data.js b/ee/spec/frontend/usage_quotas/storage/mock_data.js index 265afbe8821b6ae15642e5bf1ff5b147f9a65a52..c74770f223adfb5e2d5e93a929d326353b9d9e06 100644 --- a/ee/spec/frontend/usage_quotas/storage/mock_data.js +++ b/ee/spec/frontend/usage_quotas/storage/mock_data.js @@ -20,10 +20,14 @@ export const projects = [ lfsObjectsSize: 0, buildArtifactsSize: 0, packagesSize: 0, + wikiSize: 104800, + snippetsSize: 0, + uploadsSize: 0, }, actualRepositorySizeLimit: 100000, totalCalculatedUsedStorage: 41943, totalCalculatedStorageLimit: 41943000, + repositorySizeExcess: 0, }, { id: '8', @@ -40,10 +44,14 @@ export const projects = [ lfsObjectsSize: 0, buildArtifactsSize: 1272375, packagesSize: 0, + wikiSize: 104800, + snippetsSize: 0, + uploadsSize: 0, }, actualRepositorySizeLimit: 100000, totalCalculatedUsedStorage: 89000, totalCalculatedStorageLimit: 99430, + repositorySizeExcess: 0, }, { id: '80', @@ -60,10 +68,14 @@ export const projects = [ lfsObjectsSize: 209720, buildArtifactsSize: 1272375, packagesSize: 0, + wikiSize: 104800, + snippetsSize: 0, + uploadsSize: 0, }, actualRepositorySizeLimit: 100000, totalCalculatedUsedStorage: 13143170, totalCalculatedStorageLimit: 12143170, + repositorySizeExcess: 0, }, ]; @@ -209,4 +221,105 @@ export const statisticsCardDefaultProps = { totalStorage: 100 * 1024, usedStorage: 50 * 1024, hideProgressBar: false, + loading: false, +}; + +export const mockedNamespaceStorageResponse = { + data: { + namespace: { + id: 'gid://gitlab/Group/84', + name: 'wandercatgroup2', + storageSizeLimit: 0, + actualRepositorySizeLimit: 10737418240, + additionalPurchasedStorageSize: 0, + totalRepositorySizeExcess: 0, + totalRepositorySize: 20971, + containsLockedProjects: false, + repositorySizeExcessProjectCount: 0, + rootStorageStatistics: { + containerRegistrySize: 3_900_000, + storageSize: 125771, + repositorySize: 20971, + lfsObjectsSize: 0, + buildArtifactsSize: 0, + pipelineArtifactsSize: 0, + packagesSize: 0, + wikiSize: 104800, + snippetsSize: 0, + uploadsSize: 0, + __typename: 'RootStorageStatistics', + }, + projects: { + nodes: [ + { + id: 'gid://gitlab/Project/20', + fullPath: 'wandercatgroup2/not-so-empty-project', + nameWithNamespace: 'wandercatgroup2 / not so empty project', + avatarUrl: null, + webUrl: 'http://gdk.test:3000/wandercatgroup2/not-so-empty-project', + name: 'not so empty project', + repositorySizeExcess: 0, + actualRepositorySizeLimit: 10737418240, + statistics: { + commitCount: 1, + storageSize: 125771, + repositorySize: 20971, + lfsObjectsSize: 0, + containerRegistrySize: 0, + buildArtifactsSize: 0, + packagesSize: 0, + wikiSize: 104800, + snippetsSize: 0, + uploadsSize: 0, + __typename: 'ProjectStatistics', + }, + __typename: 'Project', + }, + { + id: 'gid://gitlab/Project/21', + fullPath: 'wandercatgroup2/not-so-empty-project1', + nameWithNamespace: 'wandercatgroup2 / not so empty project', + avatarUrl: null, + webUrl: 'http://gdk.test:3000/wandercatgroup2/not-so-empty-project', + name: 'not so empty project', + repositorySizeExcess: 0, + actualRepositorySizeLimit: 10737418240, + statistics: { + commitCount: 1, + storageSize: 125771, + repositorySize: 20971, + lfsObjectsSize: 0, + containerRegistrySize: 0, + buildArtifactsSize: 0, + packagesSize: 0, + wikiSize: 104800, + snippetsSize: 0, + uploadsSize: 0, + __typename: 'ProjectStatistics', + }, + __typename: 'Project', + }, + ], + pageInfo: { + __typename: 'PageInfo', + hasNextPage: false, + hasPreviousPage: false, + startCursor: 'eyJpZCI6IjIwIiwiZXhjZXNzX3N0b3JhZ2UiOiItMTA3MzczOTcyNjkifQ', + endCursor: 'eyJpZCI6IjIwIiwiZXhjZXNzX3N0b3JhZ2UiOiItMTA3MzczOTcyNjkifQ', + }, + __typename: 'ProjectConnection', + }, + __typename: 'Namespace', + }, + }, +}; + +export const mockDependencyProxyResponse = { + data: { + group: { + id: 'gid://gitlab/Group/84', + dependencyProxyTotalSize: '0 Bytes', + __typename: 'Group', + }, + }, }; diff --git a/locale/gitlab.pot b/locale/gitlab.pot index d3e55ff9df31061e98df5fbd5b658502e4a21c79..0c43c754c865f29eb03f098db825b3953ff5a96f 100644 --- a/locale/gitlab.pot +++ b/locale/gitlab.pot @@ -41232,6 +41232,9 @@ msgstr "" msgid "UsageQuota|Something went wrong while fetching project storage statistics" msgstr "" +msgid "UsageQuota|Something went wrong while loading usage details" +msgstr "" + msgid "UsageQuota|Storage" msgstr ""