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 "" ...@@ -28827,12 +28827,21 @@ msgstr ""
msgid "SecurityConfiguration|Available with %{linkStart}upgrade or free trial%{linkEnd}" msgid "SecurityConfiguration|Available with %{linkStart}upgrade or free trial%{linkEnd}"
msgstr "" 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." 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 "" msgstr ""
msgid "SecurityConfiguration|Configuration guide"
msgstr ""
msgid "SecurityConfiguration|Configure" msgid "SecurityConfiguration|Configure"
msgstr "" msgstr ""
msgid "SecurityConfiguration|Configure %{feature}"
msgstr ""
msgid "SecurityConfiguration|Configure via Merge Request" msgid "SecurityConfiguration|Configure via Merge Request"
msgstr "" msgstr ""
...@@ -28848,6 +28857,9 @@ msgstr "" ...@@ -28848,6 +28857,9 @@ msgstr ""
msgid "SecurityConfiguration|Enable" msgid "SecurityConfiguration|Enable"
msgstr "" msgstr ""
msgid "SecurityConfiguration|Enable %{feature}"
msgstr ""
msgid "SecurityConfiguration|Enabled" msgid "SecurityConfiguration|Enabled"
msgstr "" 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