Commit 9246a50d authored by peterhegman's avatar peterhegman

Add frontend validation to "Restrict membership by email domain"

Improve UX by implementing frontend validation for the
"Restrict membership by email domain" field
parent 38d58208
......@@ -10,9 +10,24 @@ export default (el, placeholder, qaSelector) => {
CommaSeparatedListTokenSelector,
},
data() {
const { hiddenInputId, labelId } = document.querySelector(this.$options.el).dataset;
const {
hiddenInputId,
labelId,
regexValidator,
disallowedValues,
errorMessage,
disallowedValueErrorMessage,
} = document.querySelector(this.$options.el).dataset;
return { hiddenInputId, labelId };
return {
hiddenInputId,
labelId,
regexValidator,
errorMessage,
disallowedValueErrorMessage,
...(regexValidator ? { regexValidator: new RegExp(regexValidator) } : {}),
...(disallowedValues ? { disallowedValues: JSON.parse(disallowedValues) } : {}),
};
},
render(createElement) {
return createElement('comma-separated-list-token-selector', {
......@@ -22,6 +37,10 @@ export default (el, placeholder, qaSelector) => {
props: {
hiddenInputId: this.hiddenInputId,
ariaLabelledby: this.labelId,
regexValidator: this.regexValidator,
disallowedValues: this.disallowedValues,
errorMessage: this.errorMessage,
disallowedValueErrorMessage: this.disallowedValueErrorMessage,
placeholder,
},
scopedSlots: {
......
<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>
......@@ -14,12 +14,14 @@ class AllowedEmailDomain < ApplicationRecord
'icloud.com'
].freeze
REGEX_VALIDATOR = /\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: REGEX_VALIDATOR,
message: _('The domain you entered is misformatted.') }
belongs_to :group, class_name: 'Group', foreign_key: :group_id
......
......@@ -5,7 +5,12 @@
.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::REGEX_VALIDATOR.source,
disallowed_values: AllowedEmailDomain::RESERVED_DOMAINS.to_json,
error_message: _('The domain you entered is misformatted.'),
disallowed_value_error_message: _('The domain you entered is not allowed.') } }
= 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
......@@ -25,6 +25,28 @@ describe('CommaSeparatedListTokenSelector', () => {
});
};
const findTokenSelector = () => wrapper.find(GlTokenSelector);
const findTokenSelectorInput = () => findTokenSelector().find('input[type="text"]');
const findTokenSelectorDropdown = () => findTokenSelector().find('[role="menu"]');
const findErrorMessage = () => findTokenSelector().find('[role="menuitem"][disabled="disabled"]');
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 +85,7 @@ describe('CommaSeparatedListTokenSelector', () => {
});
describe('when selected tokens changes', () => {
const setup = async () => {
const setup = () => {
const tokens = [
{
id: 1,
......@@ -81,7 +103,7 @@ describe('CommaSeparatedListTokenSelector', () => {
createComponent();
await wrapper.setData({
return wrapper.setData({
selectedTokens: tokens,
});
};
......@@ -109,13 +131,170 @@ 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/,
errorMessage: 'The value entered is invalid',
},
});
await setTokenSelectorInputValue('foo bar');
tokenSelectorTriggerEnter();
expect(findErrorMessage().text()).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'],
disallowedValueErrorMessage: 'The value entered is not allowed',
},
});
await setTokenSelectorInputValue('foo');
tokenSelectorTriggerEnter();
expect(findErrorMessage().text()).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/,
errorMessage: 'The value entered is invalid',
disallowedValues: ['foo', 'bar', 'baz'],
disallowedValueErrorMessage: 'The value entered is not allowed',
},
});
await setTokenSelectorInputValue('foo bar');
tokenSelectorTriggerEnter();
expect(findErrorMessage().text()).toBe('The value entered is invalid');
});
it('displays `disallowedValueErrorMessage` if regex passes but value is in the disallowed list', async () => {
createComponent({
propsData: {
regexValidator: /foo/,
errorMessage: 'The value entered is invalid',
disallowedValues: ['foo', 'bar', 'baz'],
disallowedValueErrorMessage: 'The value entered is not allowed',
},
});
await setTokenSelectorInputValue('foo');
tokenSelectorTriggerEnter();
expect(findErrorMessage().text()).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/,
errorMessage: 'The value entered is invalid',
disallowedValues: ['bar', 'baz'],
disallowedValueErrorMessage: 'The value entered is not allowed',
},
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/,
errorMessage: 'The value entered is invalid',
},
});
await setTokenSelectorInputValue('foo');
tokenSelectorTriggerEnter();
expect(findErrorMessage().text()).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/,
errorMessage: 'The value entered is invalid',
},
});
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