diff --git a/app/assets/javascripts/security_configuration/components/app.vue b/app/assets/javascripts/security_configuration/components/app.vue index 62c30c941ebb3d3eb8ea1e125f6ea9bccf15dda3..a71097bb501e8f912a725e91a3a2ab3ebf92b193 100644 --- a/app/assets/javascripts/security_configuration/components/app.vue +++ b/app/assets/javascripts/security_configuration/components/app.vue @@ -4,6 +4,7 @@ import { __, s__ } from '~/locale'; import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; import LocalStorageSync from '~/vue_shared/components/local_storage_sync.vue'; import UserCalloutDismisser from '~/vue_shared/components/user_callout_dismisser.vue'; +import securityTrainingProvidersQuery from '../graphql/security_training_providers.query.graphql'; import AutoDevOpsAlert from './auto_dev_ops_alert.vue'; import AutoDevOpsEnabledAlert from './auto_dev_ops_enabled_alert.vue'; import { AUTO_DEVOPS_ENABLED_ALERT_DISMISSED_STORAGE_KEY } from './constants'; @@ -29,28 +30,8 @@ export const i18n = { securityTraining: s__('SecurityConfiguration|Security training'), }; -// This will be removed and replaced with GraphQL query: -// https://gitlab.com/gitlab-org/gitlab/-/issues/346480 -export const TRAINING_PROVIDERS = [ - { - id: 101, - name: __('Kontra'), - description: __('Interactive developer security education.'), - url: 'https://application.security/', - isEnabled: false, - }, - { - id: 102, - name: __('SecureCodeWarrior'), - description: __('Security training with guide and learning pathways.'), - url: 'https://www.securecodewarrior.com/', - isEnabled: true, - }, -]; - export default { i18n, - TRAINING_PROVIDERS, components: { AutoDevOpsAlert, AutoDevOpsEnabledAlert, @@ -107,6 +88,7 @@ export default { return { autoDevopsEnabledAlertDismissedProjects: [], errorMessage: '', + securityTrainingProviders: [], }; }, computed: { @@ -128,6 +110,11 @@ export default { ); }, }, + apollo: { + securityTrainingProviders: { + query: securityTrainingProvidersQuery, + }, + }, methods: { dismissAutoDevopsEnabledAlert() { const dismissedProjects = new Set(this.autoDevopsEnabledAlertDismissedProjects); @@ -264,7 +251,10 @@ export default { > <section-layout :heading="$options.i18n.securityTraining"> <template #features> - <training-provider-list :providers="$options.TRAINING_PROVIDERS" /> + <training-provider-list + :loading="$apollo.queries.securityTrainingProviders.loading" + :providers="securityTrainingProviders" + /> </template> </section-layout> </gl-tab> diff --git a/app/assets/javascripts/security_configuration/components/training_provider_list.vue b/app/assets/javascripts/security_configuration/components/training_provider_list.vue index 160540f698950bea279c1eec9763d30b1b6c53bd..d6170e299de4574fde55bafe3657b8469101a53a 100644 --- a/app/assets/javascripts/security_configuration/components/training_provider_list.vue +++ b/app/assets/javascripts/security_configuration/components/training_provider_list.vue @@ -1,23 +1,39 @@ <script> -import { GlCard, GlToggle, GlLink } from '@gitlab/ui'; +import { GlCard, GlToggle, GlLink, GlSkeletonLoader } from '@gitlab/ui'; export default { components: { GlCard, GlToggle, GlLink, + GlSkeletonLoader, }, props: { providers: { type: Array, required: true, }, + loading: { + type: Boolean, + required: false, + default: false, + }, }, }; </script> <template> - <ul class="gl-list-style-none gl-m-0 gl-p-0"> + <div + v-if="loading" + class="gl-mb-6 gl-bg-white gl-py-6 gl-rounded-base gl-border-1 gl-border-solid gl-border-gray-100" + > + <gl-skeleton-loader :width="350" :height="44"> + <rect width="200" height="8" x="10" y="0" rx="4" /> + <rect width="300" height="8" x="10" y="15" rx="4" /> + <rect width="100" height="8" x="10" y="35" rx="4" /> + </gl-skeleton-loader> + </div> + <ul v-else class="gl-list-style-none gl-m-0 gl-p-0"> <li v-for="{ id, isEnabled, name, description, url } in providers" :key="id" class="gl-mb-6"> <gl-card> <div class="gl-display-flex"> diff --git a/app/assets/javascripts/security_configuration/graphql/security_training_providers.query.graphql b/app/assets/javascripts/security_configuration/graphql/security_training_providers.query.graphql new file mode 100644 index 0000000000000000000000000000000000000000..9a297d7f9d1c473f94fdf6e3791baa47d03bdc3a --- /dev/null +++ b/app/assets/javascripts/security_configuration/graphql/security_training_providers.query.graphql @@ -0,0 +1,9 @@ +query Query { + securityTrainingProviders { + name + id + description + isEnabled + url + } +} diff --git a/locale/gitlab.pot b/locale/gitlab.pot index c24130c99b76ef292d067e815f1bb5ceba237a5f..27b254410583b6a325c3b596dfc5564d626f3704 100644 --- a/locale/gitlab.pot +++ b/locale/gitlab.pot @@ -19017,9 +19017,6 @@ msgstr "" msgid "Integrations|can't exceed %{recipients_limit}" msgstr "" -msgid "Interactive developer security education." -msgstr "" - msgid "Interactive mode" msgstr "" @@ -20349,9 +20346,6 @@ msgstr "" msgid "Ki" msgstr "" -msgid "Kontra" -msgstr "" - msgid "Kroki" msgstr "" @@ -30860,9 +30854,6 @@ msgstr "" msgid "Secure token that identifies an external storage request." msgstr "" -msgid "SecureCodeWarrior" -msgstr "" - msgid "Security" msgstr "" @@ -30887,9 +30878,6 @@ msgstr "" msgid "Security report is out of date. Run %{newPipelineLinkStart}a new pipeline%{newPipelineLinkEnd} for the target branch (%{targetBranchName})" msgstr "" -msgid "Security training with guide and learning pathways." -msgstr "" - msgid "SecurityApprovals|A merge request approval is required when a security report contains a new vulnerability." msgstr "" diff --git a/spec/frontend/security_configuration/components/app_spec.js b/spec/frontend/security_configuration/components/app_spec.js index 759844b941a45e5c98e95f10c51ef1d1f3b5064e..fdccd533fec1b5634a20da209c2c63fb338d5dfe 100644 --- a/spec/frontend/security_configuration/components/app_spec.js +++ b/spec/frontend/security_configuration/components/app_spec.js @@ -1,14 +1,13 @@ import { GlTab } from '@gitlab/ui'; import { mount } from '@vue/test-utils'; -import { nextTick } from 'vue'; +import Vue, { nextTick } from 'vue'; +import VueApollo from 'vue-apollo'; import { useLocalStorageSpy } from 'helpers/local_storage_helper'; import { makeMockUserCalloutDismisser } from 'helpers/mock_user_callout_dismisser'; import stubChildren from 'helpers/stub_children'; import { extendedWrapper } from 'helpers/vue_test_utils_helper'; -import SecurityConfigurationApp, { - i18n, - TRAINING_PROVIDERS, -} from '~/security_configuration/components/app.vue'; +import createMockApollo from 'helpers/mock_apollo_helper'; +import SecurityConfigurationApp, { i18n } from '~/security_configuration/components/app.vue'; import AutoDevopsAlert from '~/security_configuration/components/auto_dev_ops_alert.vue'; import AutoDevopsEnabledAlert from '~/security_configuration/components/auto_dev_ops_enabled_alert.vue'; import { @@ -30,6 +29,8 @@ import { REPORT_TYPE_LICENSE_COMPLIANCE, REPORT_TYPE_SAST, } from '~/vue_shared/security_reports/constants'; +import securityTrainingProvidersQuery from '~/security_configuration/graphql/security_training_providers.query.graphql'; +import { securityTrainingProvidersResponse, securityTrainingProviders } from '../mock_data'; const upgradePath = '/upgrade'; const autoDevopsHelpPagePath = '/autoDevopsHelpPagePath'; @@ -38,10 +39,12 @@ const gitlabCiHistoryPath = 'test/historyPath'; const projectPath = 'namespace/project'; useLocalStorageSpy(); +Vue.use(VueApollo); describe('App component', () => { let wrapper; let userCalloutDismissSpy; + let mockApollo; const createComponent = ({ shouldShowCallout = true, @@ -49,9 +52,16 @@ describe('App component', () => { ...propsData }) => { userCalloutDismissSpy = jest.fn(); + mockApollo = createMockApollo([ + [ + securityTrainingProvidersQuery, + jest.fn().mockResolvedValue(securityTrainingProvidersResponse), + ], + ]); wrapper = extendedWrapper( mount(SecurityConfigurationApp, { + apolloProvider: mockApollo, propsData, provide: { upgradePath, @@ -134,6 +144,7 @@ describe('App component', () => { afterEach(() => { wrapper.destroy(); + mockApollo = null; }); describe('basic structure', () => { @@ -187,7 +198,7 @@ describe('App component', () => { }); it('renders training provider list with correct props', () => { - expect(findTrainingProviderList().props('providers')).toEqual(TRAINING_PROVIDERS); + expect(findTrainingProviderList().props('providers')).toEqual(securityTrainingProviders); }); }); diff --git a/spec/frontend/security_configuration/components/training_provider_list_spec.js b/spec/frontend/security_configuration/components/training_provider_list_spec.js index 1169a977d442dc2cdd503773cedcdcdf9f38917c..2d5cc0e80c961b6dc7fbaba00a82e2e590ea0852 100644 --- a/spec/frontend/security_configuration/components/training_provider_list_spec.js +++ b/spec/frontend/security_configuration/components/training_provider_list_spec.js @@ -1,10 +1,10 @@ -import { GlLink, GlToggle, GlCard } from '@gitlab/ui'; +import { GlLink, GlToggle, GlCard, GlSkeletonLoader } from '@gitlab/ui'; import { shallowMount } from '@vue/test-utils'; import TrainingProviderList from '~/security_configuration/components/training_provider_list.vue'; -import { TRAINING_PROVIDERS } from '~/security_configuration/components/app.vue'; +import { securityTrainingProviders } from '../mock_data'; const DEFAULT_PROPS = { - providers: TRAINING_PROVIDERS, + providers: securityTrainingProviders, }; describe('TrainingProviderList component', () => { @@ -22,6 +22,7 @@ describe('TrainingProviderList component', () => { const findCards = () => wrapper.findAllComponents(GlCard); const findLinks = () => wrapper.findAllComponents(GlLink); const findToggles = () => wrapper.findAllComponents(GlToggle); + const findLoader = () => wrapper.findComponent(GlSkeletonLoader); afterEach(() => { wrapper.destroy(); @@ -57,4 +58,23 @@ describe('TrainingProviderList component', () => { }); }); }); + + describe('loading', () => { + beforeEach(() => { + createComponent({ loading: true }); + }); + + it('shows the loader', () => { + expect(findLoader().exists()).toBe(true); + }); + + it('does not show the cards', () => { + expect(findCards().exists()).toBe(false); + }); + + it('does not show loader when not loading', () => { + createComponent({ loading: false }); + expect(findLoader().exists()).toBe(false); + }); + }); }); diff --git a/spec/frontend/security_configuration/mock_data.js b/spec/frontend/security_configuration/mock_data.js new file mode 100644 index 0000000000000000000000000000000000000000..b70259cef8d7bc9087cb7161c43ba7ecbc819211 --- /dev/null +++ b/spec/frontend/security_configuration/mock_data.js @@ -0,0 +1,22 @@ +export const securityTrainingProviders = [ + { + id: 101, + name: 'Kontra', + description: 'Interactive developer security education.', + url: 'https://application.security/', + isEnabled: false, + }, + { + id: 102, + name: 'SecureCodeWarrior', + description: 'Security training with guide and learning pathways.', + url: 'https://www.securecodewarrior.com/', + isEnabled: true, + }, +]; + +export const securityTrainingProvidersResponse = { + data: { + securityTrainingProviders, + }, +};