Commit c4b69640 authored by Angelo Gulina's avatar Angelo Gulina Committed by Nicolò Maria Mezzopera

Add confirmation message when regenerating feature flags instance ID

- add field to verify user's intention to regenerate FF instance ID
- move regenerate ID button to the bottom and disable by default
- update and add relevant Vue test for the modal
parent 1c8ff093
<script>
import {
GlFormGroup,
GlFormInput,
GlModal,
GlDeprecatedButton,
GlTooltipDirective,
GlLoadingIcon,
GlSprintf,
......@@ -13,20 +14,21 @@ import Icon from '~/vue_shared/components/icon.vue';
import Callout from '~/vue_shared/components/callout.vue';
export default {
cancelActionLabel: __('Close'),
modalTitle: s__('FeatureFlags|Configure feature flags'),
apiUrlLabelText: s__('FeatureFlags|API URL'),
apiUrlCopyText: __('Copy URL'),
instanceIdLabelText: s__('FeatureFlags|Instance ID'),
instanceIdCopyText: __('Copy ID'),
regenerateInstanceIdTooltip: __('Regenerate instance ID'),
instanceIdRegenerateError: __('Unable to generate new instance ID'),
instanceIdRegenerateText: __(
'Regenerating the instance ID can break integration depending on the client you are using.',
),
instanceIdRegenerateActionLabel: __('Regenerate instance ID'),
components: {
GlFormGroup,
GlFormInput,
GlModal,
GlDeprecatedButton,
ModalCopyButton,
Icon,
Callout,
......@@ -78,8 +80,36 @@ export default {
required: true,
},
},
inject: ['projectName'],
data() {
return {
enteredProjectName: '',
};
},
computed: {
cancelActionProps() {
return {
text: this.$options.cancelActionLabel,
};
},
canRegenerateInstanceId() {
return this.canUserRotateToken && this.enteredProjectName === this.projectName;
},
regenerateInstanceIdActionProps() {
return this.canUserRotateToken
? {
text: this.$options.instanceIdRegenerateActionLabel,
attributes: [
{
category: 'secondary',
disabled: !this.canRegenerateInstanceId,
loading: this.isRotating,
variant: 'danger',
},
],
}
: null;
},
helpText() {
return sprintf(
s__(
......@@ -97,18 +127,42 @@ export default {
},
methods: {
clearState() {
this.enteredProjectName = '';
},
rotateToken() {
this.$emit('token');
this.clearState();
},
},
};
</script>
<template>
<gl-modal :modal-id="modalId" :hide-footer="true">
<gl-modal
:modal-id="modalId"
:action-cancel="cancelActionProps"
:action-primary="regenerateInstanceIdActionProps"
@canceled="clearState"
@hide="clearState"
@primary.prevent="rotateToken"
>
<template #modal-title>
{{ $options.modalTitle }}
</template>
<p v-html="helpText"></p>
<callout category="warning">
<gl-sprintf
:message="
s__(
'FeatureFlags|Set the Unleash client application name to the name of the environment your application runs in. This value is used to match environment scopes. See the %{linkStart}example client configuration%{linkEnd}.',
)
"
>
<template #link="{ content }">
<gl-link :href="helpClientExamplePath" target="_blank">{{ content }}</gl-link>
</template>
</gl-sprintf>
</callout>
<div class="form-group">
<label for="api_url" class="label-bold">{{ $options.apiUrlLabelText }}</label>
<div class="input-group">
......@@ -149,15 +203,6 @@ export default {
/>
<div class="input-group-append">
<gl-deprecated-button
v-if="canUserRotateToken"
v-gl-tooltip.hover
:title="$options.regenerateInstanceIdTooltip"
class="input-group-text"
@click="rotateToken"
>
<icon name="retry" />
</gl-deprecated-button>
<modal-copy-button
:text="instanceId"
:title="$options.instanceIdCopyText"
......@@ -177,21 +222,31 @@ export default {
</div>
<callout
v-if="canUserRotateToken"
category="info"
category="danger"
:message="$options.instanceIdRegenerateText"
/>
<callout category="warning">
<p v-if="canUserRotateToken" data-testid="prevent-accident-text">
<gl-sprintf
:message="
s__(
'FeatureFlags|Set the Unleash client application name to the name of the environment your application runs in. This value is used to match environment scopes. See the %{linkStart}example client configuration%{linkEnd}.',
'FeatureFlags|To prevent accidental actions we ask you to confirm your intention. Please type %{projectName} to proceed or close this modal to cancel.',
)
"
>
<template #link="{ content }">
<gl-link :href="helpClientExamplePath" target="_blank">{{ content }}</gl-link>
<template #projectName>
<span class="gl-font-weight-bold gl-text-red-500">{{ projectName }}</span>
</template>
</gl-sprintf>
</callout>
</p>
<gl-form-group>
<gl-form-input
v-if="canUserRotateToken"
id="project_name_verification"
v-model="enteredProjectName"
name="project_name"
type="text"
:disabled="isRotating"
/>
</gl-form-group>
</gl-modal>
</template>
......@@ -13,6 +13,11 @@ export default () =>
dataset: document.querySelector(this.$options.el).dataset,
};
},
provide() {
return {
projectName: this.dataset.projectName,
};
},
render(createElement) {
return createElement('feature-flags-component', {
props: {
......
......@@ -27,5 +27,5 @@ $label-blue: #428bca;
}
.instance-id-loading-icon {
right: 84px;
right: 52px;
}
......@@ -2,6 +2,7 @@
#feature-flags-vue{ data: { endpoint: project_feature_flags_path(@project, format: :json),
"project-id" => @project.id,
"project-name" => @project.name,
"error-state-svg-path" => image_path('illustrations/feature_flag.svg'),
"feature-flags-help-page-path" => help_page_path("operations/feature_flags"),
"feature-flags-client-libraries-help-page-path" => help_page_path("operations/feature_flags", anchor: "choose-a-client-library"),
......
---
title: Add confirmation message when regenerating feature flags instance ID
merge_request: 38497
author:
type: changed
import { shallowMount } from '@vue/test-utils';
import { GlDeprecatedButton } from '@gitlab/ui';
import { GlModal, GlSprintf } from '@gitlab/ui';
import Component from 'ee/feature_flags/components/configure_feature_flags_modal.vue';
import Callout from '~/vue_shared/components/callout.vue';
describe('Configure Feature Flags Modal', () => {
const mockEvent = { preventDefault: jest.fn() };
const projectName = 'fakeProjectName';
let wrapper;
let propsData;
const factory = (props = {}, { mountFn = shallowMount, ...options } = {}) => {
wrapper = mountFn(Component, {
provide: {
projectName,
},
stubs: { GlSprintf },
propsData: {
helpPath: '/help/path',
helpClientLibrariesPath: '/help/path/#flags',
helpClientExamplePath: '/feature-flags#clientexample',
apiUrl: '/api/url',
instanceId: 'instance-id-token',
isRotating: false,
hasRotateError: false,
canUserRotateToken: true,
...props,
},
...options,
});
};
afterEach(() => wrapper.destroy());
const findGlModal = () => wrapper.find(GlModal);
const findPrimaryAction = () => findGlModal().props('actionPrimary');
const findProjectNameInput = () => wrapper.find('#project_name_verification');
const findDangerCallout = () =>
wrapper.findAll(Callout).filter(c => c.props('category') === 'danger');
beforeEach(() => {
propsData = {
helpPath: '/help/path',
helpClientLibrariesPath: '/help/path/#flags',
helpClientExamplePath: '/feature-flags#clientexample',
apiUrl: '/api/url',
instanceId: 'instance-id-token',
isRotating: false,
hasRotateError: false,
canUserRotateToken: true,
};
describe('idle', () => {
afterEach(() => wrapper.destroy());
beforeEach(factory);
wrapper = shallowMount(Component, {
propsData,
it('should have Primary and Cancel actions', () => {
expect(findGlModal().props('actionCancel').text).toBe('Close');
expect(findPrimaryAction().text).toBe('Regenerate instance ID');
});
});
describe('rotate token', () => {
it('should emit a `token` event on click', () => {
wrapper.find(GlDeprecatedButton).vm.$emit('click');
return wrapper.vm.$nextTick().then(() => {
expect(wrapper.emitted('token')).toEqual([[]]);
});
it('should default disable the primary action', async () => {
const [{ disabled }] = findPrimaryAction().attributes;
expect(disabled).toBe(true);
});
it('should display an error if there is a rotate error', () => {
wrapper.setProps({ hasRotateError: true });
return wrapper.vm.$nextTick().then(() => {
expect(wrapper.find('.text-danger')).toExist();
expect(wrapper.find('[name="warning"]')).toExist();
});
it('should emit a `token` event when clicking on the Primary action', async () => {
findGlModal().vm.$emit('primary', mockEvent);
await wrapper.vm.$nextTick();
expect(wrapper.emitted('token')).toEqual([[]]);
expect(mockEvent.preventDefault).toHaveBeenCalled();
});
it('should be hidden if the user cannot rotate tokens', () => {
wrapper.setProps({ canUserRotateToken: false });
return wrapper.vm.$nextTick(() => {
expect(wrapper.find('.js-ff-rotate-token-button').exists()).toBe(false);
});
it('should clear the project name input after generating the token', async () => {
findProjectNameInput().vm.$emit('input', projectName);
findGlModal().vm.$emit('primary', mockEvent);
await wrapper.vm.$nextTick();
expect(findProjectNameInput().attributes('value')).toBe('');
});
});
describe('instance id', () => {
it('should be displayed in an input box', () => {
const input = wrapper.find('#instance_id');
expect(input.element.value).toBe('instance-id-token');
it('should provide an input for filling the project name', () => {
expect(findProjectNameInput().exists()).toBe(true);
expect(findProjectNameInput().attributes('value')).toBe('');
});
});
describe('api url', () => {
it('should be displayed in an input box', () => {
const input = wrapper.find('#api_url');
expect(input.element.value).toBe('/api/url');
});
});
describe('help text', () => {
it('should be displayed', () => {
it('should display an help text', () => {
const help = wrapper.find('p');
expect(help.text()).toMatch(/More Information/);
});
......@@ -74,5 +80,73 @@ describe('Configure Feature Flags Modal', () => {
const anchoredLink = help.find('a[href="/help/path/#flags"]');
expect(anchoredLink.exists()).toBe(true);
});
it('should display one and only one danger callout', () => {
const dangerCallout = findDangerCallout();
expect(dangerCallout.length).toBe(1);
expect(dangerCallout.at(0).props('message')).toMatch(/Regenerating the instance ID/);
});
it('should display a message asking to fill the project name', () => {
expect(wrapper.find('[data-testid="prevent-accident-text"]').text()).toMatch(projectName);
});
it('should display the api URL in an input box', () => {
const input = wrapper.find('#api_url');
expect(input.element.value).toBe('/api/url');
});
it('should display the instance ID in an input box', () => {
const input = wrapper.find('#instance_id');
expect(input.element.value).toBe('instance-id-token');
});
});
describe('verified', () => {
afterEach(() => wrapper.destroy());
beforeEach(factory);
it('should enable the primary action', async () => {
findProjectNameInput().vm.$emit('input', projectName);
await wrapper.vm.$nextTick();
const [{ disabled }] = findPrimaryAction().attributes;
expect(disabled).toBe(false);
});
});
describe('cannot rotate token', () => {
afterEach(() => wrapper.destroy());
beforeEach(factory.bind(null, { canUserRotateToken: false }));
it('should not display the primary action', async () => {
expect(findPrimaryAction()).toBe(null);
});
it('shold not display regenerating instance ID', async () => {
expect(findDangerCallout().exists()).toBe(false);
});
it('should disable the project name input', async () => {
expect(findProjectNameInput().exists()).toBe(false);
});
});
describe('has rotate error', () => {
afterEach(() => wrapper.destroy());
beforeEach(factory.bind(null, { hasRotateError: false }));
it('should display an error', async () => {
expect(wrapper.find('.text-danger')).toExist();
expect(wrapper.find('[name="warning"]')).toExist();
});
});
describe('is rotating', () => {
afterEach(() => wrapper.destroy());
beforeEach(factory.bind(null, { isRotating: true }));
it('should disable the project name input', async () => {
expect(findProjectNameInput().attributes('disabled')).toBeTruthy();
});
});
});
......@@ -37,6 +37,9 @@ describe('Feature flags', () => {
const factory = (propsData = mockData, fn = shallowMount) => {
wrapper = fn(FeatureFlagsComponent, {
propsData,
provide: {
projectName: 'fakeProjectName',
},
});
};
......
......@@ -10468,6 +10468,9 @@ msgstr ""
msgid "FeatureFlags|There was an error retrieving user lists"
msgstr ""
msgid "FeatureFlags|To prevent accidental actions we ask you to confirm your intention. Please type %{projectName} to proceed or close this modal to cancel."
msgstr ""
msgid "FeatureFlags|Try again in a few moments or contact your support team."
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