Commit c32ae520 authored by Kyle Edwards's avatar Kyle Edwards

Frontend: Add ChronicDurationInput component

This component is a text input that converts a human-readable
duration into a number of seconds.
parent 0b83283a
<script>
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 {
throw 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>
......@@ -3914,6 +3914,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 ""
......@@ -25950,6 +25953,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 true', () => {
beforeEach(() => {
createComponent({ value: null, integerRequired: true });
});
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('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.vm.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.vm.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 });
});
});
describe('extra attributes', () => {
beforeEach(() => {
createComponent({ id: 'myInput', disabled: true });
});
it('passes extra attributes to text input', () => {
expect(textElement.id).toBe('myInput');
expect(textElement.disabled).toBe(true);
});
it('does not pass extra attributes to hidden input', () => {
expect(hiddenElement.id).toBe('');
expect(hiddenElement.disabled).toBe(false);
});
});
describe('lazy', () => {
describe('when lazy is true', () => {
beforeEach(() => {
createComponent({ lazy: true });
});
it('emits change on field change', async () => {
textElement.value = '2h20m';
textElement.dispatchEvent(new Event('change'));
await wrapper.vm.$nextTick();
expect(wrapper.emitted('change')).toEqual([[MOCK_VALUE]]);
});
it('does not emit change on field input', async () => {
textElement.value = '2h20m';
textElement.dispatchEvent(new Event('input'));
await wrapper.vm.$nextTick();
expect(wrapper.emitted('change')).toBeUndefined();
});
});
describe('when lazy is false', () => {
beforeEach(() => {
createComponent({ lazy: false });
});
it('emits change on field change', async () => {
textElement.value = '2h20m';
textElement.dispatchEvent(new Event('change'));
await wrapper.vm.$nextTick();
expect(wrapper.emitted('change')).toEqual([[MOCK_VALUE]]);
});
it('emits change on field input', async () => {
textElement.value = '2h20m';
textElement.dispatchEvent(new Event('input'));
await wrapper.vm.$nextTick();
expect(wrapper.emitted('change')).toEqual([[MOCK_VALUE]]);
});
});
});
});
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