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', () => {
const mockEvent = { preventDefault: jest.fn() };
const projectName = 'fakeProjectName';
let wrapper; 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(() => { describe('idle', () => {
propsData = { afterEach(() => wrapper.destroy());
helpPath: '/help/path', beforeEach(factory);
helpClientLibrariesPath: '/help/path/#flags',
helpClientExamplePath: '/feature-flags#clientexample',
apiUrl: '/api/url',
instanceId: 'instance-id-token',
isRotating: false,
hasRotateError: false,
canUserRotateToken: true,
};
wrapper = shallowMount(Component, { it('should have Primary and Cancel actions', () => {
propsData, expect(findGlModal().props('actionCancel').text).toBe('Close');
expect(findPrimaryAction().text).toBe('Regenerate instance ID');
}); });
});
describe('rotate token', () => { it('should default disable the primary action', async () => {
it('should emit a `token` event on click', () => { const [{ disabled }] = findPrimaryAction().attributes;
wrapper.find(GlDeprecatedButton).vm.$emit('click'); expect(disabled).toBe(true);
return wrapper.vm.$nextTick().then(() => {
expect(wrapper.emitted('token')).toEqual([[]]);
});
}); });
it('should display an error if there is a rotate error', () => { it('should emit a `token` event when clicking on the Primary action', async () => {
wrapper.setProps({ hasRotateError: true }); findGlModal().vm.$emit('primary', mockEvent);
return wrapper.vm.$nextTick().then(() => { await wrapper.vm.$nextTick();
expect(wrapper.find('.text-danger')).toExist(); expect(wrapper.emitted('token')).toEqual([[]]);
expect(wrapper.find('[name="warning"]')).toExist(); expect(mockEvent.preventDefault).toHaveBeenCalled();
});
}); });
it('should be hidden if the user cannot rotate tokens', () => { it('should clear the project name input after generating the token', async () => {
wrapper.setProps({ canUserRotateToken: false }); findProjectNameInput().vm.$emit('input', projectName);
return wrapper.vm.$nextTick(() => { findGlModal().vm.$emit('primary', mockEvent);
expect(wrapper.find('.js-ff-rotate-token-button').exists()).toBe(false); await wrapper.vm.$nextTick();
}); expect(findProjectNameInput().attributes('value')).toBe('');
}); });
});
describe('instance id', () => { it('should provide an input for filling the project name', () => {
it('should be displayed in an input box', () => { expect(findProjectNameInput().exists()).toBe(true);
const input = wrapper.find('#instance_id'); expect(findProjectNameInput().attributes('value')).toBe('');
expect(input.element.value).toBe('instance-id-token');
}); });
});
describe('api url', () => { it('should display an help text', () => {
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', () => {
const help = wrapper.find('p'); const help = wrapper.find('p');
expect(help.text()).toMatch(/More Information/); expect(help.text()).toMatch(/More Information/);
}); });
...@@ -74,5 +80,73 @@ describe('Configure Feature Flags Modal', () => { ...@@ -74,5 +80,73 @@ describe('Configure Feature Flags Modal', () => {
const anchoredLink = help.find('a[href="/help/path/#flags"]'); const anchoredLink = help.find('a[href="/help/path/#flags"]');
expect(anchoredLink.exists()).toBe(true); 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', () => { ...@@ -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