Commit e6018671 authored by Andrew Fontaine's avatar Andrew Fontaine

Merge branch...

Merge branch '225543-add-frontend-validation-to-restrict-membership-by-email-field-in-group-settings-general' into 'master'

Add frontend validation to "Restrict membership by email domain" field

See merge request gitlab-org/gitlab!38348
parents 3ddabbce 3002a55c
...@@ -2,7 +2,7 @@ import Vue from 'vue'; ...@@ -2,7 +2,7 @@ import Vue from 'vue';
import { __, sprintf } from '~/locale'; import { __, sprintf } from '~/locale';
import CommaSeparatedListTokenSelector from '../components/comma_separated_list_token_selector.vue'; import CommaSeparatedListTokenSelector from '../components/comma_separated_list_token_selector.vue';
export default (el, placeholder, qaSelector) => { export default (el, props = {}, qaSelector) => {
// eslint-disable-next-line no-new // eslint-disable-next-line no-new
new Vue({ new Vue({
el, el,
...@@ -10,9 +10,17 @@ export default (el, placeholder, qaSelector) => { ...@@ -10,9 +10,17 @@ export default (el, placeholder, qaSelector) => {
CommaSeparatedListTokenSelector, CommaSeparatedListTokenSelector,
}, },
data() { data() {
const { hiddenInputId, labelId } = document.querySelector(this.$options.el).dataset; const { hiddenInputId, labelId, regexValidator, disallowedValues } = document.querySelector(
this.$options.el,
).dataset;
return { hiddenInputId, labelId }; return {
hiddenInputId,
labelId,
regexValidator,
...(regexValidator ? { regexValidator: new RegExp(regexValidator) } : {}),
...(disallowedValues ? { disallowedValues: JSON.parse(disallowedValues) } : {}),
};
}, },
render(createElement) { render(createElement) {
return createElement('comma-separated-list-token-selector', { return createElement('comma-separated-list-token-selector', {
...@@ -22,7 +30,9 @@ export default (el, placeholder, qaSelector) => { ...@@ -22,7 +30,9 @@ export default (el, placeholder, qaSelector) => {
props: { props: {
hiddenInputId: this.hiddenInputId, hiddenInputId: this.hiddenInputId,
ariaLabelledby: this.labelId, ariaLabelledby: this.labelId,
placeholder, regexValidator: this.regexValidator,
disallowedValues: this.disallowedValues,
...props,
}, },
scopedSlots: { scopedSlots: {
'user-defined-token-content': ({ inputText: value }) => { 'user-defined-token-content': ({ inputText: value }) => {
......
<script> <script>
import { GlTokenSelector } from '@gitlab/ui'; import { GlTokenSelector } from '@gitlab/ui';
import { isEmpty } from 'lodash';
export default { export default {
name: 'CommaSeparatedListTokenSelector', name: 'CommaSeparatedListTokenSelector',
...@@ -19,12 +20,50 @@ export default { ...@@ -19,12 +20,50 @@ export default {
required: false, required: false,
default: null, default: null,
}, },
regexValidator: {
type: RegExp,
required: false,
default: null,
},
disallowedValues: {
type: Array,
required: false,
default: () => [],
},
errorMessage: {
type: String,
required: false,
default: '',
},
disallowedValueErrorMessage: {
type: String,
required: false,
default: '',
},
}, },
data() { data() {
return { return {
selectedTokens: [], selectedTokens: [],
textInputValue: '',
hideErrorMessage: true,
}; };
}, },
computed: {
tokenIsValid() {
return this.computedErrorMessage === '';
},
computedErrorMessage() {
if (this.regexValidator !== null && this.textInputValue.match(this.regexValidator) === null) {
return this.errorMessage;
}
if (!isEmpty(this.disallowedValues) && this.disallowedValues.includes(this.textInputValue)) {
return this.disallowedValueErrorMessage;
}
return '';
},
},
watch: { watch: {
selectedTokens(newValue) { selectedTokens(newValue) {
this.$options.hiddenInput.value = newValue.map(token => token.name).join(','); this.$options.hiddenInput.value = newValue.map(token => token.name).join(',');
...@@ -53,27 +92,51 @@ export default { ...@@ -53,27 +92,51 @@ export default {
}, },
methods: { methods: {
handleEnter(event) { handleEnter(event) {
if (this.textInputValue !== '' && !this.tokenIsValid) {
this.hideErrorMessage = false;
// Trigger a focus event on the token selector to explicitly open the dropdown and display the error message
this.$nextTick(() => {
this.$refs.tokenSelector.$el
.querySelector('input[type="text"]')
.dispatchEvent(new Event('focus'));
});
}
// Prevent form from submitting when adding a token // Prevent form from submitting when adding a token
if (event.target.value !== '') { if (event.target.value !== '') {
event.preventDefault(); event.preventDefault();
} }
}, },
handleTextInput(value) {
this.hideErrorMessage = true;
this.textInputValue = value;
},
handleBlur() {
this.hideErrorMessage = true;
},
}, },
}; };
</script> </script>
<template> <template>
<gl-token-selector <gl-token-selector
ref="tokenSelector"
v-model="selectedTokens" v-model="selectedTokens"
container-class="gl-h-auto!" container-class="gl-h-auto!"
allow-user-defined-tokens :allow-user-defined-tokens="tokenIsValid"
hide-dropdown-with-no-items :hide-dropdown-with-no-items="hideErrorMessage"
:aria-labelledby="ariaLabelledby" :aria-labelledby="ariaLabelledby"
:placeholder="placeholder" :placeholder="placeholder"
@keydown.enter="handleEnter" @keydown.enter="handleEnter"
@text-input="handleTextInput"
@blur="handleBlur"
> >
<template #user-defined-token-content="{ inputText }"> <template #user-defined-token-content="{ inputText }">
<slot name="user-defined-token-content" :input-text="inputText"></slot> <slot name="user-defined-token-content" :input-text="inputText"></slot>
</template> </template>
<template #no-results-content>
<span class="gl-text-red-500">{{ computedErrorMessage }}</span>
</template>
</gl-token-selector> </gl-token-selector>
</template> </template>
...@@ -3,10 +3,14 @@ import initAccessRestrictionField from 'ee/groups/settings/access_restriction_fi ...@@ -3,10 +3,14 @@ import initAccessRestrictionField from 'ee/groups/settings/access_restriction_fi
import { __ } from '~/locale'; import { __ } from '~/locale';
document.addEventListener('DOMContentLoaded', () => { document.addEventListener('DOMContentLoaded', () => {
initAccessRestrictionField('.js-allowed-email-domains', __('Enter domain')); initAccessRestrictionField('.js-allowed-email-domains', {
placeholder: __('Enter domain'),
errorMessage: __('The domain you entered is misformatted.'),
disallowedValueErrorMessage: __('The domain you entered is not allowed.'),
});
initAccessRestrictionField( initAccessRestrictionField(
'.js-ip-restriction', '.js-ip-restriction',
__('Enter IP address range'), { placeholder: __('Enter IP address range') },
'ip_restriction_field', 'ip_restriction_field',
); );
}); });
...@@ -14,12 +14,14 @@ class AllowedEmailDomain < ApplicationRecord ...@@ -14,12 +14,14 @@ class AllowedEmailDomain < ApplicationRecord
'icloud.com' 'icloud.com'
].freeze ].freeze
VALID_DOMAIN_REGEX = /\w*\./.freeze
validates :group_id, presence: true validates :group_id, presence: true
validates :domain, presence: true validates :domain, presence: true
validate :allow_root_group_only validate :allow_root_group_only
validates :domain, exclusion: { in: RESERVED_DOMAINS, validates :domain, exclusion: { in: RESERVED_DOMAINS,
message: _('The domain you entered is not allowed.') } message: _('The domain you entered is not allowed.') }
validates :domain, format: { with: /\w*\./, validates :domain, format: { with: VALID_DOMAIN_REGEX,
message: _('The domain you entered is misformatted.') } message: _('The domain you entered is misformatted.') }
belongs_to :group, class_name: 'Group', foreign_key: :group_id belongs_to :group, class_name: 'Group', foreign_key: :group_id
......
...@@ -5,7 +5,10 @@ ...@@ -5,7 +5,10 @@
.form-group .form-group
%label{ id: label_id } %label{ id: label_id }
= _('Restrict membership by email domain') = _('Restrict membership by email domain')
.js-allowed-email-domains{ data: { hidden_input_id: hidden_input_id, label_id: label_id } } .js-allowed-email-domains{ data: { hidden_input_id: hidden_input_id,
label_id: label_id,
regex_validator: AllowedEmailDomain::VALID_DOMAIN_REGEX.source,
disallowed_values: AllowedEmailDomain::RESERVED_DOMAINS.to_json } }
= f.hidden_field :allowed_email_domains_list, id: hidden_input_id = f.hidden_field :allowed_email_domains_list, id: hidden_input_id
.form-text.text-muted .form-text.text-muted
- read_more_link = link_to(_('Read more'), help_page_path('user/group/index', anchor: 'allowed-domain-restriction-premium')) - read_more_link = link_to(_('Read more'), help_page_path('user/group/index', anchor: 'allowed-domain-restriction-premium'))
......
---
title: Add frontend validation to "Restrict membership by email domain" field
merge_request: 38348
author:
type: changed
...@@ -12,6 +12,8 @@ describe('CommaSeparatedListTokenSelector', () => { ...@@ -12,6 +12,8 @@ describe('CommaSeparatedListTokenSelector', () => {
const defaultProps = { const defaultProps = {
hiddenInputId: 'comma-separated-list', hiddenInputId: 'comma-separated-list',
ariaLabelledby: 'comma-separated-list-label', ariaLabelledby: 'comma-separated-list-label',
errorMessage: 'The value entered is invalid',
disallowedValueErrorMessage: 'The value entered is not allowed',
}; };
const createComponent = options => { const createComponent = options => {
...@@ -25,6 +27,31 @@ describe('CommaSeparatedListTokenSelector', () => { ...@@ -25,6 +27,31 @@ describe('CommaSeparatedListTokenSelector', () => {
}); });
}; };
const findTokenSelector = () => wrapper.find(GlTokenSelector);
const findTokenSelectorInput = () => findTokenSelector().find('input[type="text"]');
const findTokenSelectorDropdown = () => findTokenSelector().find('[role="menu"]');
const findErrorMessageText = () =>
findTokenSelector()
.find('[role="menuitem"][disabled="disabled"]')
.text();
const setTokenSelectorInputValue = value => {
const tokenSelectorInput = findTokenSelectorInput();
tokenSelectorInput.element.value = value;
tokenSelectorInput.trigger('input');
return nextTick();
};
const tokenSelectorTriggerEnter = event => {
const tokenSelectorInput = findTokenSelectorInput();
tokenSelectorInput.trigger('keydown.enter', event);
};
beforeEach(() => { beforeEach(() => {
div = document.createElement('div'); div = document.createElement('div');
input = document.createElement('input'); input = document.createElement('input');
...@@ -63,7 +90,7 @@ describe('CommaSeparatedListTokenSelector', () => { ...@@ -63,7 +90,7 @@ describe('CommaSeparatedListTokenSelector', () => {
}); });
describe('when selected tokens changes', () => { describe('when selected tokens changes', () => {
const setup = async () => { const setup = () => {
const tokens = [ const tokens = [
{ {
id: 1, id: 1,
...@@ -81,7 +108,7 @@ describe('CommaSeparatedListTokenSelector', () => { ...@@ -81,7 +108,7 @@ describe('CommaSeparatedListTokenSelector', () => {
createComponent(); createComponent();
await wrapper.setData({ return wrapper.setData({
selectedTokens: tokens, selectedTokens: tokens,
}); });
}; };
...@@ -109,13 +136,160 @@ describe('CommaSeparatedListTokenSelector', () => { ...@@ -109,13 +136,160 @@ describe('CommaSeparatedListTokenSelector', () => {
it('does not submit the form if token selector text input has a value', async () => { it('does not submit the form if token selector text input has a value', async () => {
createComponent(); createComponent();
const tokenSelectorInput = wrapper.find(GlTokenSelector).find('input[type="text"]'); await setTokenSelectorInputValue('foo bar');
tokenSelectorInput.element.value = 'foo bar';
const event = { preventDefault: jest.fn() }; const event = { preventDefault: jest.fn() };
await tokenSelectorInput.trigger('keydown.enter', event); tokenSelectorTriggerEnter(event);
expect(event.preventDefault).toHaveBeenCalled(); expect(event.preventDefault).toHaveBeenCalled();
}); });
describe('when `regexValidator` prop is set', () => {
it('displays `errorMessage` if regex fails', async () => {
createComponent({
propsData: {
regexValidator: /baz/,
},
});
await setTokenSelectorInputValue('foo bar');
tokenSelectorTriggerEnter();
expect(findErrorMessageText()).toBe('The value entered is invalid');
});
});
describe('when `disallowedValues` prop is set', () => {
it('displays `disallowedValueErrorMessage` if value is in the disallowed list', async () => {
createComponent({
propsData: {
disallowedValues: ['foo', 'bar', 'baz'],
},
});
await setTokenSelectorInputValue('foo');
tokenSelectorTriggerEnter();
expect(findErrorMessageText()).toBe('The value entered is not allowed');
});
});
describe('when `regexValidator` and `disallowedValues` props are set', () => {
it('displays `errorMessage` if regex fails', async () => {
createComponent({
propsData: {
regexValidator: /baz/,
disallowedValues: ['foo', 'bar', 'baz'],
},
});
await setTokenSelectorInputValue('foo bar');
tokenSelectorTriggerEnter();
expect(findErrorMessageText()).toBe('The value entered is invalid');
});
it('displays `disallowedValueErrorMessage` if regex passes but value is in the disallowed list', async () => {
createComponent({
propsData: {
regexValidator: /foo/,
disallowedValues: ['foo', 'bar', 'baz'],
},
});
await setTokenSelectorInputValue('foo');
tokenSelectorTriggerEnter();
expect(findErrorMessageText()).toBe('The value entered is not allowed');
});
});
});
describe('when `regexValidator` and `disallowedValues` props are set', () => {
it('allows value to be added as a token if regex passes and value is not in the disallowed list', async () => {
createComponent({
propsData: {
regexValidator: /foo/,
disallowedValues: ['bar', 'baz'],
},
scopedSlots: {
'user-defined-token-content': '<span>Add "{{props.inputText}}"</span>',
},
});
await setTokenSelectorInputValue('foo');
tokenSelectorTriggerEnter();
expect(
findTokenSelector()
.find('[role="menuitem"]')
.text(),
).toBe('Add "foo"');
});
});
describe('when `regexValidator` and `disallowedValues` props are not set', () => {
it('allows any value to be added', async () => {
createComponent({
scopedSlots: {
'user-defined-token-content': '<span>Add "{{props.inputText}}"</span>',
},
});
await setTokenSelectorInputValue('foo');
expect(
findTokenSelector()
.find('[role="menuitem"]')
.text(),
).toBe('Add "foo"');
});
});
describe('when token selector text input is typed in after showing error message', () => {
it('hides error message', async () => {
createComponent({
propsData: {
regexValidator: /baz/,
},
});
await setTokenSelectorInputValue('foo');
tokenSelectorTriggerEnter();
expect(findErrorMessageText()).toBe('The value entered is invalid');
await setTokenSelectorInputValue('foo bar');
await nextTick();
expect(findTokenSelectorDropdown().classes()).not.toContain('show');
});
});
describe('when token selector text input is blurred after showing error message', () => {
it('hides error message', async () => {
createComponent({
propsData: {
regexValidator: /baz/,
},
});
await setTokenSelectorInputValue('foo');
tokenSelectorTriggerEnter();
findTokenSelectorInput().trigger('blur');
await nextTick();
expect(findTokenSelectorDropdown().classes()).not.toContain('show');
});
}); });
}); });
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