Commit 68af9f23 authored by Nicolò Maria Mezzopera's avatar Nicolò Maria Mezzopera

Merge branch...

Merge branch '231372-sast-config-ui-page-basic-sast-wide-settings-wire-up-graphql-query-to-dynamic-form' into 'master'

Wire up GraphQL query to dynamic form

See merge request gitlab-org/gitlab!38318
parents 657e84fe b097c121
<script> <script>
import { GlIcon, GlLink, GlSprintf } from '@gitlab/ui'; import { GlAlert, GlLink, GlLoadingIcon, GlSprintf } from '@gitlab/ui';
import { s__ } from '~/locale'; import { s__ } from '~/locale';
import sastCiConfigurationQuery from '../graphql/sast_ci_configuration.query.graphql';
import DynamicFields from './dynamic_fields.vue';
import { extractSastConfigurationEntities } from './utils';
export default { export default {
components: { components: {
GlIcon, DynamicFields,
GlAlert,
GlLink, GlLink,
GlLoadingIcon,
GlSprintf, GlSprintf,
}, },
props: { inject: {
sastDocumentationPath: { sastDocumentationPath: {
type: String, from: 'sastDocumentationPath',
required: true, default: '',
}, },
projectPath: {
from: 'projectPath',
default: '',
}, },
},
apollo: {
sastConfigurationEntities: {
query: sastCiConfigurationQuery,
variables() {
return {
fullPath: this.projectPath,
};
},
update: extractSastConfigurationEntities,
result({ loading }) {
if (!loading && this.sastConfigurationEntities.length === 0) {
this.onError();
}
},
error() {
this.onError();
},
},
},
data() {
return {
sastConfigurationEntities: [],
hasLoadingError: false,
};
},
methods: {
onError() {
this.hasLoadingError = true;
},
},
i18n: {
helpText: s__( helpText: s__(
`SecurityConfiguration|Customize common SAST settings to suit your `SecurityConfiguration|Customize common SAST settings to suit your
requirements. More advanced configuration options exist, which you can add requirements. More advanced configuration options exist, which you can
to the configuration file this tool generates. It's important to note that add to the configuration file this tool generates. It's important to note
if you make any configurations, they will be saved as overrides and will be that if you make any configurations, they will be saved as overrides and
%{strongStart}excluded from automatic updates%{strongEnd}.`, will be excluded from automatic updates. We've provided guidance for some
easily configurable variables below, but our docs go into even more
depth. %{linkStart}Read more%{linkEnd}`,
), ),
loadingErrorText: s__(
`SecurityConfiguration|There was an error loading the configuration.
Please reload the page to try again.`,
),
},
}; };
</script> </script>
<template> <template>
<article> <article>
<header class="my-3 gl-border-b-1 gl-border-b-gray-100 gl-border-b-solid"> <header class="gl-my-5 gl-border-b-1 gl-border-b-gray-100 gl-border-b-solid">
<h2 class="h4"> <h2 class="h4">
{{ __('SAST Configuration') }} {{ s__('SecurityConfiguration|SAST Configuration') }}
<gl-link
:href="sastDocumentationPath"
class="vertical-align-middle"
target="_blank"
:aria-label="__('Help')"
>
<gl-icon name="question" />
</gl-link>
</h2> </h2>
<p> <p>
<gl-sprintf :message="$options.helpText"> <gl-sprintf :message="$options.i18n.helpText">
<template #strong="{ content }"> <template #link="{ content }">
<strong>{{ content }}</strong> <gl-link :href="sastDocumentationPath" target="_blank" v-text="content" />
</template> </template>
</gl-sprintf> </gl-sprintf>
</p> </p>
</header> </header>
<gl-loading-icon v-if="$apollo.loading" size="lg" />
<gl-alert v-else-if="hasLoadingError" variant="danger" :dismissible="false">{{
$options.i18n.loadingErrorText
}}</gl-alert>
<dynamic-fields v-else v-model="sastConfigurationEntities" />
</article> </article>
</template> </template>
...@@ -10,6 +10,10 @@ export default { ...@@ -10,6 +10,10 @@ export default {
GlLink, GlLink,
GlSprintf, GlSprintf,
}, },
// The DynamicFields component v-binds the configuration entity to this
// component. This ensures extraneous keys/values are not added as attributes
// to the underlying GlFormGroup.
inheritAttrs: false,
model: { model: {
prop: 'value', prop: 'value',
event: 'input', event: 'input',
......
const isString = value => typeof value === 'string'; const isString = value => typeof value === 'string';
// eslint-disable-next-line import/prefer-default-export
export const isValidConfigurationEntity = object => { export const isValidConfigurationEntity = object => {
if (object == null) { if (object == null) {
return false; return false;
...@@ -17,3 +16,12 @@ export const isValidConfigurationEntity = object => { ...@@ -17,3 +16,12 @@ export const isValidConfigurationEntity = object => {
value !== undefined value !== undefined
); );
}; };
export const extractSastConfigurationEntities = ({ project }) => {
if (!project?.sastCiConfiguration) {
return [];
}
const { global, pipeline } = project.sastCiConfiguration;
return [...global.nodes, ...pipeline.nodes];
};
#import "./sast_ci_configuration_entity.fragment.graphql"
query sastCiConfiguration($fullPath: ID!) {
project(fullPath: $fullPath) {
sastCiConfiguration {
global {
nodes {
...SastCiConfigurationEntityFragment
}
}
pipeline {
nodes {
...SastCiConfigurationEntityFragment
}
}
}
}
}
fragment SastCiConfigurationEntityFragment on SastCiConfigurationEntity {
defaultValue
description
field
label
type
value
}
import Vue from 'vue'; import Vue from 'vue';
import VueApollo from 'vue-apollo';
import createDefaultClient from '~/lib/graphql';
import SASTConfigurationApp from './components/app.vue'; import SASTConfigurationApp from './components/app.vue';
export default function init() { export default function init() {
...@@ -8,16 +10,23 @@ export default function init() { ...@@ -8,16 +10,23 @@ export default function init() {
return undefined; return undefined;
} }
const { sastDocumentationPath } = el.dataset; Vue.use(VueApollo);
const apolloProvider = new VueApollo({
defaultClient: createDefaultClient(),
});
const { projectPath, sastDocumentationPath } = el.dataset;
return new Vue({ return new Vue({
el, el,
render(createElement) { apolloProvider,
return createElement(SASTConfigurationApp, { provide: {
props: { projectPath,
sastDocumentationPath, sastDocumentationPath,
}, },
}); render(createElement) {
return createElement(SASTConfigurationApp);
}, },
}); });
} }
# frozen_string_literal: true
module Projects::Security::SastConfigurationHelper
def sast_configuration_data(project)
{
sast_documentation_path: help_page_path('user/application_security/sast/index', anchor: 'configuration'),
project_path: project.full_path
}
end
end
...@@ -2,4 +2,4 @@ ...@@ -2,4 +2,4 @@
- breadcrumb_title _("SAST Configuration") - breadcrumb_title _("SAST Configuration")
- page_title _("SAST Configuration") - page_title _("SAST Configuration")
.js-sast-configuration{ data: { sast_documentation_path: help_page_path('user/application_security/sast/index', anchor: 'configuration') } } .js-sast-configuration{ data: sast_configuration_data(@project) }
import { shallowMount } from '@vue/test-utils'; import { shallowMount } from '@vue/test-utils';
import { GlLink, GlSprintf } from '@gitlab/ui'; import { GlAlert, GlLink, GlLoadingIcon, GlSprintf } from '@gitlab/ui';
import SASTConfigurationApp from 'ee/security_configuration/sast/components/app.vue'; import SASTConfigurationApp from 'ee/security_configuration/sast/components/app.vue';
import DynamicFields from 'ee/security_configuration/sast/components/dynamic_fields.vue';
import { makeEntities } from './helpers';
const sastDocumentationPath = '/help/sast'; const sastDocumentationPath = '/help/sast';
const projectPath = 'namespace/project';
describe('SAST Configuration App', () => { describe('SAST Configuration App', () => {
let wrapper; let wrapper;
const createComponent = ({ props = {}, stubs = {} } = {}) => { const createComponent = ({
provide = {},
stubs = {},
loading = false,
hasLoadingError = false,
sastConfigurationEntities = [],
} = {}) => {
wrapper = shallowMount(SASTConfigurationApp, { wrapper = shallowMount(SASTConfigurationApp, {
mocks: { $apollo: { loading } },
stubs, stubs,
propsData: { provide: {
...props, sastDocumentationPath,
projectPath,
...provide,
}, },
}); });
// While setData is usually frowned upon, it is the documented way of
// mocking GraphQL response data:
// https://docs.gitlab.com/ee/development/fe_guide/graphql.html#testing
wrapper.setData({
hasLoadingError,
sastConfigurationEntities,
});
}; };
const findHeader = () => wrapper.find('header'); const findHeader = () => wrapper.find('header');
const findSubHeading = () => findHeader().find('p'); const findSubHeading = () => findHeader().find('p');
const findLink = (container = wrapper) => container.find(GlLink); const findLink = (container = wrapper) => container.find(GlLink);
const findDynamicFields = () => wrapper.find(DynamicFields);
const findLoadingIcon = () => wrapper.find(GlLoadingIcon);
const findErrorAlert = () => wrapper.find(GlAlert);
afterEach(() => { afterEach(() => {
wrapper.destroy(); wrapper.destroy();
...@@ -28,7 +51,6 @@ describe('SAST Configuration App', () => { ...@@ -28,7 +51,6 @@ describe('SAST Configuration App', () => {
describe('header', () => { describe('header', () => {
beforeEach(() => { beforeEach(() => {
createComponent({ createComponent({
props: { sastDocumentationPath },
stubs: { GlSprintf }, stubs: { GlSprintf },
}); });
}); });
...@@ -38,7 +60,86 @@ describe('SAST Configuration App', () => { ...@@ -38,7 +60,86 @@ describe('SAST Configuration App', () => {
}); });
it('displays the subheading', () => { it('displays the subheading', () => {
expect(findSubHeading().text()).toMatchInterpolatedText(SASTConfigurationApp.helpText); expect(findSubHeading().text()).toMatchInterpolatedText(SASTConfigurationApp.i18n.helpText);
});
});
describe('when loading', () => {
beforeEach(() => {
createComponent({
loading: true,
});
});
it('displays a loading spinner', () => {
expect(findLoadingIcon().exists()).toBe(true);
});
it('does not display the dynamic fields component', () => {
expect(findDynamicFields().exists()).toBe(false);
});
it('does not display an alert message', () => {
expect(findErrorAlert().exists()).toBe(false);
});
});
describe('when loading failed', () => {
beforeEach(() => {
createComponent({
hasLoadingError: true,
});
});
it('does not display a loading spinner', () => {
expect(findLoadingIcon().exists()).toBe(false);
});
it('does not display the dynamic fields component', () => {
expect(findDynamicFields().exists()).toBe(false);
});
it('displays an alert message', () => {
expect(findErrorAlert().exists()).toBe(true);
});
});
describe('when loaded', () => {
const entities = makeEntities(3);
beforeEach(() => {
createComponent({
sastConfigurationEntities: entities,
});
});
it('does not display a loading spinner', () => {
expect(findLoadingIcon().exists()).toBe(false);
});
it('displays the dynamic fields component', () => {
const dynamicFields = findDynamicFields();
expect(dynamicFields.exists()).toBe(true);
expect(dynamicFields.props('entities')).toBe(entities);
});
it('does not display an alert message', () => {
expect(findErrorAlert().exists()).toBe(false);
});
describe('when the dynamic fields component emits an input event', () => {
let dynamicFields;
let newEntities;
beforeEach(() => {
dynamicFields = findDynamicFields();
newEntities = makeEntities(3, { value: 'foo' });
dynamicFields.vm.$emit(DynamicFields.model.event, newEntities);
});
it('updates the entities binding', () => {
expect(dynamicFields.props('entities')).toBe(newEntities);
});
}); });
}); });
}); });
...@@ -10,11 +10,11 @@ ...@@ -10,11 +10,11 @@
// eslint-disable-next-line import/prefer-default-export // eslint-disable-next-line import/prefer-default-export
export const makeEntities = (count, changes) => export const makeEntities = (count, changes) =>
[...Array(count).keys()].map(i => ({ [...Array(count).keys()].map(i => ({
defaultValue: `defaultValue${i}`,
description: `description${i}`,
field: `field${i}`, field: `field${i}`,
label: `label${i}`, label: `label${i}`,
description: `description${i}`,
defaultValue: `defaultValue${i}`,
value: `defaultValue${i}`,
type: 'string', type: 'string',
value: `defaultValue${i}`,
...changes, ...changes,
})); }));
import { isValidConfigurationEntity } from 'ee/security_configuration/sast/components/utils'; import {
isValidConfigurationEntity,
extractSastConfigurationEntities,
} from 'ee/security_configuration/sast/components/utils';
import { makeEntities } from './helpers'; import { makeEntities } from './helpers';
describe('isValidConfigurationEntity', () => { describe('isValidConfigurationEntity', () => {
...@@ -25,3 +28,37 @@ describe('isValidConfigurationEntity', () => { ...@@ -25,3 +28,37 @@ describe('isValidConfigurationEntity', () => {
expect(isValidConfigurationEntity(invalidEntity)).toBe(false); expect(isValidConfigurationEntity(invalidEntity)).toBe(false);
}); });
}); });
describe('extractSastConfigurationEntities', () => {
describe.each`
context | response
${'which is empty'} | ${{}}
${'with no project'} | ${{ project: null }}
${'with no configuration'} | ${{ project: {} }}
`('given a response $context', ({ response }) => {
it('returns an empty array', () => {
expect(extractSastConfigurationEntities(response)).toEqual([]);
});
});
describe('given a valid response', () => {
it('returns an array of entities from the global and pipeline sections', () => {
const globalEntities = makeEntities(3, { description: 'global' });
const pipelineEntities = makeEntities(3, { description: 'pipeline' });
const response = {
project: {
sastCiConfiguration: {
global: { nodes: globalEntities },
pipeline: { nodes: pipelineEntities },
},
},
};
expect(extractSastConfigurationEntities(response)).toEqual([
...globalEntities,
...pipelineEntities,
]);
});
});
});
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe Projects::Security::SastConfigurationHelper do
let_it_be(:project) { create(:project) }
let(:project_path) { project.full_path }
let(:docs_path) { help_page_path('user/application_security/sast/index', anchor: 'configuration') }
describe '#sast_configuration_data' do
subject { helper.sast_configuration_data(project) }
it {
is_expected.to eq({
sast_documentation_path: docs_path,
project_path: project_path
})
}
end
end
...@@ -21306,7 +21306,7 @@ msgstr "" ...@@ -21306,7 +21306,7 @@ msgstr ""
msgid "SecurityConfiguration|Configure" msgid "SecurityConfiguration|Configure"
msgstr "" msgstr ""
msgid "SecurityConfiguration|Customize common SAST settings to suit your requirements. More advanced configuration options exist, which you can add to the configuration file this tool generates. It's important to note that if you make any configurations, they will be saved as overrides and will be %{strongStart}excluded from automatic updates%{strongEnd}." msgid "SecurityConfiguration|Customize common SAST settings to suit your requirements. More advanced configuration options exist, which you can add to the configuration file this tool generates. It's important to note that if you make any configurations, they will be saved as overrides and will be excluded from automatic updates. We've provided guidance for some easily configurable variables below, but our docs go into even more depth. %{linkStart}Read more%{linkEnd}"
msgstr "" msgstr ""
msgid "SecurityConfiguration|Enable via Merge Request" msgid "SecurityConfiguration|Enable via Merge Request"
...@@ -21327,6 +21327,9 @@ msgstr "" ...@@ -21327,6 +21327,9 @@ msgstr ""
msgid "SecurityConfiguration|Not enabled" msgid "SecurityConfiguration|Not enabled"
msgstr "" msgstr ""
msgid "SecurityConfiguration|SAST Configuration"
msgstr ""
msgid "SecurityConfiguration|Security Control" msgid "SecurityConfiguration|Security Control"
msgstr "" msgstr ""
...@@ -21339,6 +21342,9 @@ msgstr "" ...@@ -21339,6 +21342,9 @@ msgstr ""
msgid "SecurityConfiguration|Testing & Compliance" msgid "SecurityConfiguration|Testing & Compliance"
msgstr "" msgstr ""
msgid "SecurityConfiguration|There was an error loading the configuration. Please reload the page to try again."
msgstr ""
msgid "SecurityConfiguration|Using custom settings. You won't receive automatic updates on this variable. %{anchorStart}Restore to default%{anchorEnd}" msgid "SecurityConfiguration|Using custom settings. You won't receive automatic updates on this variable. %{anchorStart}Restore to default%{anchorEnd}"
msgstr "" msgstr ""
......
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment