Commit e8a11380 authored by Frédéric Caplette's avatar Frédéric Caplette

Merge branch '328385-create-card-with-variants-security-configuration' into 'master'

Add FeatureCard component

See merge request gitlab-org/gitlab!61919
parents 0fe97b2a 6928329a
<script>
import { GlButton, GlCard, GlIcon, GlLink } from '@gitlab/ui';
import { __, s__, sprintf } from '~/locale';
import ManageViaMr from '~/vue_shared/security_configuration/components/manage_via_mr.vue';
export default {
components: {
GlButton,
GlCard,
GlIcon,
GlLink,
ManageViaMr,
},
props: {
feature: {
type: Object,
required: true,
},
},
computed: {
available() {
return this.feature.available;
},
enabled() {
return this.available && this.feature.configured;
},
hasStatus() {
return !this.available || typeof this.feature.configured === 'boolean';
},
shortName() {
return this.feature.shortName ?? this.feature.name;
},
configurationButton() {
const button = this.enabled
? {
text: this.$options.i18n.configureFeature,
category: 'secondary',
}
: {
text: this.$options.i18n.enableFeature,
category: 'primary',
};
button.text = sprintf(button.text, { feature: this.shortName });
return button;
},
showManageViaMr() {
const { available, configured, canEnableByMergeRequest } = this.feature;
return canEnableByMergeRequest && available && !configured;
},
cardClasses() {
return { 'gl-bg-gray-10': !this.available };
},
statusClasses() {
const { enabled } = this;
return {
'gl-ml-auto': true,
'gl-flex-shrink-0': true,
'gl-text-gray-500': !enabled,
'gl-text-green-500': enabled,
};
},
hasSecondary() {
const { name, description, configurationText } = this.feature.secondary ?? {};
return Boolean(name && description && configurationText);
},
},
i18n: {
enabled: s__('SecurityConfiguration|Enabled'),
notEnabled: s__('SecurityConfiguration|Not enabled'),
availableWith: s__('SecurityConfiguration|Available with Ultimate'),
configurationGuide: s__('SecurityConfiguration|Configuration guide'),
configureFeature: s__('SecurityConfiguration|Configure %{feature}'),
enableFeature: s__('SecurityConfiguration|Enable %{feature}'),
learnMore: __('Learn more'),
},
};
</script>
<template>
<gl-card :class="cardClasses">
<div class="gl-display-flex gl-align-items-baseline">
<h3 class="gl-font-lg gl-m-0 gl-mr-3">{{ feature.name }}</h3>
<div :class="statusClasses" data-testid="feature-status">
<template v-if="hasStatus">
<template v-if="enabled">
<gl-icon name="check-circle-filled" />
<span class="gl-text-green-700">{{ $options.i18n.enabled }}</span>
</template>
<template v-else-if="available">
{{ $options.i18n.notEnabled }}
</template>
<template v-else>
{{ $options.i18n.availableWith }}
</template>
</template>
</div>
</div>
<p class="gl-mb-0 gl-mt-5">
{{ feature.description }}
<gl-link :href="feature.helpPath">{{ $options.i18n.learnMore }}</gl-link>
</p>
<template v-if="available">
<gl-button
v-if="feature.configurationPath"
:href="feature.configurationPath"
variant="confirm"
:category="configurationButton.category"
class="gl-mt-5"
>
{{ configurationButton.text }}
</gl-button>
<manage-via-mr
v-else-if="showManageViaMr"
:feature="feature"
variant="confirm"
category="primary"
class="gl-mt-5"
/>
<gl-button v-else icon="external-link" :href="feature.configurationHelpPath" class="gl-mt-5">
{{ $options.i18n.configurationGuide }}
</gl-button>
</template>
<div v-if="hasSecondary" data-testid="secondary-feature">
<h4 class="gl-font-base gl-m-0 gl-mt-6">{{ feature.secondary.name }}</h4>
<p class="gl-mb-0 gl-mt-5">{{ feature.secondary.description }}</p>
<gl-button
v-if="available && feature.secondary.configurationPath"
:href="feature.secondary.configurationPath"
variant="confirm"
category="secondary"
class="gl-mt-5"
>
{{ feature.secondary.configurationText }}
</gl-button>
</div>
</gl-card>
</template>
......@@ -28827,12 +28827,21 @@ msgstr ""
msgid "SecurityConfiguration|Available with %{linkStart}upgrade or free trial%{linkEnd}"
msgstr ""
msgid "SecurityConfiguration|Available with Ultimate"
msgstr ""
msgid "SecurityConfiguration|By default, all analyzers are applied in order to cover all languages across your project, and only run if the language is detected in the Merge Request."
msgstr ""
msgid "SecurityConfiguration|Configuration guide"
msgstr ""
msgid "SecurityConfiguration|Configure"
msgstr ""
msgid "SecurityConfiguration|Configure %{feature}"
msgstr ""
msgid "SecurityConfiguration|Configure via Merge Request"
msgstr ""
......@@ -28848,6 +28857,9 @@ msgstr ""
msgid "SecurityConfiguration|Enable"
msgstr ""
msgid "SecurityConfiguration|Enable %{feature}"
msgstr ""
msgid "SecurityConfiguration|Enabled"
msgstr ""
......
import { GlIcon } from '@gitlab/ui';
import { mount } from '@vue/test-utils';
import { extendedWrapper } from 'helpers/vue_test_utils_helper';
import FeatureCard from '~/security_configuration/components/feature_card.vue';
import ManageViaMr from '~/vue_shared/security_configuration/components/manage_via_mr.vue';
import { makeFeature } from './utils';
describe('FeatureCard component', () => {
let feature;
let wrapper;
const createComponent = (propsData) => {
wrapper = extendedWrapper(
mount(FeatureCard, {
propsData,
stubs: {
ManageViaMr: true,
},
}),
);
};
const findLinks = ({ text, href }) =>
wrapper.findAll(`a[href="${href}"]`).filter((link) => link.text() === text);
const findEnableLinks = () =>
findLinks({
text: `Enable ${feature.shortName ?? feature.name}`,
href: feature.configurationPath,
});
const findConfigureLinks = () =>
findLinks({
text: `Configure ${feature.shortName ?? feature.name}`,
href: feature.configurationPath,
});
const findManageViaMr = () => wrapper.findComponent(ManageViaMr);
const findConfigGuideLinks = () =>
findLinks({ text: 'Configuration guide', href: feature.configurationHelpPath });
const findSecondarySection = () => wrapper.findByTestId('secondary-feature');
const expectAction = (action) => {
const expectEnableAction = action === 'enable';
const expectConfigureAction = action === 'configure';
const expectCreateMrAction = action === 'create-mr';
const expectGuideAction = action === 'guide';
const enableLinks = findEnableLinks();
expect(enableLinks.exists()).toBe(expectEnableAction);
if (expectEnableAction) {
expect(enableLinks).toHaveLength(1);
expect(enableLinks.at(0).props('category')).toBe('primary');
}
const configureLinks = findConfigureLinks();
expect(configureLinks.exists()).toBe(expectConfigureAction);
if (expectConfigureAction) {
expect(configureLinks).toHaveLength(1);
expect(configureLinks.at(0).props('category')).toBe('secondary');
}
const manageViaMr = findManageViaMr();
expect(manageViaMr.exists()).toBe(expectCreateMrAction);
if (expectCreateMrAction) {
expect(manageViaMr.props('feature')).toBe(feature);
}
const configGuideLinks = findConfigGuideLinks();
expect(configGuideLinks.exists()).toBe(expectGuideAction);
if (expectGuideAction) {
expect(configGuideLinks).toHaveLength(1);
}
};
afterEach(() => {
wrapper.destroy();
feature = undefined;
});
describe('basic structure', () => {
beforeEach(() => {
feature = makeFeature();
createComponent({ feature });
});
it('shows the name', () => {
expect(wrapper.text()).toContain(feature.name);
});
it('shows the description', () => {
expect(wrapper.text()).toContain(feature.description);
});
it('shows the help link', () => {
const links = findLinks({ text: 'Learn more', href: feature.helpPath });
expect(links.exists()).toBe(true);
expect(links).toHaveLength(1);
});
});
describe('status', () => {
describe.each`
context | available | configured | expectedStatus
${'a configured feature'} | ${true} | ${true} | ${'Enabled'}
${'an unconfigured feature'} | ${true} | ${false} | ${'Not enabled'}
${'an available feature with unknown status'} | ${true} | ${undefined} | ${''}
${'an unavailable feature'} | ${false} | ${false} | ${'Available with Ultimate'}
${'an unavailable feature with unknown status'} | ${false} | ${undefined} | ${'Available with Ultimate'}
`('given $context', ({ available, configured, expectedStatus }) => {
beforeEach(() => {
feature = makeFeature({ available, configured });
createComponent({ feature });
});
it(`shows the status "${expectedStatus}"`, () => {
expect(wrapper.findByTestId('feature-status').text()).toBe(expectedStatus);
});
if (configured) {
it('shows a success icon', () => {
expect(wrapper.findComponent(GlIcon).props('name')).toBe('check-circle-filled');
});
}
});
});
describe('actions', () => {
describe.each`
context | available | configured | configurationPath | canEnableByMergeRequest | action
${'unavailable'} | ${false} | ${false} | ${null} | ${false} | ${null}
${'available'} | ${true} | ${false} | ${null} | ${false} | ${'guide'}
${'configured'} | ${true} | ${true} | ${null} | ${false} | ${'guide'}
${'available, can enable by MR'} | ${true} | ${false} | ${null} | ${true} | ${'create-mr'}
${'configured, can enable by MR'} | ${true} | ${true} | ${null} | ${true} | ${'guide'}
${'available with config path'} | ${true} | ${false} | ${'foo'} | ${false} | ${'enable'}
${'available with config path, can enable by MR'} | ${true} | ${false} | ${'foo'} | ${true} | ${'enable'}
${'configured with config path'} | ${true} | ${true} | ${'foo'} | ${false} | ${'configure'}
${'configured with config path, can enable by MR'} | ${true} | ${true} | ${'foo'} | ${true} | ${'configure'}
`(
'given $context feature',
({ available, configured, configurationPath, canEnableByMergeRequest, action }) => {
beforeEach(() => {
feature = makeFeature({
available,
configured,
configurationPath,
canEnableByMergeRequest,
});
createComponent({ feature });
});
it(`shows ${action} action`, () => {
expectAction(action);
});
},
);
});
describe('secondary feature', () => {
describe('basic structure', () => {
describe('given no secondary', () => {
beforeEach(() => {
feature = makeFeature();
createComponent({ feature });
});
it('does not show a secondary feature', () => {
expect(findSecondarySection().exists()).toBe(false);
});
});
describe('given a secondary', () => {
beforeEach(() => {
feature = makeFeature({
secondary: {
name: 'secondary name',
description: 'secondary description',
configurationText: 'manage secondary',
},
});
createComponent({ feature });
});
it('shows a secondary feature', () => {
const secondaryText = findSecondarySection().text();
expect(secondaryText).toContain(feature.secondary.name);
expect(secondaryText).toContain(feature.secondary.description);
});
});
});
describe('actions', () => {
describe('given available feature with secondary', () => {
beforeEach(() => {
feature = makeFeature({
available: true,
secondary: {
name: 'secondary name',
description: 'secondary description',
configurationPath: '/secondary',
configurationText: 'manage secondary',
},
});
createComponent({ feature });
});
it('shows the secondary action', () => {
const links = findLinks({
text: feature.secondary.configurationText,
href: feature.secondary.configurationPath,
});
expect(links.exists()).toBe(true);
expect(links).toHaveLength(1);
});
});
describe.each`
context | available | secondaryConfigPath
${'available feature without config path'} | ${true} | ${null}
${'unavailable feature with config path'} | ${false} | ${'/secondary'}
`('given $context', ({ available, secondaryConfigPath }) => {
beforeEach(() => {
feature = makeFeature({
available,
secondary: {
name: 'secondary name',
description: 'secondary description',
configurationPath: secondaryConfigPath,
configurationText: 'manage secondary',
},
});
createComponent({ feature });
});
it('does not show the secondary action', () => {
const links = findLinks({
text: feature.secondary.configurationText,
href: feature.secondary.configurationPath,
});
expect(links.exists()).toBe(false);
});
});
});
});
});
export const makeFeature = (changes = {}) => ({
name: 'Foo Feature',
description: 'Lorem ipsum Foo',
type: 'foo_scanning',
helpPath: '/help/foo',
configurationHelpPath: '/help/foo#configure',
...changes,
});
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