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';
import { __, sprintf } from '~/locale';
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
new Vue({
el,
......@@ -10,9 +10,17 @@ export default (el, placeholder, qaSelector) => {
CommaSeparatedListTokenSelector,
},
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) {
return createElement('comma-separated-list-token-selector', {
......@@ -22,7 +30,9 @@ export default (el, placeholder, qaSelector) => {
props: {
hiddenInputId: this.hiddenInputId,
ariaLabelledby: this.labelId,
placeholder,
regexValidator: this.regexValidator,
disallowedValues: this.disallowedValues,
...props,
},
scopedSlots: {
'user-defined-token-content': ({ inputText: value }) => {
......
<script>
import { GlTokenSelector } from '@gitlab/ui';
import { isEmpty } from 'lodash';
export default {
name: 'CommaSeparatedListTokenSelector',
......@@ -19,12 +20,50 @@ export default {
required: false,
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() {
return {
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: {
selectedTokens(newValue) {
this.$options.hiddenInput.value = newValue.map(token => token.name).join(',');
......@@ -53,27 +92,51 @@ export default {
},
methods: {
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
if (event.target.value !== '') {
event.preventDefault();
}
},
handleTextInput(value) {
this.hideErrorMessage = true;
this.textInputValue = value;
},
handleBlur() {
this.hideErrorMessage = true;
},
},
};
</script>
<template>
<gl-token-selector
ref="tokenSelector"
v-model="selectedTokens"
container-class="gl-h-auto!"
allow-user-defined-tokens
hide-dropdown-with-no-items
:allow-user-defined-tokens="tokenIsValid"
:hide-dropdown-with-no-items="hideErrorMessage"
:aria-labelledby="ariaLabelledby"
:placeholder="placeholder"
@keydown.enter="handleEnter"
@text-input="handleTextInput"
@blur="handleBlur"
>
<template #user-defined-token-content="{ inputText }">
<slot name="user-defined-token-content" :input-text="inputText"></slot>
</template>
<template #no-results-content>
<span class="gl-text-red-500">{{ computedErrorMessage }}</span>
</template>
</gl-token-selector>
</template>
......@@ -3,10 +3,14 @@ import initAccessRestrictionField from 'ee/groups/settings/access_restriction_fi
import { __ } from '~/locale';
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(
'.js-ip-restriction',
__('Enter IP address range'),
{ placeholder: __('Enter IP address range') },
'ip_restriction_field',
);
});
......@@ -14,12 +14,14 @@ class AllowedEmailDomain < ApplicationRecord
'icloud.com'
].freeze
VALID_DOMAIN_REGEX = /\w*\./.freeze
validates :group_id, presence: true
validates :domain, presence: true
validate :allow_root_group_only
validates :domain, exclusion: { in: RESERVED_DOMAINS,
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.') }
belongs_to :group, class_name: 'Group', foreign_key: :group_id
......
......@@ -5,7 +5,10 @@
.form-group
%label{ id: label_id }
= _('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
.form-text.text-muted
- 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', () => {
const defaultProps = {
hiddenInputId: 'comma-separated-list',
ariaLabelledby: 'comma-separated-list-label',
errorMessage: 'The value entered is invalid',
disallowedValueErrorMessage: 'The value entered is not allowed',
};
const createComponent = options => {
......@@ -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(() => {
div = document.createElement('div');
input = document.createElement('input');
......@@ -63,7 +90,7 @@ describe('CommaSeparatedListTokenSelector', () => {
});
describe('when selected tokens changes', () => {
const setup = async () => {
const setup = () => {
const tokens = [
{
id: 1,
......@@ -81,7 +108,7 @@ describe('CommaSeparatedListTokenSelector', () => {
createComponent();
await wrapper.setData({
return wrapper.setData({
selectedTokens: tokens,
});
};
......@@ -109,13 +136,160 @@ describe('CommaSeparatedListTokenSelector', () => {
it('does not submit the form if token selector text input has a value', async () => {
createComponent();
const tokenSelectorInput = wrapper.find(GlTokenSelector).find('input[type="text"]');
tokenSelectorInput.element.value = 'foo bar';
await setTokenSelectorInputValue('foo bar');
const event = { preventDefault: jest.fn() };
await tokenSelectorInput.trigger('keydown.enter', event);
tokenSelectorTriggerEnter(event);
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