Commit a408f7eb authored by Robert Hunt's avatar Robert Hunt Committed by Nicolò Maria Mezzopera

Created listing of compliance framework labels for groups

- Added new EE-only partial to groups edit HAML
- Added Vue ID to new partial and disabled it if the feature flag is off
- Created new initializer to trigger the Vue app
- Created new list Vue app with a tabulated list, empty state, error
alert and list items
- List item contains the label + color and description
- Added new GraphQL query to get the compliance framework labels list
parent 6dfafeb5
...@@ -40,6 +40,7 @@ ...@@ -40,6 +40,7 @@
.settings-content .settings-content
= render 'shared/badges/badge_settings' = render 'shared/badges/badge_settings'
= render_if_exists 'groups/compliance_frameworks', expanded: expanded
= render_if_exists 'groups/custom_project_templates_setting' = render_if_exists 'groups/custom_project_templates_setting'
= render_if_exists 'groups/templates_setting', expanded: expanded = render_if_exists 'groups/templates_setting', expanded: expanded
......
<script>
import { GlAlert, GlLoadingIcon, GlTab, GlTabs } from '@gitlab/ui';
import { getIdFromGraphQLId } from '~/graphql_shared/utils';
import { s__ } from '~/locale';
import * as Sentry from '~/sentry/wrapper';
import getComplianceFrameworkQuery from '../graphql/queries/get_compliance_framework.query.graphql';
import ListItem from './list_item.vue';
import EmptyState from './list_empty_state.vue';
export default {
components: {
EmptyState,
GlAlert,
GlLoadingIcon,
ListItem,
GlTab,
GlTabs,
},
props: {
emptyStateSvgPath: {
type: String,
required: true,
},
groupPath: {
type: String,
required: true,
},
},
data() {
return {
complianceFrameworks: [],
error: '',
};
},
apollo: {
complianceFrameworks: {
query: getComplianceFrameworkQuery,
variables() {
return {
fullPath: this.groupPath,
};
},
update(data) {
const nodes = data.namespace?.complianceFrameworks?.nodes;
return (
nodes?.map(framework => ({
...framework,
parsedId: getIdFromGraphQLId(framework.id),
})) || []
);
},
error(error) {
this.error = this.$options.i18n.fetchError;
Sentry.captureException(error);
},
},
},
computed: {
isLoading() {
return this.$apollo.loading;
},
hasLoaded() {
return !this.isLoading && !this.error;
},
frameworksCount() {
return this.complianceFrameworks.length;
},
isEmpty() {
return this.hasLoaded && this.frameworksCount === 0;
},
hasFrameworks() {
return this.hasLoaded && this.frameworksCount > 0;
},
regulatedCount() {
return 0;
},
},
i18n: {
fetchError: s__(
'ComplianceFrameworks|Error fetching compliance frameworks data. Please refresh the page',
),
allTab: s__('ComplianceFrameworks|All'),
regulatedTab: s__('ComplianceFrameworks|Regulated'),
},
};
</script>
<template>
<div class="gl-border-t-1 gl-border-t-solid gl-border-t-gray-100">
<gl-alert v-if="error" class="gl-mt-5" variant="danger" :dismissible="false">
{{ error }}
</gl-alert>
<gl-loading-icon v-if="isLoading" size="lg" class="gl-mt-5" />
<empty-state v-if="isEmpty" :image-path="emptyStateSvgPath" />
<gl-tabs v-if="hasFrameworks">
<gl-tab class="gl-mt-6" :title="$options.i18n.allTab">
<list-item
v-for="framework in complianceFrameworks"
:key="framework.parsedId"
:framework="framework"
/>
</gl-tab>
<gl-tab disabled :title="$options.i18n.regulatedTab" />
</gl-tabs>
</div>
</template>
<script>
import { GlEmptyState } from '@gitlab/ui';
import { s__ } from '~/locale';
export default {
components: {
GlEmptyState,
},
props: {
imagePath: {
type: String,
required: true,
},
},
i18n: {
heading: s__('ComplianceFrameworks|There are no compliance frameworks set up yet'),
description: s__(
'ComplianceFrameworks|Once you have created a compliance framework it will appear here.',
),
addButton: s__('ComplianceFrameworks|Add framework'),
},
};
</script>
<template>
<gl-empty-state
:title="$options.i18n.heading"
:description="$options.i18n.description"
:svg-path="imagePath"
primary-button-link="#"
:primary-button-text="$options.i18n.addButton"
compact
:svg-height="110"
class="gl-mt-5"
/>
</template>
<script>
import { GlLabel } from '@gitlab/ui';
export default {
components: {
GlLabel,
},
props: {
framework: {
type: Object,
required: true,
},
},
computed: {
isScoped() {
return this.framework.name.includes('::');
},
},
};
</script>
<template>
<div
class="gl-display-flex gl-align-items-center gl-justify-content-space-between gl-inset-border-1-gray-100 gl-px-5 gl-p-6 gl-mb-4"
>
<div class="gl-w-quarter gl-mr-3 gl-flex-shrink-0">
<gl-label
target="#"
:background-color="framework.color"
:title="framework.name"
:scoped="isScoped"
/>
</div>
<p class="gl-w-full gl-m-0!" data-testid="compliance-framework-description">
{{ framework.description }}
</p>
</div>
</template>
query getComplianceFramework($fullPath: ID!) {
namespace(fullPath: $fullPath) {
id
name
complianceFrameworks {
nodes {
id
name
description
color
}
}
}
}
import Vue from 'vue';
import VueApollo from 'vue-apollo';
import createDefaultClient from '~/lib/graphql';
import Form from './components/list.vue';
Vue.use(VueApollo);
const apolloProvider = new VueApollo({
defaultClient: createDefaultClient(),
});
const createComplianceFrameworksListApp = el => {
if (!el) {
return false;
}
const { emptyStateSvgPath, groupPath } = el.dataset;
return new Vue({
el,
apolloProvider,
render(createElement) {
return createElement(Form, {
props: {
emptyStateSvgPath,
groupPath,
},
});
},
});
};
export { createComplianceFrameworksListApp };
import '~/pages/groups/edit'; import '~/pages/groups/edit';
import initAccessRestrictionField from 'ee/groups/settings/access_restriction_field';
import validateRestrictedIpAddress from 'ee/groups/settings/access_restriction_field/validate_ip_address'; import validateRestrictedIpAddress from 'ee/groups/settings/access_restriction_field/validate_ip_address';
import initAccessRestrictionField from 'ee/groups/settings/access_restriction_field';
import createFlash from '~/flash';
import { __ } from '~/locale'; import { __ } from '~/locale';
initAccessRestrictionField('.js-allowed-email-domains', { initAccessRestrictionField('.js-allowed-email-domains', {
...@@ -8,9 +9,25 @@ initAccessRestrictionField('.js-allowed-email-domains', { ...@@ -8,9 +9,25 @@ initAccessRestrictionField('.js-allowed-email-domains', {
regexErrorMessage: __('The domain you entered is misformatted.'), regexErrorMessage: __('The domain you entered is misformatted.'),
disallowedValueErrorMessage: __('The domain you entered is not allowed.'), disallowedValueErrorMessage: __('The domain you entered is not allowed.'),
}); });
initAccessRestrictionField( initAccessRestrictionField(
'.js-ip-restriction', '.js-ip-restriction',
{ placeholder: __('Enter IP address range') }, { placeholder: __('Enter IP address range') },
'ip_restriction_field', 'ip_restriction_field',
validateRestrictedIpAddress, validateRestrictedIpAddress,
); );
const complianceFrameworksList = document.querySelector('#js-compliance-frameworks-list');
if (complianceFrameworksList) {
(async () => {
try {
const { createComplianceFrameworksListApp } = await import(
/* webpackChunkName: 'createComplianceFrameworksListApp' */ 'ee/groups/settings/compliance_frameworks/init_list'
);
createComplianceFrameworksListApp(complianceFrameworksList);
} catch {
createFlash({ message: __('An error occurred while loading a section of this page.') });
}
})();
}
# frozen_string_literal: true
module ComplianceManagement
module ComplianceFramework
module GroupSettingsHelper
def show_compliance_frameworks?
License.feature_available?(:custom_compliance_frameworks) && Feature.enabled?(:ff_custom_compliance_frameworks)
end
def compliance_frameworks_list_data
{
empty_state_svg_path: image_path('illustrations/welcome/ee_trial.svg'),
group_path: @group.full_path
}
end
end
end
end
- expanded = expanded_by_default?
- if show_compliance_frameworks?
%section.settings.no-animate#js-compliance-frameworks-settings{ class: ('expanded' if expanded) }
.settings-header
%h4
= s_('GroupSettings|Compliance frameworks')
%button.btn.gl-button.js-settings-toggle{ type: 'button' }
= expanded ? _('Collapse') : _('Expand')
%p
= s_('GroupSettings|Configure frameworks to apply enforceable rules to projects.')
.settings-content
#js-compliance-frameworks-list{ data: compliance_frameworks_list_data }
import { GlEmptyState } from '@gitlab/ui';
import { shallowMount } from '@vue/test-utils';
import ListEmptyState from 'ee/groups/settings/compliance_frameworks/components/list_empty_state.vue';
describe('ListEmptyState', () => {
let wrapper;
const findEmptyState = () => wrapper.find(GlEmptyState);
const createComponent = (props = {}) => {
wrapper = shallowMount(ListEmptyState, {
propsData: {
imagePath: 'dir/image.svg',
...props,
},
});
};
afterEach(() => {
wrapper.destroy();
});
it('has the correct props', () => {
createComponent();
expect(findEmptyState().props()).toMatchObject({
title: 'There are no compliance frameworks set up yet',
description: 'Once you have created a compliance framework it will appear here.',
svgPath: 'dir/image.svg',
primaryButtonLink: '#',
primaryButtonText: 'Add framework',
svgHeight: 110,
compact: true,
});
});
});
import { GlLabel } from '@gitlab/ui';
import { shallowMount } from '@vue/test-utils';
import ListItem from 'ee/groups/settings/compliance_frameworks/components/list_item.vue';
describe('ListItem', () => {
let wrapper;
const framework = { name: 'framework', description: 'a framework', color: '#112233' };
const findLabel = () => wrapper.find(GlLabel);
const findDescription = () => wrapper.find('[data-testid="compliance-framework-description"]');
const createComponent = (props = {}) => {
wrapper = shallowMount(ListItem, {
propsData: {
framework,
...props,
},
});
};
afterEach(() => {
wrapper.destroy();
});
it('displays the description defined by the framework', () => {
createComponent();
expect(findDescription().text()).toBe('a framework');
});
it('displays the label as unscoped', () => {
createComponent();
expect(findLabel().props('title')).toBe('framework');
expect(findLabel().props('scoped')).toBe(false);
});
it('displays the label as scoped', () => {
createComponent({ framework: { ...framework, name: 'scoped::framework' } });
expect(findLabel().props('title')).toBe('scoped::framework');
expect(findLabel().props('scoped')).toBe(true);
});
});
import { GlAlert, GlLoadingIcon, GlTab, GlTabs } from '@gitlab/ui';
import VueApollo from 'vue-apollo';
import { createLocalVue, shallowMount } from '@vue/test-utils';
import waitForPromises from 'helpers/wait_for_promises';
import createMockApollo from 'jest/helpers/mock_apollo_helper';
import getComplianceFrameworkQuery from 'ee/groups/settings/compliance_frameworks/graphql/queries/get_compliance_framework.query.graphql';
import List from 'ee/groups/settings/compliance_frameworks/components/list.vue';
import ListItem from 'ee/groups/settings/compliance_frameworks/components/list_item.vue';
import EmptyState from 'ee/groups/settings/compliance_frameworks/components/list_empty_state.vue';
import { validFetchResponse, emptyFetchResponse } from '../mock_data';
import * as Sentry from '~/sentry/wrapper';
const localVue = createLocalVue();
localVue.use(VueApollo);
describe('List', () => {
let wrapper;
const sentryError = new Error('Network error');
const fetch = jest.fn().mockResolvedValue(validFetchResponse);
const fetchEmpty = jest.fn().mockResolvedValue(emptyFetchResponse);
const fetchLoading = jest.fn().mockResolvedValue(new Promise(() => {}));
const fetchWithErrors = jest.fn().mockRejectedValue(sentryError);
const findAlert = () => wrapper.find(GlAlert);
const findLoadingIcon = () => wrapper.find(GlLoadingIcon);
const findEmptyState = () => wrapper.find(EmptyState);
const findTabs = () => wrapper.findAll(GlTab);
const findTabsContainer = () => wrapper.find(GlTabs);
const findListItems = () => wrapper.findAll(ListItem);
function createMockApolloProvider(resolverMock) {
localVue.use(VueApollo);
const requestHandlers = [[getComplianceFrameworkQuery, resolverMock]];
return createMockApollo(requestHandlers);
}
function createComponentWithApollo(resolverMock) {
return shallowMount(List, {
localVue,
apolloProvider: createMockApolloProvider(resolverMock),
propsData: {
emptyStateSvgPath: 'dir/image.svg',
groupPath: 'group-1',
},
stubs: {
GlLoadingIcon,
GlTab,
GlTabs,
},
});
}
afterEach(() => {
wrapper.destroy();
});
describe('loading', () => {
beforeEach(() => {
wrapper = createComponentWithApollo(fetchLoading);
});
it('shows the loader', () => {
expect(findLoadingIcon().exists()).toBe(true);
});
it('does not show the other parts of the app', () => {
expect(findAlert().exists()).toBe(false);
expect(findTabsContainer().exists()).toBe(false);
expect(findEmptyState().exists()).toBe(false);
});
});
describe('fetching error', () => {
beforeEach(() => {
wrapper = createComponentWithApollo(fetchWithErrors);
});
it('shows the alert', () => {
expect(findAlert().text()).toBe(
'Error fetching compliance frameworks data. Please refresh the page',
);
});
it('does not show the other parts of the app', () => {
expect(findLoadingIcon().exists()).toBe(false);
expect(findTabsContainer().exists()).toBe(false);
expect(findEmptyState().exists()).toBe(false);
});
it('should fetch data once', () => {
expect(fetchWithErrors).toHaveBeenCalledTimes(1);
});
it('sends the error to Sentry', async () => {
jest.spyOn(Sentry, 'captureException');
await waitForPromises();
expect(Sentry.captureException.mock.calls[0][0].networkError).toBe(sentryError);
});
});
describe('empty state', () => {
beforeEach(() => {
wrapper = createComponentWithApollo(fetchEmpty);
});
it('shows the empty state', () => {
expect(findEmptyState().exists()).toBe(true);
expect(findEmptyState().props('imagePath')).toBe('dir/image.svg');
});
it('does not show the other parts of the app', () => {
expect(findAlert().exists()).toBe(false);
expect(findLoadingIcon().exists()).toBe(false);
expect(findTabsContainer().exists()).toBe(false);
});
});
describe('content', () => {
beforeEach(() => {
wrapper = createComponentWithApollo(fetch);
});
it('does not show the other parts of the app', () => {
expect(findAlert().exists()).toBe(false);
expect(findLoadingIcon().exists()).toBe(false);
expect(findEmptyState().exists()).toBe(false);
});
it('shows the tabs', () => {
expect(findTabsContainer().exists()).toBe(true);
expect(findTabs()).toHaveLength(2);
});
it('shows the all tab', () => {
expect(
findTabs()
.at(0)
.attributes('title'),
).toBe('All');
});
it('shows the disabled regulated tab', () => {
const tab = findTabs().at(1);
expect(tab.attributes('title')).toBe('Regulated');
expect(tab.attributes('disabled')).toBe('true');
});
it('shows the list items with expect props', () => {
expect(findListItems()).toHaveLength(2);
findListItems().wrappers.forEach(item =>
expect(item.props()).toEqual(
expect.objectContaining({
framework: {
id: expect.stringContaining('gid://gitlab/ComplianceManagement::Framework/'),
parsedId: expect.any(Number),
name: expect.any(String),
description: expect.any(String),
color: expect.stringMatching(/^#([0-9A-F]{3}){1,2}$/i),
},
}),
),
);
});
});
});
export const validFetchResponse = {
data: {
namespace: {
id: 'gid://gitlab/Group/1',
name: 'Group 1',
complianceFrameworks: {
nodes: [
{
id: 'gid://gitlab/ComplianceManagement::Framework/1',
name: 'GDPR',
description: 'General Data Protection Regulation',
color: '#1aaa55',
__typename: 'ComplianceFramework',
},
{
id: 'gid://gitlab/ComplianceManagement::Framework/2',
name: 'PCI-DSS',
description: 'Payment Card Industry-Data Security Standard',
color: '#6666c4',
__typename: 'ComplianceFramework',
},
],
__typename: 'ComplianceFrameworkConnection',
},
__typename: 'Namespace',
},
},
};
export const emptyFetchResponse = {
data: {
namespace: {
id: 'gid://group-1/Group/1',
name: 'Group 1',
complianceFrameworks: {
nodes: [],
__typename: 'ComplianceFrameworkConnection',
},
__typename: 'Namespace',
},
},
};
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe ComplianceManagement::ComplianceFramework::GroupSettingsHelper do
let_it_be(:group) { build(:group) }
before do
assign(:group, group)
end
describe '#show_compliance_frameworks?' do
using RSpec::Parameterized::TableSyntax
where(:feature_flag_enabled, :license_feature_enabled, :result) do
true | true | true
false | true | false
true | false | false
false | false | false
end
with_them do
before do
stub_feature_flags(ff_custom_compliance_frameworks: feature_flag_enabled)
stub_licensed_features(custom_compliance_frameworks: license_feature_enabled)
end
it 'returns the correct value' do
expect(helper.show_compliance_frameworks?).to eql(result)
end
end
end
describe '#compliance_frameworks_list_data' do
it 'returns the correct data' do
expect(helper.compliance_frameworks_list_data).to contain_exactly(
[:empty_state_svg_path, ActionController::Base.helpers.image_path('illustrations/welcome/ee_trial.svg')],
[:group_path, group.full_path]
)
end
end
end
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe 'groups/_compliance_frameworks.html.haml' do
let_it_be(:group) { build(:group) }
let(:title) { 'Compliance frameworks' }
let(:description) { 'Configure frameworks to apply enforceable rules to projects.' }
before do
assign(:group, group)
end
context 'when the compliance frameworks should show' do
before do
allow(view).to receive(:show_compliance_frameworks?).and_return(true)
end
it 'shows the compliance frameworks list', :aggregate_failures do
render
expect(rendered).to have_content(title)
expect(rendered).to have_content(description)
end
end
context 'when the compliance frameworks should not show' do
before do
allow(view).to receive(:show_compliance_frameworks?).and_return(false)
end
it 'hides the compliance frameworks list', :aggregate_failures do
render
expect(rendered).not_to have_content(title)
expect(rendered).not_to have_content(description)
end
end
end
...@@ -7131,6 +7131,24 @@ msgstr "" ...@@ -7131,6 +7131,24 @@ msgstr ""
msgid "ComplianceDashboard|created by:" msgid "ComplianceDashboard|created by:"
msgstr "" msgstr ""
msgid "ComplianceFrameworks|Add framework"
msgstr ""
msgid "ComplianceFrameworks|All"
msgstr ""
msgid "ComplianceFrameworks|Error fetching compliance frameworks data. Please refresh the page"
msgstr ""
msgid "ComplianceFrameworks|Once you have created a compliance framework it will appear here."
msgstr ""
msgid "ComplianceFrameworks|Regulated"
msgstr ""
msgid "ComplianceFrameworks|There are no compliance frameworks set up yet"
msgstr ""
msgid "ComplianceFramework|GDPR" msgid "ComplianceFramework|GDPR"
msgstr "" msgstr ""
...@@ -13676,6 +13694,12 @@ msgstr "" ...@@ -13676,6 +13694,12 @@ msgstr ""
msgid "GroupSettings|Changing group URL can have unintended side effects." msgid "GroupSettings|Changing group URL can have unintended side effects."
msgstr "" msgstr ""
msgid "GroupSettings|Compliance frameworks"
msgstr ""
msgid "GroupSettings|Configure frameworks to apply enforceable rules to projects."
msgstr ""
msgid "GroupSettings|Custom project templates" msgid "GroupSettings|Custom project templates"
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