diff --git a/ee/app/assets/javascripts/usage_quotas/components/statistics_card.vue b/ee/app/assets/javascripts/usage_quotas/components/statistics_card.vue index 12141b5a6ad0ac628e270c24692de2ed84d6def9..42a09959d298235213c3d9d043aa4151d6445bc1 100644 --- a/ee/app/assets/javascripts/usage_quotas/components/statistics_card.vue +++ b/ee/app/assets/javascripts/usage_quotas/components/statistics_card.vue @@ -86,7 +86,7 @@ export default { <template> <div - class="gl-bg-white gl-border-1 gl-border-gray-100 gl-border-solid gl-p-5 gl-rounded-base" + class="gl-bg-white gl-border-1 gl-border-gray-100 gl-border-solid gl-p-6 gl-rounded-base" data-testid="container" :class="cssClass" > diff --git a/ee/app/assets/javascripts/usage_quotas/components/usage_statistics.vue b/ee/app/assets/javascripts/usage_quotas/components/usage_statistics.vue index 6aa4e2437dc6e701b0c46b1cbfc5877dbd73107b..e90d767b514f78570055382e3acfdd88074aa84a 100644 --- a/ee/app/assets/javascripts/usage_quotas/components/usage_statistics.vue +++ b/ee/app/assets/javascripts/usage_quotas/components/usage_statistics.vue @@ -48,15 +48,15 @@ export default { <p v-if="usageValue" class="gl-font-size-h-display gl-font-weight-bold gl-mb-0" - data-testid="denominator" + data-testid="usage" > {{ usageValue - }}<span v-if="usageUnit" data-testid="denominator-usage-unit" class="gl-font-lg">{{ + }}<span v-if="usageUnit" data-testid="usage-unit" class="gl-font-lg">{{ usageUnit }}</span> - <span v-if="totalValue" data-testid="denominator-total"> + <span v-if="totalValue" data-testid="total"> / {{ totalValue - }}<span v-if="totalUnit" class="gl-font-lg" data-testid="denominator-total-unit">{{ + }}<span v-if="totalUnit" class="gl-font-lg" data-testid="total-unit">{{ totalUnit }}</span> </span> diff --git a/ee/app/assets/javascripts/usage_quotas/seats/components/code_suggestions_usage_statistics_card.vue b/ee/app/assets/javascripts/usage_quotas/seats/components/code_suggestions_usage_statistics_card.vue new file mode 100644 index 0000000000000000000000000000000000000000..b382f6e01ae67d5c5a22a9d7e27f4f8bfbb4b9e0 --- /dev/null +++ b/ee/app/assets/javascripts/usage_quotas/seats/components/code_suggestions_usage_statistics_card.vue @@ -0,0 +1,91 @@ +<script> +import { GlButton, GlIcon, GlLink, GlSprintf } from '@gitlab/ui'; +import UsageStatistics from 'ee/usage_quotas/components/usage_statistics.vue'; +import { + codeSuggestionsInfoLink, + codeSuggestionsInfoText, + codeSuggestionIntroDescriptionText, + codeSuggestionsLearnMoreLink, + learnMoreText, +} from 'ee/usage_quotas/seats/constants'; + +export default { + name: 'CodeSuggestionsUsageStatisticsCard', + components: { + GlButton, + GlIcon, + GlLink, + GlSprintf, + UsageStatistics, + }, + data() { + return { + totalValue: null, + usageValue: null, + }; + }, + computed: { + descriptionText() { + return this.$options.i18n.codeSuggestionIntroDescriptionText; + }, + percentage() { + return Math.round((this.usageValue / this.totalValue) * 100); + }, + shouldShowUsageStatistics() { + return Boolean(this.totalValue) && this.percentage >= 0; + }, + }, + helpLinks: { + codeSuggestionsInfoLink, + codeSuggestionsLearnMoreLink, + }, + i18n: { + codeSuggestionsInfoText, + codeSuggestionIntroDescriptionText, + learnMoreText, + }, +}; +</script> +<template> + <div class="gl-bg-white gl-border-1 gl-border-purple-300 gl-border-solid gl-p-6 gl-rounded-base"> + <usage-statistics + v-if="shouldShowUsageStatistics" + :percentage="percentage" + :total-value="`${totalValue}`" + :usage-value="`${usageValue}`" + /> + <div v-else class="gl-display-flex gl-sm-flex-direction-column"> + <section> + <p class="gl-font-weight-bold gl-mb-3" data-testid="code-suggestions-description"> + <gl-icon name="tanuki-ai" class="gl-text-purple-600 gl-mr-3" /> + {{ descriptionText }} + </p> + <p data-testid="code-suggestions-info"> + <gl-sprintf :message="$options.i18n.codeSuggestionsInfoText"> + <template #link="{ content }"> + <gl-link + :href="$options.helpLinks.codeSuggestionsInfoLink" + target="_blank" + data-testid="code-suggestions-info-link" + >{{ content }}</gl-link + > + </template> + </gl-sprintf> + </p> + </section> + <section> + <gl-button + :href="$options.helpLinks.codeSuggestionsLearnMoreLink" + category="primary" + target="_blank" + size="small" + variant="default" + data-testid="learn-more" + data-qa-selector="learn_more" + > + {{ $options.i18n.learnMoreText }} + </gl-button> + </section> + </div> + </div> +</template> diff --git a/ee/app/assets/javascripts/usage_quotas/seats/components/subscription_seats.vue b/ee/app/assets/javascripts/usage_quotas/seats/components/subscription_seats.vue index 7ede6b3b6e3c49a1a8991d9ab3bcf477c27faa83..10e5cce211cf578a295ea2f103e75333f9c019f5 100644 --- a/ee/app/assets/javascripts/usage_quotas/seats/components/subscription_seats.vue +++ b/ee/app/assets/javascripts/usage_quotas/seats/components/subscription_seats.vue @@ -35,12 +35,13 @@ import { seatsTooltipTrialText, unlimited, } from 'ee/usage_quotas/seats/constants'; +import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; import { s__, __, sprintf, n__ } from '~/locale'; +import CodeSuggestionsUsageStatisticsCard from 'ee/usage_quotas/seats/components/code_suggestions_usage_statistics_card.vue'; import SearchAndSortBar from 'ee/usage_quotas/components/search_and_sort_bar/search_and_sort_bar.vue'; import StatisticsCard from 'ee/usage_quotas/components/statistics_card.vue'; import StatisticsSeatsCard from 'ee/usage_quotas/seats/components/statistics_seats_card.vue'; import SubscriptionUsageStatisticsCard from 'ee/usage_quotas/seats/components/subscription_usage_statistics_card.vue'; -import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; import SubscriptionUpgradeInfoCard from './subscription_upgrade_info_card.vue'; import RemoveBillableMemberModal from './remove_billable_member_modal.vue'; import SubscriptionSeatDetails from './subscription_seat_details.vue'; @@ -60,6 +61,7 @@ export default { GlIcon, GlPagination, GlTable, + CodeSuggestionsUsageStatisticsCard, RemoveBillableMemberModal, SubscriptionSeatDetails, SearchAndSortBar, @@ -128,7 +130,7 @@ export default { this.pendingMembersCount > 0 && this.pendingMembersPagePath && !this.hasLimitedFreePlan ); }, - shouldShowSubscriptionUsageStatistics() { + shouldShowSubscriptionRelatedCards() { return Boolean(this.glFeatures?.enableHamiltonInUsageQuotasUi) && !this.hasNoSubscription; }, seatsInUsePercentage() { @@ -288,7 +290,7 @@ export default { <div v-else class="gl-display-grid gl-md-grid-template-columns-2 gl-gap-5"> <subscription-usage-statistics-card - v-if="shouldShowSubscriptionUsageStatistics" + v-if="shouldShowSubscriptionRelatedCards" :percentage="seatsInUsePercentage" :usage-value="String(totalSeatsInUse)" :total-value="displayedTotalSeats" @@ -309,6 +311,7 @@ export default { :explore-plans-path="explorePlansPath" :active-trial="activeTrial" /> + <code-suggestions-usage-statistics-card v-else-if="shouldShowSubscriptionRelatedCards" /> <statistics-seats-card v-else :seats-used="maxSeatsUsed" diff --git a/ee/app/assets/javascripts/usage_quotas/seats/components/subscription_upgrade_info_card.vue b/ee/app/assets/javascripts/usage_quotas/seats/components/subscription_upgrade_info_card.vue index 454ac92f57fb8306b152ffd68aa15f56c9d70567..02618c5f3d8a5c58aef5b1c3d72598f2acafe905 100644 --- a/ee/app/assets/javascripts/usage_quotas/seats/components/subscription_upgrade_info_card.vue +++ b/ee/app/assets/javascripts/usage_quotas/seats/components/subscription_upgrade_info_card.vue @@ -50,7 +50,7 @@ export default { </script> <template> - <div class="gl-bg-white gl-border-1 gl-border-gray-100 gl-border-solid gl-p-5 gl-rounded-base"> + <div class="gl-bg-white gl-border-1 gl-border-blue-300 gl-border-solid gl-p-6 gl-rounded-base"> <div class="gl-display-flex gl-sm-flex-direction-column"> <div class="gl-mb-3 gl-md-mb-0 gl-md-mr-5 gl-sm-mr-0"> <p class="gl-font-weight-bold gl-mb-3" data-testid="title"> @@ -65,6 +65,7 @@ export default { :href="explorePlansPath" category="primary" variant="confirm" + size="small" @click="trackClick" > {{ $options.i18n.cta }} diff --git a/ee/app/assets/javascripts/usage_quotas/seats/constants.js b/ee/app/assets/javascripts/usage_quotas/seats/constants.js index e23da3fa8386f3fdf798a10c1aa71e74e43d380a..4645a4d49612cd7064a76f010c3673d7516f7c20 100644 --- a/ee/app/assets/javascripts/usage_quotas/seats/constants.js +++ b/ee/app/assets/javascripts/usage_quotas/seats/constants.js @@ -1,6 +1,7 @@ import { thWidthPercent } from '~/lib/utils/table_utility'; import { __, s__ } from '~/locale'; import { helpPagePath } from '~/helpers/help_page_helper'; +import { PROMO_URL } from 'jh_else_ce/lib/utils/url_utility'; // Billable Seats HTTP headers export const HEADER_TOTAL_ENTRIES = 'x-total'; @@ -107,6 +108,9 @@ export const seatsOwedLink = helpPagePath('subscriptions/gitlab_com/index', { export const seatsUsedLink = helpPagePath('subscriptions/gitlab_com/index', { anchor: 'view-your-gitlab-saas-subscription', }); +export const codeSuggestionsInfoLink = helpPagePath( + 'user/project/repository/code_suggestions.html', +); export const emailNotVisibleTooltipText = s__( 'Billing|An email address is only visible for users with public emails.', ); @@ -129,3 +133,9 @@ export const addSeatsText = s__('Billing|Add seats'); export const subscriptionEndDateText = s__('Billing|Subscription end'); export const subscriptionStartDateText = s__('Billing|Subscription start'); export const seatsUsedDescriptionText = s__('Billing|%{plan} SaaS Plan seats used'); +export const codeSuggestionIntroDescriptionText = __('Introducing the Code Suggestions add-on'); +export const codeSuggestionsInfoText = __( + `Enhance your coding experience with intelligent recommendations. %{linkStart}Code Suggestions%{linkEnd} uses generative AI to suggest code while you're developing. Not available for Guest roles.`, +); +export const learnMoreText = __('Learn more'); +export const codeSuggestionsLearnMoreLink = `${PROMO_URL}/solutions/code-suggestions/`; diff --git a/ee/spec/frontend/usage_quotas/components/usage_statistics_spec.js b/ee/spec/frontend/usage_quotas/components/usage_statistics_spec.js index c20789efac43d75d8ad526d1ffbc84405d2e8fcb..091f653e59f733aeb6ddd04202c45cee425b101f 100644 --- a/ee/spec/frontend/usage_quotas/components/usage_statistics_spec.js +++ b/ee/spec/frontend/usage_quotas/components/usage_statistics_spec.js @@ -35,11 +35,11 @@ describe('UsageStatistics', () => { }); it('renders the usage value', () => { - expect(wrapper.findByTestId('denominator').text()).toBe(`${usageValue}${usageUnit}`); + expect(wrapper.findByTestId('usage').text()).toBe(`${usageValue}${usageUnit}`); }); it('renders the usage unit', () => { - expect(wrapper.findByTestId('denominator-usage-unit').text()).toBe(usageUnit); + expect(wrapper.findByTestId('usage-unit').text()).toBe(usageUnit); }); }); @@ -52,11 +52,11 @@ describe('UsageStatistics', () => { }); it('renders the total value', () => { - expect(wrapper.findByTestId('denominator-total').text()).toBe(`/ ${totalValue}${totalUnit}`); + expect(wrapper.findByTestId('total').text()).toBe(`/ ${totalValue}${totalUnit}`); }); it('renders the total unit', () => { - expect(wrapper.findByTestId('denominator-total-unit').text()).toBe(totalUnit); + expect(wrapper.findByTestId('total-unit').text()).toBe(totalUnit); }); }); diff --git a/ee/spec/frontend/usage_quotas/seats/components/code_suggestions_usage_statistics_spec.js b/ee/spec/frontend/usage_quotas/seats/components/code_suggestions_usage_statistics_spec.js new file mode 100644 index 0000000000000000000000000000000000000000..09b2d94f8c1029d90dd53c9ea2e93ff557bfd076 --- /dev/null +++ b/ee/spec/frontend/usage_quotas/seats/components/code_suggestions_usage_statistics_spec.js @@ -0,0 +1,65 @@ +import { GlSprintf } from '@gitlab/ui'; +import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; +import { + codeSuggestionsInfoLink, + codeSuggestionsInfoText, + codeSuggestionIntroDescriptionText, + codeSuggestionsLearnMoreLink, + learnMoreText, +} from 'ee/usage_quotas/seats/constants'; +import CodeSuggestionsUsageStatisticsCard from 'ee/usage_quotas/seats/components/code_suggestions_usage_statistics_card.vue'; + +describe('CodeSuggestionsUsageStatisticsCard', () => { + let wrapper; + + const findLearnMoreButton = () => wrapper.findByTestId('learn-more'); + const findCodeSuggestionsDescription = () => wrapper.findByTestId('code-suggestions-description'); + const findCodeSuggestionsInfo = () => wrapper.findByTestId('code-suggestions-info'); + const findCodeSuggestionsInfoLink = () => wrapper.findByTestId('code-suggestions-info-link'); + const createComponent = () => { + wrapper = shallowMountExtended(CodeSuggestionsUsageStatisticsCard, { + stubs: { + GlSprintf, + UsageStatistics: { + template: ` + <div> + <slot name="actions"></slot> + <slot name="description"></slot> + <slot name="additional-info"></slot> + </div> + `, + }, + }, + }); + }; + + beforeEach(() => { + createComponent(); + }); + + it('renders the component', () => { + expect(wrapper.exists()).toBe(true); + }); + + describe('`Learn more` button', () => { + it('renders the text', () => { + expect(findLearnMoreButton().text()).toBe(learnMoreText); + }); + + it('provides the correct href', () => { + expect(findLearnMoreButton().attributes('href')).toBe(codeSuggestionsLearnMoreLink); + }); + }); + + it('renders the description text', () => { + expect(findCodeSuggestionsDescription().text()).toBe(codeSuggestionIntroDescriptionText); + }); + + it('renders the info text', () => { + expect(findCodeSuggestionsInfo().text()).toMatchInterpolatedText(codeSuggestionsInfoText); + }); + + it('renders the info link', () => { + expect(findCodeSuggestionsInfoLink().attributes('href')).toBe(codeSuggestionsInfoLink); + }); +}); diff --git a/ee/spec/frontend/usage_quotas/seats/components/subscription_seats_spec.js b/ee/spec/frontend/usage_quotas/seats/components/subscription_seats_spec.js index f3a60b9c16ebf5bdd2d0a0d3b0ea6d2fcdf0425f..9845a5d2eba59f3548338593c157e897cc820d05 100644 --- a/ee/spec/frontend/usage_quotas/seats/components/subscription_seats_spec.js +++ b/ee/spec/frontend/usage_quotas/seats/components/subscription_seats_spec.js @@ -10,6 +10,7 @@ import { import { mount, shallowMount } from '@vue/test-utils'; import Vue from 'vue'; import Vuex from 'vuex'; +import CodeSuggestionsUsageStatisticsCard from 'ee/usage_quotas/seats/components/code_suggestions_usage_statistics_card.vue'; import StatisticsCard from 'ee/usage_quotas/components/statistics_card.vue'; import StatisticsSeatsCard from 'ee/usage_quotas/seats/components/statistics_seats_card.vue'; import SubscriptionUpgradeInfoCard from 'ee/usage_quotas/seats/components/subscription_upgrade_info_card.vue'; @@ -98,6 +99,8 @@ describe('Subscription Seats', () => { const findPagination = () => wrapper.findComponent(GlPagination); const findAllRemoveUserItems = () => wrapper.findAllByTestId('remove-user'); + const findCodeSuggestionsStatisticsCard = () => + wrapper.findComponent(CodeSuggestionsUsageStatisticsCard); const findErrorModal = () => wrapper.findComponent(GlModal); const findStatisticsCard = () => wrapper.findComponent(StatisticsCard); const findStatisticsSeatsCard = () => wrapper.findComponent(StatisticsSeatsCard); @@ -425,6 +428,23 @@ describe('Subscription Seats', () => { }); }); + it('renders <code-suggestions-usage-statistics-card>', () => { + wrapper = createComponent({ + initialState: { + ...defaultInitialState, + hasNoSubscription: false, + }, + provide: { + glFeatures: { + enableHamiltonInUsageQuotasUi: true, + }, + }, + }); + + expect(findStatisticsSeatsCard().exists()).toBe(false); + expect(findCodeSuggestionsStatisticsCard().exists()).toBe(true); + }); + describe('for free namespace with limit', () => { beforeEach(() => { wrapper = createComponent({ diff --git a/locale/gitlab.pot b/locale/gitlab.pot index 920a40dd29c8077ed03416455eaedef2d73ba992..6ddd127d01913d0ee6d24bb7b2589f425ccaea08 100644 --- a/locale/gitlab.pot +++ b/locale/gitlab.pot @@ -17438,6 +17438,9 @@ msgstr "" msgid "Enhance security by storing service account keys in secret managers - learn more about %{docLinkStart}secret management with GitLab%{docLinkEnd}" msgstr "" +msgid "Enhance your coding experience with intelligent recommendations. %{linkStart}Code Suggestions%{linkEnd} uses generative AI to suggest code while you're developing. Not available for Guest roles." +msgstr "" + msgid "Ensure your %{linkStart}environment is part of the deploy stage%{linkEnd} of your CI pipeline to track deployments to your cluster." msgstr "" @@ -24948,6 +24951,9 @@ msgstr "" msgid "Introducing Your DevOps Reports" msgstr "" +msgid "Introducing the Code Suggestions add-on" +msgstr "" + msgid "Invalid 'schemaVersion' '%{schema_version}'" msgstr ""