Commit 563bd7cf authored by Janis Altherr's avatar Janis Altherr Committed by Brandon Labuschagne

Introduce a list widget for the Pipeline wizard

This introduces a list type widget for the Pipeline wizard.
It allows the injection of a Sequence of text values into the
pipeline.
Signed-off-by: default avatarJanis Altherr <jaltherr@gitlab.com>
parent 5730873b
<script>
import { uniqueId } from 'lodash';
import { GlButton, GlFormGroup, GlFormInputGroup } from '@gitlab/ui';
import { s__ } from '~/locale';
const VALIDATION_STATE = {
NO_VALIDATION: null,
INVALID: false,
VALID: true,
};
export const i18n = {
addStepButtonLabel: s__('PipelineWizardListWidget|add another step'),
removeStepButtonLabel: s__('PipelineWizardListWidget|remove step'),
invalidFeedback: s__('PipelineWizardInputValidation|This value is not valid'),
errors: {
needsAnyValueError: s__('PipelineWizardInputValidation|At least one entry is required'),
},
};
export default {
i18n,
name: 'ListWidget',
components: {
GlButton,
GlFormGroup,
GlFormInputGroup,
},
props: {
label: {
type: String,
required: true,
},
description: {
type: String,
required: false,
default: null,
},
placeholder: {
type: String,
required: false,
default: null,
},
default: {
type: Array,
required: false,
default: null,
},
invalidFeedback: {
type: String,
required: false,
default: i18n.invalidFeedback,
},
id: {
type: String,
required: false,
default: () => uniqueId('listWidget-'),
},
pattern: {
type: String,
required: false,
default: null,
},
required: {
type: Boolean,
required: false,
default: false,
},
validate: {
type: Boolean,
required: false,
default: false,
},
},
data() {
return {
touched: false,
value: this.default ? this.default.map(this.getAsValueEntry) : [this.getAsValueEntry(null)],
};
},
computed: {
sanitizedValue() {
// Filter out empty steps
return this.value.filter(({ value }) => Boolean(value)).map(({ value }) => value) || [];
},
hasAnyValue() {
return this.value.some(({ value }) => Boolean(value));
},
needsAnyValue() {
return this.required && !this.value.some(({ value }) => Boolean(value));
},
inputFieldStates() {
return this.value.map(this.getValidationStateForValue);
},
inputGroupState() {
return this.showValidationState
? this.inputFieldStates.every((v) => v !== VALIDATION_STATE.INVALID)
: VALIDATION_STATE.NO_VALIDATION;
},
showValidationState() {
return this.touched || this.validate;
},
feedback() {
return this.needsAnyValue
? this.$options.i18n.errors.needsAnyValueError
: this.invalidFeedback;
},
},
async created() {
if (this.default) {
// emit an updated default value
await this.$nextTick();
this.$emit('input', this.sanitizedValue);
}
},
methods: {
addInputField() {
this.value.push(this.getAsValueEntry(null));
},
getAsValueEntry(value) {
return {
id: uniqueId('listValue-'),
value,
};
},
getValidationStateForValue({ value }, fieldIndex) {
// If we require a value to be set, mark the first
// field as invalid, but not all of them.
if (this.needsAnyValue && fieldIndex === 0) return VALIDATION_STATE.INVALID;
if (!value) return VALIDATION_STATE.NO_VALIDATION;
return this.passesPatternValidation(value)
? VALIDATION_STATE.VALID
: VALIDATION_STATE.INVALID;
},
passesPatternValidation(v) {
return !this.pattern || new RegExp(this.pattern).test(v);
},
async onValueUpdate() {
await this.$nextTick();
this.$emit('input', this.sanitizedValue);
},
onTouch() {
this.touched = true;
},
removeValue(index) {
this.value.splice(index, 1);
this.onValueUpdate();
},
},
};
</script>
<template>
<div class="gl-mb-6">
<gl-form-group
:invalid-feedback="feedback"
:label="label"
:label-description="description"
:state="inputGroupState"
class="gl-mb-2"
>
<gl-form-input-group
v-for="(item, i) in value"
:key="item.id"
v-model.trim="value[i].value"
:placeholder="i === 0 ? placeholder : undefined"
:state="inputFieldStates[i]"
class="gl-mb-2"
type="text"
@blur="onTouch"
@input="onValueUpdate"
>
<template v-if="value.length > 1" #append>
<gl-button
:aria-label="$options.i18n.removeStepButtonLabel"
category="secondary"
data-testid="remove-step-button"
icon="remove"
@click="removeValue"
/>
</template>
</gl-form-input-group>
</gl-form-group>
<gl-button
category="tertiary"
data-testid="add-step-button"
icon="plus"
size="small"
variant="confirm"
@click="addInputField"
>
{{ $options.i18n.addStepButtonLabel }}
</gl-button>
</div>
</template>
......@@ -26813,12 +26813,21 @@ msgstr ""
msgid "PipelineWizardDefaultCommitMessage|Update %{filename}"
msgstr ""
msgid "PipelineWizardInputValidation|At least one entry is required"
msgstr ""
msgid "PipelineWizardInputValidation|This field is required"
msgstr ""
msgid "PipelineWizardInputValidation|This value is not valid"
msgstr ""
msgid "PipelineWizardListWidget|add another step"
msgstr ""
msgid "PipelineWizardListWidget|remove step"
msgstr ""
msgid "PipelineWizard|Commit"
msgstr ""
......
import { GlFormGroup, GlFormInputGroup } from '@gitlab/ui';
import { nextTick } from 'vue';
import ListWidget from '~/pipeline_wizard/components/widgets/list.vue';
import { mountExtended, shallowMountExtended } from 'helpers/vue_test_utils_helper';
describe('Pipeline Wizard - List Widget', () => {
const defaultProps = {
label: 'This label',
description: 'some description',
placeholder: 'some placeholder',
pattern: '^[a-z]+$',
invalidFeedback: 'some feedback',
};
let wrapper;
let addStepBtn;
const findGlFormGroup = () => wrapper.findComponent(GlFormGroup);
const findGlFormGroupInvalidFeedback = () => findGlFormGroup().find('.invalid-feedback').text();
const findFirstGlFormInputGroup = () => wrapper.findComponent(GlFormInputGroup);
const findAllGlFormInputGroups = () => wrapper.findAllComponents(GlFormInputGroup);
const findGlFormInputGroupByIndex = (index) => findAllGlFormInputGroups().at(index);
const setValueOnInputField = (value, atIndex = 0) => {
return findGlFormInputGroupByIndex(atIndex).vm.$emit('input', value);
};
const findAddStepButton = () => wrapper.findByTestId('add-step-button');
const addStep = () => findAddStepButton().vm.$emit('click');
const createComponent = (props = {}, mountFn = shallowMountExtended) => {
wrapper = mountFn(ListWidget, {
propsData: {
...defaultProps,
...props,
},
});
addStepBtn = findAddStepButton();
};
describe('component setup and interface', () => {
afterEach(() => {
wrapper.destroy();
});
it('prints the label inside the legend', () => {
createComponent();
expect(findGlFormGroup().attributes('label')).toBe(defaultProps.label);
});
it('prints the description inside the legend', () => {
createComponent();
expect(findGlFormGroup().attributes('labeldescription')).toBe(defaultProps.description);
});
it('sets the input field type attribute to "text"', async () => {
createComponent();
expect(findFirstGlFormInputGroup().attributes('type')).toBe('text');
});
it('passes the placeholder to the first input field', () => {
createComponent();
expect(findFirstGlFormInputGroup().attributes('placeholder')).toBe(defaultProps.placeholder);
});
it('shows a delete button on all fields if there are more than one', async () => {
createComponent({}, mountExtended);
await addStep();
await addStep();
const inputGroups = findAllGlFormInputGroups().wrappers;
expect(inputGroups.length).toBe(3);
inputGroups.forEach((inputGroup) => {
const button = inputGroup.find('[data-testid="remove-step-button"]');
expect(button.find('[data-testid="remove-icon"]').exists()).toBe(true);
expect(button.attributes('aria-label')).toBe('remove step');
});
});
it('null values do not cause an input event', async () => {
createComponent();
await addStep();
expect(wrapper.emitted('input')).toBe(undefined);
});
it('hides the delete button if there is only one', () => {
createComponent({}, mountExtended);
const inputGroups = findAllGlFormInputGroups().wrappers;
expect(inputGroups.length).toBe(1);
expect(wrapper.findByTestId('remove-step-button').exists()).toBe(false);
});
it('shows an "add step" button', () => {
createComponent();
expect(addStepBtn.attributes('icon')).toBe('plus');
expect(addStepBtn.text()).toBe('add another step');
});
it('the "add step" button increases the number of input fields', async () => {
createComponent();
expect(findAllGlFormInputGroups().wrappers.length).toBe(1);
await addStep();
expect(findAllGlFormInputGroups().wrappers.length).toBe(2);
});
it('does not pass the placeholder on subsequent input fields', async () => {
createComponent();
await addStep();
await addStep();
const nullOrUndefined = [null, undefined];
expect(nullOrUndefined).toContain(findAllGlFormInputGroups().at(1).attributes('placeholder'));
expect(nullOrUndefined).toContain(findAllGlFormInputGroups().at(2).attributes('placeholder'));
});
it('emits an update event on input', async () => {
createComponent();
const localValue = 'somevalue';
await setValueOnInputField(localValue);
await nextTick();
expect(wrapper.emitted('input')).toEqual([[[localValue]]]);
});
it('only emits non-null values', async () => {
createComponent();
await addStep();
await addStep();
await setValueOnInputField('abc', 1);
await nextTick();
const events = wrapper.emitted('input');
expect(events.length).toBe(1);
expect(events[0]).toEqual([['abc']]);
});
});
describe('form validation', () => {
afterEach(() => {
wrapper.destroy();
});
it('does not show validation state when untouched', async () => {
createComponent({}, mountExtended);
expect(findGlFormGroup().classes()).not.toContain('is-valid');
expect(findGlFormGroup().classes()).not.toContain('is-invalid');
});
it('shows invalid state on blur', async () => {
createComponent({}, mountExtended);
expect(findGlFormGroup().classes()).not.toContain('is-invalid');
const input = findFirstGlFormInputGroup().find('input');
await input.setValue('invalid99');
await input.trigger('blur');
expect(input.classes()).toContain('is-invalid');
expect(findGlFormGroup().classes()).toContain('is-invalid');
});
it('shows invalid state when toggling `validate` prop', async () => {
createComponent({ required: true, validate: false }, mountExtended);
await setValueOnInputField(null);
expect(findGlFormGroup().classes()).not.toContain('is-invalid');
await wrapper.setProps({ validate: true });
expect(findGlFormGroup().classes()).toContain('is-invalid');
});
it.each`
scenario | required | values | inputFieldClasses | inputGroupClass | feedback
${'shows invalid if all inputs are empty'} | ${true} | ${[null, null]} | ${['is-invalid', null]} | ${'is-invalid'} | ${'At least one entry is required'}
${'is valid if at least one field has a valid entry'} | ${true} | ${[null, 'abc']} | ${[null, 'is-valid']} | ${'is-valid'} | ${expect.anything()}
${'is invalid if one field has an invalid entry'} | ${true} | ${['abc', '99']} | ${['is-valid', 'is-invalid']} | ${'is-invalid'} | ${defaultProps.invalidFeedback}
${'is not invalid if its not required but all values are null'} | ${false} | ${[null, null]} | ${[null, null]} | ${'is-valid'} | ${expect.anything()}
${'is invalid if pattern does not match even if its not required'} | ${false} | ${['99', null]} | ${['is-invalid', null]} | ${'is-invalid'} | ${defaultProps.invalidFeedback}
`('$scenario', async ({ required, values, inputFieldClasses, inputGroupClass, feedback }) => {
createComponent({ required, validate: true }, mountExtended);
await Promise.all(
values.map(async (value, i) => {
if (i > 0) {
await addStep();
}
await setValueOnInputField(value, i);
}),
);
await nextTick();
inputFieldClasses.forEach((expected, i) => {
const inputWrapper = findGlFormInputGroupByIndex(i).find('input');
if (expected === null) {
expect(inputWrapper.classes()).not.toContain('is-valid');
expect(inputWrapper.classes()).not.toContain('is-invalid');
} else {
expect(inputWrapper.classes()).toContain(expected);
}
});
expect(findGlFormGroup().classes()).toContain(inputGroupClass);
expect(findGlFormGroupInvalidFeedback()).toEqual(feedback);
});
});
});
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