diff --git a/app/assets/javascripts/pipeline_wizard/components/widgets/text.vue b/app/assets/javascripts/pipeline_wizard/components/widgets/text.vue new file mode 100644 index 0000000000000000000000000000000000000000..26235b20ce97d54a3c2a2238717e43dd19ba4fdb --- /dev/null +++ b/app/assets/javascripts/pipeline_wizard/components/widgets/text.vue @@ -0,0 +1,126 @@ +<script> +import { GlFormGroup, GlFormInput } from '@gitlab/ui'; +import { uniqueId } from 'lodash'; +import { s__ } from '~/locale'; + +const VALIDATION_STATE = { + NO_VALIDATION: null, + INVALID: false, + VALID: true, +}; + +export default { + name: 'TextWidget', + components: { + GlFormGroup, + GlFormInput, + }, + props: { + label: { + type: String, + required: true, + }, + description: { + type: String, + required: false, + default: null, + }, + placeholder: { + type: String, + required: false, + default: null, + }, + invalidFeedback: { + type: String, + required: false, + default: s__('PipelineWizardInputValidation|This value is not valid'), + }, + id: { + type: String, + required: false, + default: () => uniqueId('textWidget-'), + }, + pattern: { + type: String, + required: false, + default: null, + }, + validate: { + type: Boolean, + required: false, + default: false, + }, + required: { + type: Boolean, + required: false, + default: false, + }, + default: { + type: String, + required: false, + default: null, + }, + }, + data() { + return { + touched: false, + value: this.default, + }; + }, + computed: { + validationState() { + if (!this.showValidationState) return VALIDATION_STATE.NO_VALIDATION; + if (this.isRequiredButEmpty) return VALIDATION_STATE.INVALID; + return this.needsValidationAndPasses ? VALIDATION_STATE.VALID : VALIDATION_STATE.INVALID; + }, + showValidationState() { + return this.touched || this.validate; + }, + isRequiredButEmpty() { + return this.required && !this.value; + }, + needsValidationAndPasses() { + return !this.pattern || new RegExp(this.pattern).test(this.value); + }, + invalidFeedbackMessage() { + return this.isRequiredButEmpty + ? s__('PipelineWizardInputValidation|This field is required') + : this.invalidFeedback; + }, + }, + watch: { + validationState(v) { + this.$emit('update:valid', v); + }, + value(v) { + this.$emit('input', v.trim()); + }, + }, + created() { + if (this.default) { + this.$emit('input', this.value); + } + }, +}; +</script> + +<template> + <div data-testid="text-widget"> + <gl-form-group + :description="description" + :invalid-feedback="invalidFeedbackMessage" + :label="label" + :label-for="id" + :state="validationState" + > + <gl-form-input + :id="id" + v-model="value" + :placeholder="placeholder" + :state="validationState" + type="text" + @blur="touched = true" + /> + </gl-form-group> + </div> +</template> diff --git a/locale/gitlab.pot b/locale/gitlab.pot index 371e0649c5d4aab8f24084d4d54e8c0407f5a0b6..c3642ec7270b7f97ec61cd5f43074363ba00f060 100644 --- a/locale/gitlab.pot +++ b/locale/gitlab.pot @@ -26577,6 +26577,12 @@ msgstr "" msgid "PipelineStatusTooltip|Pipeline: %{ci_status}" msgstr "" +msgid "PipelineWizardInputValidation|This field is required" +msgstr "" + +msgid "PipelineWizardInputValidation|This value is not valid" +msgstr "" + msgid "Pipelines" msgstr "" diff --git a/spec/frontend/pipeline_wizard/components/widgets/text_spec.js b/spec/frontend/pipeline_wizard/components/widgets/text_spec.js new file mode 100644 index 0000000000000000000000000000000000000000..a11c0214d156f32a87fc4b6ca280d02eca05a59b --- /dev/null +++ b/spec/frontend/pipeline_wizard/components/widgets/text_spec.js @@ -0,0 +1,152 @@ +import { shallowMount } from '@vue/test-utils'; +import { GlFormGroup, GlFormInput } from '@gitlab/ui'; +import TextWidget from '~/pipeline_wizard/components/widgets/text.vue'; +import { mountExtended } from 'helpers/vue_test_utils_helper'; + +describe('Pipeline Wizard - Text Widget', () => { + const defaultProps = { + label: 'This label', + description: 'some description', + placeholder: 'some placeholder', + pattern: '^[a-z]+$', + invalidFeedback: 'some feedback', + }; + + let wrapper; + + const findGlFormGroup = () => wrapper.findComponent(GlFormGroup); + const findGlFormGroupInvalidFeedback = () => findGlFormGroup().find('.invalid-feedback'); + const findGlFormInput = () => wrapper.findComponent(GlFormInput); + + const createComponent = (props = {}, mountFn = mountExtended) => { + wrapper = mountFn(TextWidget, { + propsData: { + ...defaultProps, + ...props, + }, + }); + }; + + afterEach(() => { + if (wrapper) { + wrapper.destroy(); + } + }); + + it('creates an input element with the correct label', () => { + createComponent(); + + expect(wrapper.findByLabelText(defaultProps.label).exists()).toBe(true); + }); + + it('passes the description', () => { + createComponent({}, shallowMount); + + expect(findGlFormGroup().attributes('description')).toBe(defaultProps.description); + }); + + it('sets the "text" type on the input component', () => { + createComponent(); + + expect(findGlFormInput().attributes('type')).toBe('text'); + }); + + it('passes the placeholder', () => { + createComponent(); + + expect(findGlFormInput().attributes('placeholder')).toBe(defaultProps.placeholder); + }); + + it('emits an update event on input', async () => { + createComponent(); + + const localValue = 'somevalue'; + await findGlFormInput().setValue(localValue); + + expect(wrapper.emitted('input')).toEqual([[localValue]]); + }); + + it('passes invalid feedback message', () => { + createComponent(); + + expect(findGlFormGroupInvalidFeedback().text()).toBe(defaultProps.invalidFeedback); + }); + + it('provides invalid feedback', async () => { + createComponent({ validate: true }); + + await findGlFormInput().setValue('invalid%99'); + + expect(findGlFormGroup().classes()).toContain('is-invalid'); + expect(findGlFormInput().classes()).toContain('is-invalid'); + }); + + it('provides valid feedback', async () => { + createComponent({ validate: true }); + + await findGlFormInput().setValue('valid'); + + expect(findGlFormGroup().classes()).toContain('is-valid'); + expect(findGlFormInput().classes()).toContain('is-valid'); + }); + + it('does not show validation state when untouched', () => { + createComponent({ value: 'invalid99' }); + + expect(findGlFormGroup().classes()).not.toContain('is-valid'); + expect(findGlFormGroup().classes()).not.toContain('is-invalid'); + }); + + it('shows invalid state on blur', async () => { + createComponent(); + + await findGlFormInput().setValue('invalid%99'); + + expect(findGlFormGroup().classes()).not.toContain('is-invalid'); + + await findGlFormInput().trigger('blur'); + + expect(findGlFormInput().classes()).toContain('is-invalid'); + expect(findGlFormGroup().classes()).toContain('is-invalid'); + }); + + it('shows invalid state when toggling `validate` prop', async () => { + createComponent({ + required: true, + validate: false, + }); + + expect(findGlFormGroup().classes()).not.toContain('is-invalid'); + + await wrapper.setProps({ validate: true }); + + expect(findGlFormGroup().classes()).toContain('is-invalid'); + }); + + it('does not update validation if not required', async () => { + createComponent({ + pattern: null, + validate: true, + }); + + expect(findGlFormGroup().classes()).not.toContain('is-invalid'); + }); + + it('sets default value', () => { + const defaultValue = 'foo'; + createComponent({ + default: defaultValue, + }); + + expect(wrapper.findByLabelText(defaultProps.label).element.value).toBe(defaultValue); + }); + + it('emits default value on setup', () => { + const defaultValue = 'foo'; + createComponent({ + default: defaultValue, + }); + + expect(wrapper.emitted('input')).toEqual([[defaultValue]]); + }); +});