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> <script>
import { import {
GlFormGroup,
GlFormInput,
GlModal, GlModal,
GlDeprecatedButton,
GlTooltipDirective, GlTooltipDirective,
GlLoadingIcon, GlLoadingIcon,
GlSprintf, GlSprintf,
...@@ -13,20 +14,21 @@ import Icon from '~/vue_shared/components/icon.vue'; ...@@ -13,20 +14,21 @@ import Icon from '~/vue_shared/components/icon.vue';
import Callout from '~/vue_shared/components/callout.vue'; import Callout from '~/vue_shared/components/callout.vue';
export default { export default {
cancelActionLabel: __('Close'),
modalTitle: s__('FeatureFlags|Configure feature flags'), modalTitle: s__('FeatureFlags|Configure feature flags'),
apiUrlLabelText: s__('FeatureFlags|API URL'), apiUrlLabelText: s__('FeatureFlags|API URL'),
apiUrlCopyText: __('Copy URL'), apiUrlCopyText: __('Copy URL'),
instanceIdLabelText: s__('FeatureFlags|Instance ID'), instanceIdLabelText: s__('FeatureFlags|Instance ID'),
instanceIdCopyText: __('Copy ID'), instanceIdCopyText: __('Copy ID'),
regenerateInstanceIdTooltip: __('Regenerate instance ID'),
instanceIdRegenerateError: __('Unable to generate new instance ID'), instanceIdRegenerateError: __('Unable to generate new instance ID'),
instanceIdRegenerateText: __( instanceIdRegenerateText: __(
'Regenerating the instance ID can break integration depending on the client you are using.', 'Regenerating the instance ID can break integration depending on the client you are using.',
), ),
instanceIdRegenerateActionLabel: __('Regenerate instance ID'),
components: { components: {
GlFormGroup,
GlFormInput,
GlModal, GlModal,
GlDeprecatedButton,
ModalCopyButton, ModalCopyButton,
Icon, Icon,
Callout, Callout,
...@@ -78,8 +80,36 @@ export default { ...@@ -78,8 +80,36 @@ export default {
required: true, required: true,
}, },
}, },
inject: ['projectName'],
data() {
return {
enteredProjectName: '',
};
},
computed: { 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() { helpText() {
return sprintf( return sprintf(
s__( s__(
...@@ -97,18 +127,42 @@ export default { ...@@ -97,18 +127,42 @@ export default {
}, },
methods: { methods: {
clearState() {
this.enteredProjectName = '';
},
rotateToken() { rotateToken() {
this.$emit('token'); this.$emit('token');
this.clearState();
}, },
}, },
}; };
</script> </script>
<template> <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> <template #modal-title>
{{ $options.modalTitle }} {{ $options.modalTitle }}
</template> </template>
<p v-html="helpText"></p> <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"> <div class="form-group">
<label for="api_url" class="label-bold">{{ $options.apiUrlLabelText }}</label> <label for="api_url" class="label-bold">{{ $options.apiUrlLabelText }}</label>
<div class="input-group"> <div class="input-group">
...@@ -149,15 +203,6 @@ export default { ...@@ -149,15 +203,6 @@ export default {
/> />
<div class="input-group-append"> <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 <modal-copy-button
:text="instanceId" :text="instanceId"
:title="$options.instanceIdCopyText" :title="$options.instanceIdCopyText"
...@@ -177,21 +222,31 @@ export default { ...@@ -177,21 +222,31 @@ export default {
</div> </div>
<callout <callout
v-if="canUserRotateToken" v-if="canUserRotateToken"
category="info" category="danger"
:message="$options.instanceIdRegenerateText" :message="$options.instanceIdRegenerateText"
/> />
<callout category="warning"> <p v-if="canUserRotateToken" data-testid="prevent-accident-text">
<gl-sprintf <gl-sprintf
:message=" :message="
s__( 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 }"> <template #projectName>
<gl-link :href="helpClientExamplePath" target="_blank">{{ content }}</gl-link> <span class="gl-font-weight-bold gl-text-red-500">{{ projectName }}</span>
</template> </template>
</gl-sprintf> </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> </gl-modal>
</template> </template>
...@@ -13,6 +13,11 @@ export default () => ...@@ -13,6 +13,11 @@ export default () =>
dataset: document.querySelector(this.$options.el).dataset, dataset: document.querySelector(this.$options.el).dataset,
}; };
}, },
provide() {
return {
projectName: this.dataset.projectName,
};
},
render(createElement) { render(createElement) {
return createElement('feature-flags-component', { return createElement('feature-flags-component', {
props: { props: {
......
...@@ -27,5 +27,5 @@ $label-blue: #428bca; ...@@ -27,5 +27,5 @@ $label-blue: #428bca;
} }
.instance-id-loading-icon { .instance-id-loading-icon {
right: 84px; right: 52px;
} }
...@@ -2,6 +2,7 @@ ...@@ -2,6 +2,7 @@
#feature-flags-vue{ data: { endpoint: project_feature_flags_path(@project, format: :json), #feature-flags-vue{ data: { endpoint: project_feature_flags_path(@project, format: :json),
"project-id" => @project.id, "project-id" => @project.id,
"project-name" => @project.name,
"error-state-svg-path" => image_path('illustrations/feature_flag.svg'), "error-state-svg-path" => image_path('illustrations/feature_flag.svg'),
"feature-flags-help-page-path" => help_page_path("operations/feature_flags"), "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"), "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 { 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 Component from 'ee/feature_flags/components/configure_feature_flags_modal.vue';
import Callout from '~/vue_shared/components/callout.vue';
describe('Configure Feature Flags Modal', () => { describe('Configure Feature Flags Modal', () => {
let wrapper; const mockEvent = { preventDefault: jest.fn() };
let propsData; const projectName = 'fakeProjectName';
afterEach(() => wrapper.destroy());
beforeEach(() => { let wrapper;
propsData = { const factory = (props = {}, { mountFn = shallowMount, ...options } = {}) => {
wrapper = mountFn(Component, {
provide: {
projectName,
},
stubs: { GlSprintf },
propsData: {
helpPath: '/help/path', helpPath: '/help/path',
helpClientLibrariesPath: '/help/path/#flags', helpClientLibrariesPath: '/help/path/#flags',
helpClientExamplePath: '/feature-flags#clientexample', helpClientExamplePath: '/feature-flags#clientexample',
...@@ -18,61 +23,130 @@ describe('Configure Feature Flags Modal', () => { ...@@ -18,61 +23,130 @@ describe('Configure Feature Flags Modal', () => {
isRotating: false, isRotating: false,
hasRotateError: false, hasRotateError: false,
canUserRotateToken: true, canUserRotateToken: true,
...props,
},
...options,
});
}; };
wrapper = shallowMount(Component, { const findGlModal = () => wrapper.find(GlModal);
propsData, const findPrimaryAction = () => findGlModal().props('actionPrimary');
const findProjectNameInput = () => wrapper.find('#project_name_verification');
const findDangerCallout = () =>
wrapper.findAll(Callout).filter(c => c.props('category') === 'danger');
describe('idle', () => {
afterEach(() => wrapper.destroy());
beforeEach(factory);
it('should have Primary and Cancel actions', () => {
expect(findGlModal().props('actionCancel').text).toBe('Close');
expect(findPrimaryAction().text).toBe('Regenerate instance ID');
}); });
it('should default disable the primary action', async () => {
const [{ disabled }] = findPrimaryAction().attributes;
expect(disabled).toBe(true);
}); });
describe('rotate token', () => { it('should emit a `token` event when clicking on the Primary action', async () => {
it('should emit a `token` event on click', () => { findGlModal().vm.$emit('primary', mockEvent);
wrapper.find(GlDeprecatedButton).vm.$emit('click'); await wrapper.vm.$nextTick();
return wrapper.vm.$nextTick().then(() => {
expect(wrapper.emitted('token')).toEqual([[]]); expect(wrapper.emitted('token')).toEqual([[]]);
expect(mockEvent.preventDefault).toHaveBeenCalled();
}); });
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('');
}); });
it('should display an error if there is a rotate error', () => { it('should provide an input for filling the project name', () => {
wrapper.setProps({ hasRotateError: true }); expect(findProjectNameInput().exists()).toBe(true);
return wrapper.vm.$nextTick().then(() => { expect(findProjectNameInput().attributes('value')).toBe('');
expect(wrapper.find('.text-danger')).toExist();
expect(wrapper.find('[name="warning"]')).toExist();
}); });
it('should display an help text', () => {
const help = wrapper.find('p');
expect(help.text()).toMatch(/More Information/);
}); });
it('should be hidden if the user cannot rotate tokens', () => { it('should have links to the documentation', () => {
wrapper.setProps({ canUserRotateToken: false }); const help = wrapper.find('p');
return wrapper.vm.$nextTick(() => { const link = help.find('a[href="/help/path"]');
expect(wrapper.find('.js-ff-rotate-token-button').exists()).toBe(false); expect(link.exists()).toBe(true);
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');
}); });
describe('instance id', () => { it('should display the instance ID in an input box', () => {
it('should be displayed in an input box', () => {
const input = wrapper.find('#instance_id'); const input = wrapper.find('#instance_id');
expect(input.element.value).toBe('instance-id-token'); expect(input.element.value).toBe('instance-id-token');
}); });
}); });
describe('api url', () => {
it('should be displayed in an input box', () => { describe('verified', () => {
const input = wrapper.find('#api_url'); afterEach(() => wrapper.destroy());
expect(input.element.value).toBe('/api/url'); 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('help text', () => {
it('should be displayed', () => { describe('cannot rotate token', () => {
const help = wrapper.find('p'); afterEach(() => wrapper.destroy());
expect(help.text()).toMatch(/More Information/); beforeEach(factory.bind(null, { canUserRotateToken: false }));
it('should not display the primary action', async () => {
expect(findPrimaryAction()).toBe(null);
}); });
it('should have links to the documentation', () => { it('shold not display regenerating instance ID', async () => {
const help = wrapper.find('p'); expect(findDangerCallout().exists()).toBe(false);
const link = help.find('a[href="/help/path"]'); });
expect(link.exists()).toBe(true);
const anchoredLink = help.find('a[href="/help/path/#flags"]'); it('should disable the project name input', async () => {
expect(anchoredLink.exists()).toBe(true); 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', () => { ...@@ -37,6 +37,9 @@ describe('Feature flags', () => {
const factory = (propsData = mockData, fn = shallowMount) => { const factory = (propsData = mockData, fn = shallowMount) => {
wrapper = fn(FeatureFlagsComponent, { wrapper = fn(FeatureFlagsComponent, {
propsData, propsData,
provide: {
projectName: 'fakeProjectName',
},
}); });
}; };
......
...@@ -10468,6 +10468,9 @@ msgstr "" ...@@ -10468,6 +10468,9 @@ msgstr ""
msgid "FeatureFlags|There was an error retrieving user lists" msgid "FeatureFlags|There was an error retrieving user lists"
msgstr "" 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." msgid "FeatureFlags|Try again in a few moments or contact your support team."
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