diff --git a/ee/app/assets/javascripts/usage_quotas/product_analytics/components/group_usage/product_analytics_group_monthly_usage_chart.vue b/ee/app/assets/javascripts/usage_quotas/product_analytics/components/group_usage/product_analytics_group_monthly_usage_chart.vue new file mode 100644 index 0000000000000000000000000000000000000000..ad0e0eb51ef0884b5776c8dd34f368f89e1924ab --- /dev/null +++ b/ee/app/assets/javascripts/usage_quotas/product_analytics/components/group_usage/product_analytics_group_monthly_usage_chart.vue @@ -0,0 +1,57 @@ +<script> +import { GlSkeletonLoader } from '@gitlab/ui'; +import { GlAreaChart } from '@gitlab/ui/dist/charts'; + +import { s__ } from '~/locale'; + +import { monthlyTotalsValidator } from '../utils'; + +export default { + name: 'ProductAnalyticsGroupMonthlyUsageChart', + components: { + GlAreaChart, + GlSkeletonLoader, + }, + props: { + isLoading: { + type: Boolean, + required: true, + }, + monthlyTotals: { + type: Array, + required: false, + default: null, + validator: monthlyTotalsValidator, + }, + }, + computed: { + chartData() { + return [ + { + name: s__('ProductAnalytics|Analytics events by month'), + data: this.monthlyTotals, + }, + ]; + }, + }, + CHART_OPTIONS: { + yAxis: { + name: s__('ProductAnalytics|Events'), + }, + xAxis: { + name: s__('ProductAnalytics|Month'), + type: 'category', + }, + }, +}; +</script> +<template> + <section> + <h2 class="gl-font-lg">{{ s__('ProductAnalytics|Usage by month') }}</h2> + + <gl-skeleton-loader v-if="isLoading" :lines="3" /> + <template v-else> + <gl-area-chart :data="chartData" :option="$options.CHART_OPTIONS" /> + </template> + </section> +</template> diff --git a/ee/app/assets/javascripts/usage_quotas/product_analytics/components/product_analytics_group_usage.vue b/ee/app/assets/javascripts/usage_quotas/product_analytics/components/group_usage/product_analytics_group_usage.vue similarity index 67% rename from ee/app/assets/javascripts/usage_quotas/product_analytics/components/product_analytics_group_usage.vue rename to ee/app/assets/javascripts/usage_quotas/product_analytics/components/group_usage/product_analytics_group_usage.vue index 6de9c86a3096f0eedf0477b003c0e6e3bcef2681..6e36161a9440769d82a3effae9d362f3029b9aca 100644 --- a/ee/app/assets/javascripts/usage_quotas/product_analytics/components/product_analytics_group_usage.vue +++ b/ee/app/assets/javascripts/usage_quotas/product_analytics/components/group_usage/product_analytics_group_usage.vue @@ -1,25 +1,25 @@ <script> -import { GlAlert, GlLink, GlSkeletonLoader, GlSprintf } from '@gitlab/ui'; -import { GlAreaChart } from '@gitlab/ui/dist/charts'; +import { GlAlert, GlLink, GlSprintf } from '@gitlab/ui'; import * as Sentry from '~/sentry/sentry_browser_wrapper'; import { nMonthsBefore } from '~/lib/utils/datetime/date_calculation_utility'; -import { s__ } from '~/locale'; import { helpPagePath } from '~/helpers/help_page_helper'; import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; -import { projectHasProductAnalyticsEnabled } from '../utils'; -import getGroupProductAnalyticsUsage from '../graphql/queries/get_group_product_analytics_usage.query.graphql'; -import { getCurrentMonth, mapMonthlyTotals } from './utils'; +import { projectHasProductAnalyticsEnabled } from '../../utils'; +import getGroupProductAnalyticsUsage from '../../graphql/queries/get_group_product_analytics_usage.query.graphql'; +import { findCurrentMonthEventsUsed, getCurrentMonth, mapMonthlyTotals } from '../utils'; +import ProductAnalyticsGroupUsageOverview from './product_analytics_group_usage_overview.vue'; +import ProductAnalyticsGroupMonthlyUsageChart from './product_analytics_group_monthly_usage_chart.vue'; export default { name: 'ProductAnalyticsGroupUsage', components: { GlAlert, - GlAreaChart, GlLink, - GlSkeletonLoader, GlSprintf, + ProductAnalyticsGroupMonthlyUsageChart, + ProductAnalyticsGroupUsageOverview, }, mixins: [glFeatureFlagsMixin()], inject: { @@ -30,24 +30,23 @@ export default { data() { return { error: null, - projectsUsageData: null, + monthlyTotals: null, + storedEventsLimit: null, }; }, computed: { isLoading() { - return this.$apollo.queries.projectsUsageData.loading; + return this.$apollo.queries.monthlyTotals.loading; }, - chartData() { - return [ - { - name: s__('ProductAnalytics|Analytics events by month'), - data: this.projectsUsageData, - }, - ]; + showUsageOverview() { + return this.glFeatures.productAnalyticsBilling; + }, + eventsUsed() { + return findCurrentMonthEventsUsed(this.monthlyTotals); }, }, apollo: { - projectsUsageData: { + monthlyTotals: { query: getGroupProductAnalyticsUsage, variables() { return { @@ -58,6 +57,8 @@ export default { update(data) { const projects = data.group.projects.nodes.filter(projectHasProductAnalyticsEnabled); + this.storedEventsLimit = data.group.productAnalyticsStoredEventsLimit; + if (projects.length === 0) { this.$emit('no-projects'); return []; @@ -90,15 +91,6 @@ export default { }); }, }, - CHART_OPTIONS: { - yAxis: { - name: s__('ProductAnalytics|Events'), - }, - xAxis: { - name: s__('ProductAnalytics|Month'), - type: 'category', - }, - }, LEARN_MORE_URL: helpPagePath('/user/product_analytics/index', { anchor: 'product-analytics-usage-quota', }), @@ -106,7 +98,7 @@ export default { </script> <template> <section class="gl-mt-5 gl-mb-7"> - <h2 class="gl-font-lg">{{ s__('ProductAnalytics|Usage by month') }}</h2> + <h2>{{ s__('Analytics|Overview') }}</h2> <p> <gl-sprintf :message=" @@ -124,6 +116,7 @@ export default { </template> </gl-sprintf> </p> + <gl-alert v-if="error" variant="danger" :dismissible="false"> {{ s__( @@ -131,9 +124,19 @@ export default { ) }} </gl-alert> - <gl-skeleton-loader v-else-if="isLoading" :lines="3" /> <template v-else> - <gl-area-chart :data="chartData" :option="$options.CHART_OPTIONS" /> + <product-analytics-group-usage-overview + v-if="showUsageOverview" + :events-used="eventsUsed" + :stored-events-limit="storedEventsLimit" + :is-loading="isLoading" + /> + + <h2>{{ s__('Analytics|Usage breakdown') }}</h2> + <product-analytics-group-monthly-usage-chart + :is-loading="isLoading" + :monthly-totals="monthlyTotals" + /> </template> </section> </template> diff --git a/ee/app/assets/javascripts/usage_quotas/product_analytics/components/group_usage/product_analytics_group_usage_overview.vue b/ee/app/assets/javascripts/usage_quotas/product_analytics/components/group_usage/product_analytics_group_usage_overview.vue new file mode 100644 index 0000000000000000000000000000000000000000..84b477d0520d425efb2bfa61ef10abf116cf9540 --- /dev/null +++ b/ee/app/assets/javascripts/usage_quotas/product_analytics/components/group_usage/product_analytics_group_usage_overview.vue @@ -0,0 +1,64 @@ +<script> +import { formatNumber, s__ } from '~/locale'; +import { formatDate } from '~/lib/utils/datetime/date_format_utility'; +import { SHORT_DATE_FORMAT } from '~/vue_shared/constants'; + +import StatisticsCard from '../../../components/statistics_card.vue'; +import { getCurrentMonth } from '../utils'; + +export default { + name: 'ProductAnalyticsGroupUsageOverview', + components: { StatisticsCard }, + props: { + eventsUsed: { + type: Number, + required: false, + default: null, + }, + storedEventsLimit: { + type: Number, + required: false, + default: null, + }, + isLoading: { + type: Boolean, + required: false, + }, + }, + computed: { + description() { + const currentMonth = getCurrentMonth(); + return s__(`Analytics|Events received since ${formatDate(currentMonth, SHORT_DATE_FORMAT)}`); + }, + eventsUsedPercentage() { + if (!this.storedEventsLimit) { + return 100; + } + + const used = Math.floor((this.eventsUsed / this.storedEventsLimit) * 100); + + return Math.min(used, 100); + }, + formattedEventsUsed() { + return formatNumber(this.eventsUsed); + }, + formattedStoredEventsLimit() { + return formatNumber(this.storedEventsLimit); + }, + showStatisticsCard() { + return this.isLoading || this.storedEventsLimit !== null; + }, + }, +}; +</script> + +<template> + <statistics-card + v-if="showStatisticsCard" + :loading="isLoading" + :usage-value="formattedEventsUsed" + :total-value="formattedStoredEventsLimit" + :description="description" + :percentage="eventsUsedPercentage" + /> +</template> diff --git a/ee/app/assets/javascripts/usage_quotas/product_analytics/components/product_analytics_usage_quota_app.vue b/ee/app/assets/javascripts/usage_quotas/product_analytics/components/product_analytics_usage_quota_app.vue index 054b7576fde0e6f3018f9ea378af7b453d834137..efde40163f8868de9f58b89d46e31441dc5ecaf2 100644 --- a/ee/app/assets/javascripts/usage_quotas/product_analytics/components/product_analytics_usage_quota_app.vue +++ b/ee/app/assets/javascripts/usage_quotas/product_analytics/components/product_analytics_usage_quota_app.vue @@ -3,7 +3,7 @@ import { GlEmptyState } from '@gitlab/ui'; import { helpPagePath } from '~/helpers/help_page_helper'; -import ProductAnalyticsGroupUsage from './product_analytics_group_usage.vue'; +import ProductAnalyticsGroupUsage from './group_usage/product_analytics_group_usage.vue'; import ProductAnalyticsProjectsUsage from './projects_usage/product_analytics_projects_usage.vue'; export default { diff --git a/ee/app/assets/javascripts/usage_quotas/product_analytics/components/utils.js b/ee/app/assets/javascripts/usage_quotas/product_analytics/components/utils.js index 77181eeb5c1ab72d99d1cad0adbd5435631d0006..142af323473e217b718a4c15293148a984976251 100644 --- a/ee/app/assets/javascripts/usage_quotas/product_analytics/components/utils.js +++ b/ee/app/assets/javascripts/usage_quotas/product_analytics/components/utils.js @@ -24,6 +24,15 @@ export const projectsUsageDataValidator = (items) => { return Array.isArray(items) && items.every(isValidProject); }; +export const monthlyTotalsValidator = (items) => { + return ( + Array.isArray(items) && + items.every( + ([monthLabel, count]) => typeof monthLabel === 'string' && typeof count === 'number', + ) + ); +}; + export const getCurrentMonth = () => { return dateAtFirstDayOfMonth(new Date()); }; @@ -44,6 +53,10 @@ export const findPreviousMonthUsage = (project) => { return findMonthsUsage(project, 1); }; +const formatMonthLabel = (date) => { + return formatDate(date, 'mmm yyyy'); +}; + /** * Maps projects into an array of monthLabel, numEvents pairs. */ @@ -60,5 +73,11 @@ export const mapMonthlyTotals = (projects) => { return Array.from(Object.entries(monthlyTotals)) .map(([timestampString, count]) => [Number(timestampString), count]) .sort(([timestampA], [timestampB]) => timestampA - timestampB) - .map(([timestamp, count]) => [formatDate(new Date(timestamp), 'mmm yyyy'), count]); + .map(([timestamp, count]) => [formatMonthLabel(new Date(timestamp)), count]); +}; + +export const findCurrentMonthEventsUsed = (monthlyTotals) => { + const currentMonthLabel = formatMonthLabel(getCurrentMonth()); + + return monthlyTotals?.find(([dateLabel]) => dateLabel === currentMonthLabel)?.at(1); }; diff --git a/ee/app/assets/javascripts/usage_quotas/product_analytics/graphql/queries/get_group_product_analytics_usage.query.graphql b/ee/app/assets/javascripts/usage_quotas/product_analytics/graphql/queries/get_group_product_analytics_usage.query.graphql index 0eaba32c8ca544765bbd43d5f3ace72afcbaf7c5..d214020dfe45dac5c2f7ee4082d8b41677992239 100644 --- a/ee/app/assets/javascripts/usage_quotas/product_analytics/graphql/queries/get_group_product_analytics_usage.query.graphql +++ b/ee/app/assets/javascripts/usage_quotas/product_analytics/graphql/queries/get_group_product_analytics_usage.query.graphql @@ -1,6 +1,7 @@ query getGroupProductAnalyticsUsage($namespacePath: ID!, $monthSelection: [MonthSelectionInput!]!) { group(fullPath: $namespacePath) { id + productAnalyticsStoredEventsLimit projects(includeSubgroups: true) { nodes { id diff --git a/ee/app/controllers/ee/groups/usage_quotas_controller.rb b/ee/app/controllers/ee/groups/usage_quotas_controller.rb index f1b5891b290c00fc72ad4cec5c0cb9b5b691eb21..06f7abd4a2d72270e50ada787fa5e88b07676db5 100644 --- a/ee/app/controllers/ee/groups/usage_quotas_controller.rb +++ b/ee/app/controllers/ee/groups/usage_quotas_controller.rb @@ -16,6 +16,7 @@ module UsageQuotasController push_frontend_feature_flag(:limited_access_modal) push_frontend_feature_flag(:enable_add_on_users_filtering, group) push_frontend_feature_flag(:product_analytics_usage_quota_annual_data, group) + push_frontend_feature_flag(:product_analytics_billing, type: :wip) end end diff --git a/ee/spec/frontend/usage_quotas/product_analytics/components/group_usage/product_analytics_group_monthly_usage_chart_spec.js b/ee/spec/frontend/usage_quotas/product_analytics/components/group_usage/product_analytics_group_monthly_usage_chart_spec.js new file mode 100644 index 0000000000000000000000000000000000000000..24c4bb806dcc7e0a21ec69bf7c2634637d6e3854 --- /dev/null +++ b/ee/spec/frontend/usage_quotas/product_analytics/components/group_usage/product_analytics_group_monthly_usage_chart_spec.js @@ -0,0 +1,64 @@ +import { GlSkeletonLoader } from '@gitlab/ui'; +import { GlAreaChart } from '@gitlab/ui/dist/charts'; +import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; + +import ProductAnalyticsGroupMonthlyUsageChart from 'ee/usage_quotas/product_analytics/components/group_usage/product_analytics_group_monthly_usage_chart.vue'; + +describe('ProductAnalyticsGroupMonthlyUsageChart', () => { + /** @type {import('helpers/vue_test_utils_helper').ExtendedWrapper} */ + let wrapper; + + const findSkeletonLoader = () => wrapper.findComponent(GlSkeletonLoader); + const findChart = () => wrapper.findComponent(GlAreaChart); + + const createComponent = (props = {}) => { + wrapper = shallowMountExtended(ProductAnalyticsGroupMonthlyUsageChart, { + propsData: { + ...props, + }, + }); + }; + + it('renders a section header', () => { + createComponent(); + + expect(wrapper.text()).toContain('Usage by month'); + }); + + describe('when loading', () => { + beforeEach(() => { + createComponent({ isLoading: true }); + }); + + it('renders the loading state', () => { + expect(findSkeletonLoader().exists()).toBe(true); + }); + + it('does not render the chart', () => { + expect(findChart().exists()).toBe(false); + }); + }); + + describe('once loaded', () => { + const monthlyTotals = [['Nov 2023', 1234]]; + + beforeEach(() => { + createComponent({ isLoading: false, monthlyTotals }); + }); + + it('does not render the loading state', () => { + expect(findSkeletonLoader().exists()).toBe(false); + }); + + it('renders the chart', () => { + expect(findChart().props()).toMatchObject({ + data: [ + { + name: 'Analytics events by month', + data: [['Nov 2023', 1234]], + }, + ], + }); + }); + }); +}); diff --git a/ee/spec/frontend/usage_quotas/product_analytics/components/group_usage/product_analytics_group_usage_overview_spec.js b/ee/spec/frontend/usage_quotas/product_analytics/components/group_usage/product_analytics_group_usage_overview_spec.js new file mode 100644 index 0000000000000000000000000000000000000000..3d4933e0af4e079747747aa90ea17763b2825e71 --- /dev/null +++ b/ee/spec/frontend/usage_quotas/product_analytics/components/group_usage/product_analytics_group_usage_overview_spec.js @@ -0,0 +1,68 @@ +import { useFakeDate } from 'helpers/fake_date'; +import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; + +import ProductAnalyticsGroupUsageOverview from 'ee/usage_quotas/product_analytics/components/group_usage/product_analytics_group_usage_overview.vue'; +import StatisticsCard from 'ee/usage_quotas/components/statistics_card.vue'; + +describe('ProductAnalyticsGroupUsageOverview', () => { + /** @type {import('helpers/vue_test_utils_helper').ExtendedWrapper} */ + let wrapper; + + const mockNow = '2023-01-15T12:00:00Z'; + useFakeDate(mockNow); + + const findStatisticsCard = () => wrapper.findComponent(StatisticsCard); + + const createWrapper = (props = {}) => { + wrapper = shallowMountExtended(ProductAnalyticsGroupUsageOverview, { + propsData: { + ...props, + }, + }); + }; + + it('should render loading indicators', () => { + createWrapper({ isLoading: true }); + + expect(findStatisticsCard().props('loading')).toBe(true); + }); + + describe('once loaded', () => { + describe('with no events limit set', () => { + beforeEach(() => { + createWrapper({ + isLoading: false, + eventsUsed: 123456, + storedEventsLimit: null, + }); + }); + + it('should not render statistics card', () => { + expect(findStatisticsCard().exists()).toBe(false); + }); + }); + + describe('with an events limit set', () => { + beforeEach(() => { + createWrapper({ + isLoading: false, + eventsUsed: 123456, + storedEventsLimit: 1000000, + }); + }); + + it('should not render loading indicators', () => { + expect(findStatisticsCard().props('loading')).toBe(false); + }); + + it('should render the statistics card', () => { + expect(findStatisticsCard().props()).toMatchObject({ + description: 'Events received since Jan 01, 2023', + percentage: 12, + totalValue: '1,000,000', + usageValue: '123,456', + }); + }); + }); + }); +}); diff --git a/ee/spec/frontend/usage_quotas/product_analytics/components/product_analytics_group_usage_spec.js b/ee/spec/frontend/usage_quotas/product_analytics/components/group_usage/product_analytics_group_usage_spec.js similarity index 62% rename from ee/spec/frontend/usage_quotas/product_analytics/components/product_analytics_group_usage_spec.js rename to ee/spec/frontend/usage_quotas/product_analytics/components/group_usage/product_analytics_group_usage_spec.js index 876186d26968c335d01a12aea80104cb5869c267..f3791eccd4ff59c91f5e41d72d227c2970a6de28 100644 --- a/ee/spec/frontend/usage_quotas/product_analytics/components/product_analytics_group_usage_spec.js +++ b/ee/spec/frontend/usage_quotas/product_analytics/components/group_usage/product_analytics_group_usage_spec.js @@ -1,21 +1,22 @@ import Vue from 'vue'; import VueApollo from 'vue-apollo'; -import { GlAlert, GlSkeletonLoader, GlSprintf } from '@gitlab/ui'; -import { GlAreaChart } from '@gitlab/ui/dist/charts'; +import { GlAlert, GlSprintf } from '@gitlab/ui'; import * as Sentry from '~/sentry/sentry_browser_wrapper'; import createMockApollo from 'helpers/mock_apollo_helper'; import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; import waitForPromises from 'helpers/wait_for_promises'; import { - getProjectWithYearsUsage, getProjectsUsageDataResponse, getProjectUsage, + getProjectWithYearsUsage, } from 'ee_jest/usage_quotas/product_analytics/graphql/mock_data'; import { useFakeDate } from 'helpers/fake_date'; import getGroupCurrentAndPrevProductAnalyticsUsage from 'ee/usage_quotas/product_analytics/graphql/queries/get_group_product_analytics_usage.query.graphql'; -import ProductAnalyticsGroupUsage from 'ee/usage_quotas/product_analytics/components/product_analytics_group_usage.vue'; +import ProductAnalyticsGroupUsage from 'ee/usage_quotas/product_analytics/components/group_usage/product_analytics_group_usage.vue'; +import ProductAnalyticsGroupMonthlyUsageChart from 'ee/usage_quotas/product_analytics/components/group_usage/product_analytics_group_monthly_usage_chart.vue'; +import ProductAnalyticsGroupUsageOverview from 'ee/usage_quotas/product_analytics/components/group_usage/product_analytics_group_usage_overview.vue'; Vue.use(VueApollo); @@ -29,8 +30,8 @@ describe('ProductAnalyticsGroupUsage', () => { useFakeDate(mockNow); const findError = () => wrapper.findComponent(GlAlert); - const findSkeletonLoader = () => wrapper.findComponent(GlSkeletonLoader); - const findChart = () => wrapper.findComponent(GlAreaChart); + const findUsageOverview = () => wrapper.findComponent(ProductAnalyticsGroupUsageOverview); + const findChart = () => wrapper.findComponent(ProductAnalyticsGroupMonthlyUsageChart); const findLearnMoreLink = () => wrapper.findByTestId('product-analytics-usage-quota-learn-more'); const mockProjectsUsageDataHandler = jest.fn(); @@ -62,7 +63,6 @@ describe('ProductAnalyticsGroupUsage', () => { it('renders a section header', () => { createComponent(); - expect(wrapper.text()).toContain('Usage by month'); expect(findLearnMoreLink().attributes('href')).toBe( '/help/user/product_analytics/index#product-analytics-usage-quota', ); @@ -158,12 +158,8 @@ describe('ProductAnalyticsGroupUsage', () => { expect(findError().exists()).toBe(false); }); - it('renders the loading state', () => { - expect(findSkeletonLoader().exists()).toBe(true); - }); - - it('does not render the chart', () => { - expect(findChart().exists()).toBe(false); + it('renders the chart loading state', () => { + expect(findChart().props('isLoading')).toBe(true); }); }); @@ -176,10 +172,6 @@ describe('ProductAnalyticsGroupUsage', () => { return waitForPromises(); }); - it('does not render the loading state', () => { - expect(findSkeletonLoader().exists()).toBe(false); - }); - it('does not render the chart', () => { expect(findChart().exists()).toBe(false); }); @@ -214,8 +206,8 @@ describe('ProductAnalyticsGroupUsage', () => { expect(findError().exists()).toBe(false); }); - it('does not render the loading state', () => { - expect(findSkeletonLoader().exists()).toBe(false); + it('does not render the chart loading state', () => { + expect(findChart().props('isLoading')).toBe(false); }); it('emits "no-projects" event', () => { @@ -236,30 +228,22 @@ describe('ProductAnalyticsGroupUsage', () => { expect(findError().exists()).toBe(false); }); - it('does not render the loading state', () => { - expect(findSkeletonLoader().exists()).toBe(false); - }); - it('renders the chart', () => { expect(findChart().props()).toMatchObject({ - data: [ - { - name: 'Analytics events by month', - data: [ - ['Feb 2022', 1], - ['Mar 2022', 1], - ['Apr 2022', 1], - ['May 2022', 1], - ['Jun 2022', 1], - ['Jul 2022', 1], - ['Aug 2022', 1], - ['Sep 2022', 1], - ['Oct 2022', 1], - ['Nov 2022', 1], - ['Dec 2022', 1], - ['Jan 2023', 1], - ], - }, + isLoading: false, + monthlyTotals: [ + ['Feb 2022', 1], + ['Mar 2022', 1], + ['Apr 2022', 1], + ['May 2022', 1], + ['Jun 2022', 1], + ['Jul 2022', 1], + ['Aug 2022', 1], + ['Sep 2022', 1], + ['Oct 2022', 1], + ['Nov 2022', 1], + ['Dec 2022', 1], + ['Jan 2023', 1], ], }); }); @@ -281,28 +265,100 @@ describe('ProductAnalyticsGroupUsage', () => { it('renders the chart with correctly summed counts', () => { expect(findChart().props()).toMatchObject({ - data: [ - { - name: 'Analytics events by month', - data: [ - ['Feb 2022', 2], - ['Mar 2022', 2], - ['Apr 2022', 2], - ['May 2022', 2], - ['Jun 2022', 2], - ['Jul 2022', 2], - ['Aug 2022', 2], - ['Sep 2022', 2], - ['Oct 2022', 2], - ['Nov 2022', 2], - ['Dec 2022', 2], - ['Jan 2023', 2], - ], - }, + isLoading: false, + monthlyTotals: [ + ['Feb 2022', 2], + ['Mar 2022', 2], + ['Apr 2022', 2], + ['May 2022', 2], + ['Jun 2022', 2], + ['Jul 2022', 2], + ['Aug 2022', 2], + ['Sep 2022', 2], + ['Oct 2022', 2], + ['Nov 2022', 2], + ['Dec 2022', 2], + ['Jan 2023', 2], ], }); }); }); }); }); + + describe('usage overview', () => { + describe('when "productAnalyticsBilling" feature flag is disabled', () => { + describe('while loading', () => { + beforeEach(() => { + mockProjectsUsageDataHandler.mockResolvedValue({ + data: getProjectsUsageDataResponse([getProjectWithYearsUsage()]), + }); + createComponent({ glFeatures: { productAnalyticsBilling: false } }); + }); + + it('does not render the usage overview loading state', () => { + expect(findUsageOverview().exists()).toBe(false); + }); + }); + + describe('when there is data', () => { + beforeEach(() => { + mockProjectsUsageDataHandler.mockResolvedValue({ + data: getProjectsUsageDataResponse([getProjectWithYearsUsage()]), + }); + createComponent({ glFeatures: { productAnalyticsBilling: false } }); + return waitForPromises(); + }); + + it('does not render usage overview', () => { + expect(findUsageOverview().exists()).toBe(false); + }); + }); + }); + + describe('when "productAnalyticsBilling" feature flag is enabled', () => { + describe('while loading', () => { + beforeEach(() => { + mockProjectsUsageDataHandler.mockResolvedValue({ + data: getProjectsUsageDataResponse([getProjectWithYearsUsage()]), + }); + createComponent({ glFeatures: { productAnalyticsBilling: true } }); + }); + + it('renders the usage overview loading state', () => { + expect(findUsageOverview().props('isLoading')).toBe(true); + }); + }); + + describe('when there is an error', () => { + beforeEach(() => { + mockProjectsUsageDataHandler.mockRejectedValue(new Error('oh no!')); + createComponent({ glFeatures: { productAnalyticsBilling: true } }); + return waitForPromises(); + }); + + it('does not render usage overview', () => { + expect(findUsageOverview().exists()).toBe(false); + }); + }); + + describe('when there is data', () => { + beforeEach(() => { + mockProjectsUsageDataHandler.mockResolvedValue({ + data: getProjectsUsageDataResponse([getProjectWithYearsUsage()]), + }); + createComponent({ glFeatures: { productAnalyticsBilling: true } }); + return waitForPromises(); + }); + + it('renders the usage overview', () => { + expect(findUsageOverview().props()).toMatchObject({ + isLoading: false, + eventsUsed: 1, + storedEventsLimit: 1000000, + }); + }); + }); + }); + }); }); diff --git a/ee/spec/frontend/usage_quotas/product_analytics/components/product_analytics_usage_quota_app_spec.js b/ee/spec/frontend/usage_quotas/product_analytics/components/product_analytics_usage_quota_app_spec.js index de5e0a09022e960b892c82e260319f4af579e127..208fd3de27d6fd1cb7a9daf7d3a4874d653b6674 100644 --- a/ee/spec/frontend/usage_quotas/product_analytics/components/product_analytics_usage_quota_app_spec.js +++ b/ee/spec/frontend/usage_quotas/product_analytics/components/product_analytics_usage_quota_app_spec.js @@ -2,7 +2,7 @@ import { nextTick } from 'vue'; import { shallowMount } from '@vue/test-utils'; import { GlEmptyState } from '@gitlab/ui'; import ProductAnalyticsUsageQuotaApp from 'ee/usage_quotas/product_analytics/components/product_analytics_usage_quota_app.vue'; -import ProductAnalyticsGroupUsage from 'ee/usage_quotas/product_analytics/components/product_analytics_group_usage.vue'; +import ProductAnalyticsGroupUsage from 'ee/usage_quotas/product_analytics/components/group_usage/product_analytics_group_usage.vue'; import ProductAnalyticsProjectsUsage from 'ee/usage_quotas/product_analytics/components/projects_usage/product_analytics_projects_usage.vue'; describe('ProductAnalyticsUsageQuotaApp', () => { diff --git a/ee/spec/frontend/usage_quotas/product_analytics/components/utils_spec.js b/ee/spec/frontend/usage_quotas/product_analytics/components/utils_spec.js index e583ff4ce694f0f168696c25e6d9502917e655be..d3e986bc9c74759fec466807353324a5b88981aa 100644 --- a/ee/spec/frontend/usage_quotas/product_analytics/components/utils_spec.js +++ b/ee/spec/frontend/usage_quotas/product_analytics/components/utils_spec.js @@ -3,6 +3,7 @@ import { findCurrentMonthUsage, findPreviousMonthUsage, mapMonthlyTotals, + monthlyTotalsValidator, } from 'ee/usage_quotas/product_analytics/components/utils'; import { getProjectUsage, @@ -113,6 +114,52 @@ describe('Product analytics usage quota component utils', () => { }); }); + describe('monthlyTotalsValidator', () => { + it('should return true for a valid array of monthly totals', () => { + const items = [ + ['Nov 2023', 10], + ['Dec 2023', 12], + ['Jan 2024', 15], + ]; + + const isValid = monthlyTotalsValidator(items); + + expect(isValid).toBe(true); + }); + + it('should return false if not given an array of arrays', () => { + const items = ['Nov 2023', 10]; + + const isValid = monthlyTotalsValidator(items); + + expect(isValid).toBe(false); + }); + + it('should return false for an array that contains an invalid date label', () => { + const items = [ + [false, 10], + ['12 2023', 12], + ['Jan 2024', 15], + ]; + + const isValid = monthlyTotalsValidator(items); + + expect(isValid).toBe(false); + }); + + it('should return false for an array that contains an invalid count', () => { + const items = [ + ['Nov 2023', 10], + ['Dec 2023', '12'], + ['Jan 2024', 15], + ]; + + const isValid = monthlyTotalsValidator(items); + + expect(isValid).toBe(false); + }); + }); + describe('findCurrentMonthUsage', () => { const mockNow = '2023-01-15T12:00:00Z'; useFakeDate(mockNow); diff --git a/ee/spec/frontend/usage_quotas/product_analytics/graphql/mock_data.js b/ee/spec/frontend/usage_quotas/product_analytics/graphql/mock_data.js index 525c6ca069603e4b589f1de7260a84dc2506d25a..427d6568e7e0b6c114a1625ddb6c47c77b5f7cd4 100644 --- a/ee/spec/frontend/usage_quotas/product_analytics/graphql/mock_data.js +++ b/ee/spec/frontend/usage_quotas/product_analytics/graphql/mock_data.js @@ -13,6 +13,7 @@ export const getProjectUsage = ({ id, name, usage } = {}) => ({ export const getProjectsUsageDataResponse = (projects) => ({ group: { id: convertToGraphQLId(TYPENAME_GROUP, 1), + productAnalyticsStoredEventsLimit: 1000000, projects: { nodes: projects || [ getProjectUsage({ diff --git a/locale/gitlab.pot b/locale/gitlab.pot index ffb0ff73aa0de9fbfdc69a1b43cdc17e7971df08..ec3aa089c6640bee4316da26c290a76857f9ff93 100644 --- a/locale/gitlab.pot +++ b/locale/gitlab.pot @@ -5730,6 +5730,9 @@ msgstr "" msgid "Analytics|OS Version" msgstr "" +msgid "Analytics|Overview" +msgstr "" + msgid "Analytics|Page Language" msgstr "" @@ -5808,6 +5811,9 @@ msgstr "" msgid "Analytics|Updating visualization %{visualizationName}" msgstr "" +msgid "Analytics|Usage breakdown" +msgstr "" + msgid "Analytics|Use the visualization designer to create custom visualizations. After you save a visualization, you can add it to a dashboard." msgstr ""