Commit c8d7cf7a authored by Olena Horal-Koretska's avatar Olena Horal-Koretska

Merge branch 'chronic-duration-input' into 'master'

Add ChronicDurationInput component

See merge request gitlab-org/gitlab!73686
parents 31c9e3cb 33320f0a
<script>
import * as Sentry from '@sentry/browser';
import { GlFormInput } from '@gitlab/ui';
import {
DurationParseError,
outputChronicDuration,
parseChronicDuration,
} from '~/chronic_duration';
import { __ } from '~/locale';
export default {
components: {
GlFormInput,
},
model: {
prop: 'value',
event: 'change',
},
props: {
value: {
type: Number,
required: false,
default: null,
},
name: {
type: String,
required: false,
default: null,
},
integerRequired: {
type: Boolean,
required: false,
default: true,
},
},
data() {
return {
numberData: this.value,
humanReadableData: this.convertDuration(this.value),
isValueValid: this.value === null ? null : true,
};
},
computed: {
numberValue: {
get() {
return this.numberData;
},
set(value) {
if (this.numberData !== value) {
this.numberData = value;
this.humanReadableData = this.convertDuration(value);
this.isValueValid = value === null ? null : true;
}
this.emitEvents();
},
},
humanReadableValue: {
get() {
return this.humanReadableData;
},
set(value) {
this.humanReadableData = value;
try {
if (value === '') {
this.numberData = null;
this.isValueValid = null;
} else {
this.numberData = parseChronicDuration(value, {
keepZero: true,
raiseExceptions: true,
});
this.isValueValid = true;
}
} catch (e) {
if (e instanceof DurationParseError) {
this.isValueValid = false;
} else {
Sentry.captureException(e);
}
}
this.emitEvents(true);
},
},
isValidDecimal() {
return !this.integerRequired || this.numberData === null || Number.isInteger(this.numberData);
},
feedback() {
if (this.isValueValid === false) {
return this.$options.i18n.INVALID_INPUT_FEEDBACK;
}
if (!this.isValidDecimal) {
return this.$options.i18n.INVALID_DECIMAL_FEEDBACK;
}
return '';
},
},
i18n: {
INVALID_INPUT_FEEDBACK: __('Please enter a valid time interval'),
INVALID_DECIMAL_FEEDBACK: __('An integer value is required for seconds'),
},
watch: {
value() {
this.numberValue = this.value;
},
},
mounted() {
this.emitEvents();
},
methods: {
convertDuration(value) {
return value === null ? '' : outputChronicDuration(value);
},
emitEvents(emitChange = false) {
if (emitChange && this.isValueValid !== false && this.isValidDecimal) {
this.$emit('change', this.numberData);
}
const { feedback } = this;
this.$refs.text.$el.setCustomValidity(feedback);
this.$refs.hidden.setCustomValidity(feedback);
this.$emit('valid', {
valid: this.isValueValid && this.isValidDecimal,
feedback,
});
},
},
};
</script>
<template>
<div>
<gl-form-input ref="text" v-bind="$attrs" v-model="humanReadableValue" />
<input ref="hidden" type="hidden" :name="name" :value="numberValue" />
</div>
</template>
......@@ -3917,6 +3917,9 @@ msgstr ""
msgid "An example showing how to use Jsonnet with GitLab dynamic child pipelines"
msgstr ""
msgid "An integer value is required for seconds"
msgstr ""
msgid "An issue already exists"
msgstr ""
......@@ -25974,6 +25977,9 @@ msgstr ""
msgid "Please enter a valid number"
msgstr ""
msgid "Please enter a valid time interval"
msgstr ""
msgid "Please enter or upload a valid license."
msgstr ""
......
import { mount } from '@vue/test-utils';
import ChronicDurationInput from '~/vue_shared/components/chronic_duration_input.vue';
const MOCK_VALUE = 2 * 3600 + 20 * 60;
describe('vue_shared/components/chronic_duration_input', () => {
let wrapper;
let textElement;
let hiddenElement;
afterEach(() => {
wrapper.destroy();
wrapper = null;
textElement = null;
hiddenElement = null;
});
const findComponents = () => {
textElement = wrapper.find('input[type=text]').element;
hiddenElement = wrapper.find('input[type=hidden]').element;
};
const createComponent = (props = {}) => {
if (wrapper) {
throw new Error('There should only be one wrapper created per test');
}
wrapper = mount(ChronicDurationInput, { propsData: props });
findComponents();
};
describe('value', () => {
it('has human-readable output with value', () => {
createComponent({ value: MOCK_VALUE });
expect(textElement.value).toBe('2 hrs 20 mins');
expect(hiddenElement.value).toBe(MOCK_VALUE.toString());
});
it('has empty output with no value', () => {
createComponent({ value: null });
expect(textElement.value).toBe('');
expect(hiddenElement.value).toBe('');
});
});
describe('change', () => {
const createAndDispatch = async (initialValue, humanReadableInput) => {
createComponent({ value: initialValue });
await wrapper.vm.$nextTick();
textElement.value = humanReadableInput;
textElement.dispatchEvent(new Event('input'));
};
describe('when starting with no value and receiving human-readable input', () => {
beforeEach(() => {
createAndDispatch(null, '2hr20min');
});
it('updates hidden field', () => {
expect(textElement.value).toBe('2hr20min');
expect(hiddenElement.value).toBe(MOCK_VALUE.toString());
});
it('emits change event', () => {
expect(wrapper.emitted('change')).toEqual([[MOCK_VALUE]]);
});
});
describe('when starting with a value and receiving empty input', () => {
beforeEach(() => {
createAndDispatch(MOCK_VALUE, '');
});
it('updates hidden field', () => {
expect(textElement.value).toBe('');
expect(hiddenElement.value).toBe('');
});
it('emits change event', () => {
expect(wrapper.emitted('change')).toEqual([[null]]);
});
});
describe('when starting with a value and receiving invalid input', () => {
beforeEach(() => {
createAndDispatch(MOCK_VALUE, 'gobbledygook');
});
it('does not update hidden field', () => {
expect(textElement.value).toBe('gobbledygook');
expect(hiddenElement.value).toBe(MOCK_VALUE.toString());
});
it('does not emit change event', () => {
expect(wrapper.emitted('change')).toBeUndefined();
});
});
});
describe('valid', () => {
describe('initial value', () => {
beforeEach(() => {
createComponent({ value: MOCK_VALUE });
});
it('emits valid with initial value', () => {
expect(wrapper.emitted('valid')).toEqual([[{ valid: true, feedback: '' }]]);
expect(textElement.validity.valid).toBe(true);
expect(textElement.validity.customError).toBe(false);
expect(textElement.validationMessage).toBe('');
expect(hiddenElement.validity.valid).toBe(true);
expect(hiddenElement.validity.customError).toBe(false);
expect(hiddenElement.validationMessage).toBe('');
});
it('emits valid with user input', async () => {
textElement.value = '1m10s';
textElement.dispatchEvent(new Event('input'));
await wrapper.vm.$nextTick();
expect(wrapper.emitted('valid')).toEqual([
[{ valid: true, feedback: '' }],
[{ valid: true, feedback: '' }],
]);
expect(textElement.validity.valid).toBe(true);
expect(textElement.validity.customError).toBe(false);
expect(textElement.validationMessage).toBe('');
expect(hiddenElement.validity.valid).toBe(true);
expect(hiddenElement.validity.customError).toBe(false);
expect(hiddenElement.validationMessage).toBe('');
textElement.value = '';
textElement.dispatchEvent(new Event('input'));
await wrapper.vm.$nextTick();
expect(wrapper.emitted('valid')).toEqual([
[{ valid: true, feedback: '' }],
[{ valid: true, feedback: '' }],
[{ valid: null, feedback: '' }],
]);
expect(textElement.validity.valid).toBe(true);
expect(textElement.validity.customError).toBe(false);
expect(textElement.validationMessage).toBe('');
expect(hiddenElement.validity.valid).toBe(true);
expect(hiddenElement.validity.customError).toBe(false);
expect(hiddenElement.validationMessage).toBe('');
});
it('emits invalid with user input', async () => {
textElement.value = 'gobbledygook';
textElement.dispatchEvent(new Event('input'));
await wrapper.vm.$nextTick();
expect(wrapper.emitted('valid')).toEqual([
[{ valid: true, feedback: '' }],
[{ valid: false, feedback: ChronicDurationInput.i18n.INVALID_INPUT_FEEDBACK }],
]);
expect(textElement.validity.valid).toBe(false);
expect(textElement.validity.customError).toBe(true);
expect(textElement.validationMessage).toBe(
ChronicDurationInput.i18n.INVALID_INPUT_FEEDBACK,
);
expect(hiddenElement.validity.valid).toBe(false);
expect(hiddenElement.validity.customError).toBe(true);
// Hidden elements do not have validationMessage
expect(hiddenElement.validationMessage).toBe('');
});
});
describe('no initial value', () => {
beforeEach(() => {
createComponent({ value: null });
});
it('emits valid with no initial value', () => {
expect(wrapper.emitted('valid')).toEqual([[{ valid: null, feedback: '' }]]);
expect(textElement.validity.valid).toBe(true);
expect(textElement.validity.customError).toBe(false);
expect(textElement.validationMessage).toBe('');
expect(hiddenElement.validity.valid).toBe(true);
expect(hiddenElement.validity.customError).toBe(false);
expect(hiddenElement.validationMessage).toBe('');
});
it('emits valid with updated value', async () => {
wrapper.setProps({ value: MOCK_VALUE });
await wrapper.vm.$nextTick();
expect(wrapper.emitted('valid')).toEqual([
[{ valid: null, feedback: '' }],
[{ valid: true, feedback: '' }],
]);
expect(textElement.validity.valid).toBe(true);
expect(textElement.validity.customError).toBe(false);
expect(textElement.validationMessage).toBe('');
expect(hiddenElement.validity.valid).toBe(true);
expect(hiddenElement.validity.customError).toBe(false);
expect(hiddenElement.validationMessage).toBe('');
});
});
describe('decimal input', () => {
describe('when integerRequired is false', () => {
beforeEach(() => {
createComponent({ value: null, integerRequired: false });
});
it('emits valid when input is integer', async () => {
textElement.value = '2hr20min';
textElement.dispatchEvent(new Event('input'));
await wrapper.vm.$nextTick();
expect(wrapper.emitted('change')).toEqual([[MOCK_VALUE]]);
expect(wrapper.emitted('valid')).toEqual([
[{ valid: null, feedback: '' }],
[{ valid: true, feedback: '' }],
]);
expect(textElement.validity.valid).toBe(true);
expect(textElement.validity.customError).toBe(false);
expect(textElement.validationMessage).toBe('');
expect(hiddenElement.validity.valid).toBe(true);
expect(hiddenElement.validity.customError).toBe(false);
expect(hiddenElement.validationMessage).toBe('');
});
it('emits valid when input is decimal', async () => {
textElement.value = '1.5s';
textElement.dispatchEvent(new Event('input'));
await wrapper.vm.$nextTick();
expect(wrapper.emitted('change')).toEqual([[1.5]]);
expect(wrapper.emitted('valid')).toEqual([
[{ valid: null, feedback: '' }],
[{ valid: true, feedback: '' }],
]);
expect(textElement.validity.valid).toBe(true);
expect(textElement.validity.customError).toBe(false);
expect(textElement.validationMessage).toBe('');
expect(hiddenElement.validity.valid).toBe(true);
expect(hiddenElement.validity.customError).toBe(false);
expect(hiddenElement.validationMessage).toBe('');
});
});
describe('when integerRequired is unspecified', () => {
beforeEach(() => {
createComponent({ value: null });
});
it('emits valid when input is integer', async () => {
textElement.value = '2hr20min';
textElement.dispatchEvent(new Event('input'));
await wrapper.vm.$nextTick();
expect(wrapper.emitted('change')).toEqual([[MOCK_VALUE]]);
expect(wrapper.emitted('valid')).toEqual([
[{ valid: null, feedback: '' }],
[{ valid: true, feedback: '' }],
]);
expect(textElement.validity.valid).toBe(true);
expect(textElement.validity.customError).toBe(false);
expect(textElement.validationMessage).toBe('');
expect(hiddenElement.validity.valid).toBe(true);
expect(hiddenElement.validity.customError).toBe(false);
expect(hiddenElement.validationMessage).toBe('');
});
it('emits invalid when input is decimal', async () => {
textElement.value = '1.5s';
textElement.dispatchEvent(new Event('input'));
await wrapper.vm.$nextTick();
expect(wrapper.emitted('change')).toBeUndefined();
expect(wrapper.emitted('valid')).toEqual([
[{ valid: null, feedback: '' }],
[
{
valid: false,
feedback: ChronicDurationInput.i18n.INVALID_DECIMAL_FEEDBACK,
},
],
]);
expect(textElement.validity.valid).toBe(false);
expect(textElement.validity.customError).toBe(true);
expect(textElement.validationMessage).toBe(
ChronicDurationInput.i18n.INVALID_DECIMAL_FEEDBACK,
);
expect(hiddenElement.validity.valid).toBe(false);
expect(hiddenElement.validity.customError).toBe(true);
// Hidden elements do not have validationMessage
expect(hiddenElement.validationMessage).toBe('');
});
});
});
});
describe('v-model', () => {
beforeEach(() => {
wrapper = mount({
data() {
return { value: 1 * 60 + 10 };
},
components: { ChronicDurationInput },
template: '<div><chronic-duration-input v-model="value"/></div>',
});
findComponents();
});
describe('value', () => {
it('passes initial prop via v-model', () => {
expect(textElement.value).toBe('1 min 10 secs');
expect(hiddenElement.value).toBe((1 * 60 + 10).toString());
});
it('passes updated prop via v-model', async () => {
wrapper.setData({ value: MOCK_VALUE });
await wrapper.vm.$nextTick();
expect(textElement.value).toBe('2 hrs 20 mins');
expect(hiddenElement.value).toBe(MOCK_VALUE.toString());
});
});
describe('change', () => {
it('passes user input to parent via v-model', async () => {
textElement.value = '2hr20min';
textElement.dispatchEvent(new Event('input'));
await wrapper.vm.$nextTick();
expect(wrapper.findComponent(ChronicDurationInput).props('value')).toBe(MOCK_VALUE);
expect(textElement.value).toBe('2hr20min');
expect(hiddenElement.value).toBe(MOCK_VALUE.toString());
});
});
});
describe('name', () => {
beforeEach(() => {
createComponent({ name: 'myInput' });
});
it('sets name of hidden field', () => {
expect(hiddenElement.name).toBe('myInput');
});
it('does not set name of text field', () => {
expect(textElement.name).toBe('');
});
});
describe('form submission', () => {
beforeEach(() => {
wrapper = mount({
template: `<form data-testid="myForm"><chronic-duration-input name="myInput" :value="${MOCK_VALUE}"/></form>`,
components: {
ChronicDurationInput,
},
});
findComponents();
});
it('creates form data with initial value', () => {
const formData = new FormData(wrapper.find('[data-testid=myForm]').element);
const iter = formData.entries();
expect(iter.next()).toEqual({
value: ['myInput', MOCK_VALUE.toString()],
done: false,
});
expect(iter.next()).toEqual({ value: undefined, done: true });
});
it('creates form data with user-specified value', async () => {
textElement.value = '1m10s';
textElement.dispatchEvent(new Event('input'));
await wrapper.vm.$nextTick();
const formData = new FormData(wrapper.find('[data-testid=myForm]').element);
const iter = formData.entries();
expect(iter.next()).toEqual({
value: ['myInput', (1 * 60 + 10).toString()],
done: false,
});
expect(iter.next()).toEqual({ value: undefined, done: true });
});
});
});
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