diff --git a/app/assets/javascripts/lib/utils/dom_utils.js b/app/assets/javascripts/lib/utils/dom_utils.js index 8fa235f8afb5e68e6c18dc4e0baa455b947b8643..d9b0e8c447654873d22b77f1e01958ca2f52d067 100644 --- a/app/assets/javascripts/lib/utils/dom_utils.js +++ b/app/assets/javascripts/lib/utils/dom_utils.js @@ -1,3 +1,4 @@ +import { has } from 'lodash'; import { isInIssuePage, isInMRPage, isInEpicPage } from './common_utils'; export const addClassIfElementExists = (element, className) => { @@ -25,3 +26,24 @@ export const toggleContainerClasses = (containerEl, classList) => { }); } }; + +/** + * Return a object mapping element dataset names to booleans. + * + * This is useful for data- attributes whose presense represent + * a truthiness, no matter the value of the attribute. The absense of the + * attribute represents falsiness. + * + * This can be useful when Rails-provided boolean-like values are passed + * directly to the HAML template, rather than cast to a string. + * + * @param {HTMLElement} element - The DOM element to inspect + * @param {string[]} names - The dataset (i.e., camelCase) names to inspect + * @returns {Object.<string, boolean>} + */ +export const parseBooleanDataAttributes = ({ dataset }, names) => + names.reduce((acc, name) => { + acc[name] = has(dataset, name); + + return acc; + }, {}); diff --git a/ee/app/assets/javascripts/security_configuration/components/app.vue b/ee/app/assets/javascripts/security_configuration/components/app.vue index eac992a2758a49fda69cec2f49f24b1dbe5733e1..7a607b4cf8bfa233327eb9375c338cb00186fc7c 100644 --- a/ee/app/assets/javascripts/security_configuration/components/app.vue +++ b/ee/app/assets/javascripts/security_configuration/components/app.vue @@ -1,11 +1,12 @@ <script> -import { GlLink, GlSprintf, GlTable } from '@gitlab/ui'; +import { GlAlert, GlLink, GlSprintf, GlTable } from '@gitlab/ui'; import { s__, __, sprintf } from '~/locale'; import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; import AutoFixSettings from './auto_fix_settings.vue'; export default { components: { + GlAlert, GlLink, GlSprintf, GlTable, @@ -39,6 +40,21 @@ export default { type: Object, required: true, }, + gitlabCiPresent: { + type: Boolean, + required: false, + default: false, + }, + autoDevopsPath: { + type: String, + required: false, + default: '', + }, + canEnableAutoDevops: { + type: Boolean, + required: false, + default: false, + }, }, computed: { devopsMessage() { @@ -68,6 +84,14 @@ export default { }, ]; }, + shouldShowAutoDevopsAlert() { + return Boolean( + this.glFeatures.sastConfigurationByClick && + !this.autoDevopsEnabled && + !this.gitlabCiPresent && + this.canEnableAutoDevops, + ); + }, }, methods: { getStatusText(value) { @@ -81,6 +105,9 @@ export default { }); }, }, + autoDevopsAlertMessage: s__(` + SecurityConfiguration|You can quickly enable all security scanning tools by + enabling %{linkStart}Auto DevOps%{linkEnd}.`), }; </script> @@ -98,6 +125,21 @@ export default { </p> </header> + <gl-alert + v-if="shouldShowAutoDevopsAlert" + :title="__('Auto DevOps')" + :primary-button-text="__('Enable Auto DevOps')" + :primary-button-link="autoDevopsPath" + :dismissible="false" + class="gl-mb-5" + > + <gl-sprintf :message="$options.autoDevopsAlertMessage"> + <template #link="{ content }"> + <gl-link :href="autoDevopsHelpPagePath" v-text="content" /> + </template> + </gl-sprintf> + </gl-alert> + <gl-table ref="securityControlTable" :items="features" :fields="fields" stacked="md"> <template #cell(feature)="{ item }"> <div class="gl-text-gray-900">{{ item.name }}</div> diff --git a/ee/app/assets/javascripts/security_configuration/index.js b/ee/app/assets/javascripts/security_configuration/index.js index db3a46aec30837dba6423663651cc12d88cee1d6..b3f683aad726a95177ad53abd7173dbe34758d27 100644 --- a/ee/app/assets/javascripts/security_configuration/index.js +++ b/ee/app/assets/javascripts/security_configuration/index.js @@ -1,11 +1,12 @@ import Vue from 'vue'; +import { parseBooleanDataAttributes } from '~/lib/utils/dom_utils'; import SecurityConfigurationApp from './components/app.vue'; export default function init() { const el = document.getElementById('js-security-configuration'); const { - autoDevopsEnabled, autoDevopsHelpPagePath, + autoDevopsPath, features, helpPagePath, latestPipelinePath, @@ -17,11 +18,6 @@ export default function init() { toggleAutofixSettingEndpoint, } = el.dataset; - // When canToggleAutoFixSettings is false in the backend, it is undefined in the frontend, - // and when it's true in the backend, it comes in as an empty string in the frontend. The next - // line ensures that we cast it to a boolean. - const canToggleAutoFixSettings = el.dataset.canToggleAutoFixSettings !== undefined; - return new Vue({ el, components: { @@ -30,19 +26,24 @@ export default function init() { render(createElement) { return createElement(SecurityConfigurationApp, { props: { - autoDevopsEnabled, autoDevopsHelpPagePath, + autoDevopsPath, features: JSON.parse(features), helpPagePath, latestPipelinePath, + ...parseBooleanDataAttributes(el, [ + 'autoDevopsEnabled', + 'canEnableAutoDevops', + 'gitlabCiPresent', + ]), autoFixSettingsProps: { autoFixEnabled: JSON.parse(autoFixEnabled), autoFixHelpPath, autoFixUserPath, containerScanningHelpPath, dependencyScanningHelpPath, - canToggleAutoFixSettings, toggleAutofixSettingEndpoint, + ...parseBooleanDataAttributes(el, ['canToggleAutoFixSettings']), }, }, }); diff --git a/ee/app/controllers/projects/security/configuration_controller.rb b/ee/app/controllers/projects/security/configuration_controller.rb index 4db66abc1ca5154bd1cce3ae228d5a56a32c2c35..87d6330c616b805bc40c7d256eeb4e0c26e61858 100644 --- a/ee/app/controllers/projects/security/configuration_controller.rb +++ b/ee/app/controllers/projects/security/configuration_controller.rb @@ -9,6 +9,7 @@ class ConfigurationController < Projects::ApplicationController before_action only: [:show] do push_frontend_feature_flag(:security_auto_fix, project, default_enabled: false) + push_frontend_feature_flag(:sast_configuration_by_click, project, default_enabled: false) end before_action only: [:auto_fix] do diff --git a/ee/spec/frontend/security_configuration/components/app_spec.js b/ee/spec/frontend/security_configuration/components/app_spec.js index f10565a3f00d250f7ae5db78ad93dea716d9880b..04d7aa5255c315ad0b181702e4cfe4685276bc0f 100644 --- a/ee/spec/frontend/security_configuration/components/app_spec.js +++ b/ee/spec/frontend/security_configuration/components/app_spec.js @@ -1,27 +1,37 @@ import { mount } from '@vue/test-utils'; -import { GlLink } from '@gitlab/ui'; +import { merge } from 'lodash'; +import { GlAlert, GlLink } from '@gitlab/ui'; import SecurityConfigurationApp from 'ee/security_configuration/components/app.vue'; import stubChildren from 'helpers/stub_children'; +const propsData = { + features: [], + autoDevopsEnabled: false, + latestPipelinePath: 'http://latestPipelinePath', + autoDevopsHelpPagePath: 'http://autoDevopsHelpPagePath', + autoDevopsPath: 'http://autoDevopsPath', + helpPagePath: 'http://helpPagePath', + autoFixSettingsProps: {}, +}; + describe('Security Configuration App', () => { let wrapper; - const createComponent = (props = {}) => { - wrapper = mount(SecurityConfigurationApp, { - stubs: { - ...stubChildren(SecurityConfigurationApp), - GlTable: false, - GlSprintf: false, - }, - propsData: { - features: [], - autoDevopsEnabled: false, - latestPipelinePath: 'http://latestPipelinePath', - autoDevopsHelpPagePath: 'http://autoDevopsHelpPagePath', - helpPagePath: 'http://helpPagePath', - autoFixSettingsProps: {}, - ...props, - }, - }); + const createComponent = (options = {}) => { + wrapper = mount( + SecurityConfigurationApp, + merge( + {}, + { + stubs: { + ...stubChildren(SecurityConfigurationApp), + GlTable: false, + GlSprintf: false, + }, + propsData, + }, + options, + ), + ); }; afterEach(() => { @@ -39,16 +49,17 @@ describe('Security Configuration App', () => { const getPipelinesLink = () => wrapper.find({ ref: 'pipelinesLink' }); const getFeaturesTable = () => wrapper.find({ ref: 'securityControlTable' }); + const getAlert = () => wrapper.find(GlAlert); describe('header', () => { it.each` autoDevopsEnabled | expectedUrl - ${true} | ${'http://autoDevopsHelpPagePath'} - ${false} | ${'http://latestPipelinePath'} + ${true} | ${propsData.autoDevopsHelpPagePath} + ${false} | ${propsData.latestPipelinePath} `( 'displays a link to "$expectedUrl" when autoDevops is "$autoDevopsEnabled"', ({ autoDevopsEnabled, expectedUrl }) => { - createComponent({ autoDevopsEnabled }); + createComponent({ propsData: { autoDevopsEnabled } }); expect(getPipelinesLink().attributes('href')).toBe(expectedUrl); expect(getPipelinesLink().attributes('target')).toBe('_blank'); @@ -56,11 +67,68 @@ describe('Security Configuration App', () => { ); }); + describe('Auto DevOps alert', () => { + describe.each` + gitlabCiPresent | autoDevopsEnabled | canEnableAutoDevops | sastConfigurationByClick | shouldShowAlert + ${false} | ${false} | ${true} | ${true} | ${true} + ${true} | ${false} | ${true} | ${true} | ${false} + ${false} | ${true} | ${true} | ${true} | ${false} + ${false} | ${false} | ${false} | ${true} | ${false} + ${false} | ${false} | ${true} | ${false} | ${false} + `( + 'given gitlabCiPresent is $gitlabCiPresent, autoDevopsEnabled is $autoDevopsEnabled, canEnableAutoDevops is $canEnableAutoDevops, sastConfigurationByClick is $sastConfigurationByClick', + ({ + gitlabCiPresent, + autoDevopsEnabled, + canEnableAutoDevops, + sastConfigurationByClick, + shouldShowAlert, + }) => { + beforeEach(() => { + createComponent({ + propsData: { + gitlabCiPresent, + autoDevopsEnabled, + canEnableAutoDevops, + }, + provide: { glFeatures: { sastConfigurationByClick } }, + }); + }); + + it(`is${shouldShowAlert ? '' : ' not'} rendered`, () => { + expect(getAlert().exists()).toBe(shouldShowAlert); + }); + + if (shouldShowAlert) { + it('has the expected text', () => { + expect(getAlert().text()).toMatchInterpolatedText( + SecurityConfigurationApp.autoDevopsAlertMessage, + ); + }); + + it('has a link to the Auto DevOps docs', () => { + const link = getAlert().find(GlLink); + expect(link.attributes().href).toBe(propsData.autoDevopsHelpPagePath); + }); + + it('has the correct primary button', () => { + expect(getAlert().props()).toMatchObject({ + title: 'Auto DevOps', + primaryButtonText: 'Enable Auto DevOps', + primaryButtonLink: propsData.autoDevopsPath, + dismissible: false, + }); + }); + } + }, + ); + }); + describe('features table', () => { it('passes the expected data to the GlTable', () => { const features = generateFeatures(5); - createComponent({ features }); + createComponent({ propsData: { features } }); expect(getFeaturesTable().classes('b-table-stacked-md')).toBeTruthy(); const rows = getFeaturesTable().findAll('tbody tr'); diff --git a/locale/gitlab.pot b/locale/gitlab.pot index b53b43f02e6ca98e559b1750dc3749122944e4c0..ea171aa2e0d64cf4b92dba553f07979c5aaafd4e 100644 --- a/locale/gitlab.pot +++ b/locale/gitlab.pot @@ -20441,6 +20441,9 @@ msgstr "" msgid "SecurityConfiguration|Testing & Compliance" msgstr "" +msgid "SecurityConfiguration|You can quickly enable all security scanning tools by enabling %{linkStart}Auto DevOps%{linkEnd}." +msgstr "" + msgid "SecurityReports|%{firstProject} and %{secondProject}" msgstr "" diff --git a/spec/frontend/lib/utils/dom_utils_spec.js b/spec/frontend/lib/utils/dom_utils_spec.js index 10b4a10a8ffcae667270702b94fac25ee9492dae..d918016a5f42ef75d9e62adf2968d56d233dcaea 100644 --- a/spec/frontend/lib/utils/dom_utils_spec.js +++ b/spec/frontend/lib/utils/dom_utils_spec.js @@ -1,4 +1,9 @@ -import { addClassIfElementExists, canScrollUp, canScrollDown } from '~/lib/utils/dom_utils'; +import { + addClassIfElementExists, + canScrollUp, + canScrollDown, + parseBooleanDataAttributes, +} from '~/lib/utils/dom_utils'; const TEST_MARGIN = 5; @@ -112,4 +117,47 @@ describe('DOM Utils', () => { expect(canScrollDown(element, TEST_MARGIN)).toBe(false); }); }); + + describe('parseBooleanDataAttributes', () => { + let element; + + beforeEach(() => { + setFixtures('<div data-foo-bar data-baz data-qux="">'); + element = document.querySelector('[data-foo-bar]'); + }); + + it('throws if not given an element', () => { + expect(() => parseBooleanDataAttributes(null, ['baz'])).toThrow(); + }); + + it('throws if not given an array of dataset names', () => { + expect(() => parseBooleanDataAttributes(element)).toThrow(); + }); + + it('returns an empty object if given an empty array of names', () => { + expect(parseBooleanDataAttributes(element, [])).toEqual({}); + }); + + it('correctly parses boolean-like data attributes', () => { + expect( + parseBooleanDataAttributes(element, [ + 'fooBar', + 'foobar', + 'baz', + 'qux', + 'doesNotExist', + 'toString', + ]), + ).toEqual({ + fooBar: true, + foobar: false, + baz: true, + qux: true, + doesNotExist: false, + + // Ensure prototype properties aren't false positives + toString: false, + }); + }); + }); });