diff --git a/frontend/src/components/CommunitySwitch.vue b/frontend/src/components/CommunitySwitch.vue index 8775e8667..b081f5d93 100644 --- a/frontend/src/components/CommunitySwitch.vue +++ b/frontend/src/components/CommunitySwitch.vue @@ -22,7 +22,7 @@ diff --git a/frontend/src/components/Inputs/InputIdentifier.vue b/frontend/src/components/Inputs/InputIdentifier.vue deleted file mode 100644 index 3381e152f..000000000 --- a/frontend/src/components/Inputs/InputIdentifier.vue +++ /dev/null @@ -1,61 +0,0 @@ - - diff --git a/frontend/src/components/Inputs/InputTextarea.spec.js b/frontend/src/components/Inputs/InputTextarea.spec.js deleted file mode 100644 index dc04b5b63..000000000 --- a/frontend/src/components/Inputs/InputTextarea.spec.js +++ /dev/null @@ -1,125 +0,0 @@ -import { mount } from '@vue/test-utils' -import { describe, it, expect, beforeEach, vi } from 'vitest' -import InputTextarea from './InputTextarea' -import { useField } from 'vee-validate' -import { BFormGroup, BFormInvalidFeedback, BFormTextarea } from 'bootstrap-vue-next' - -vi.mock('vee-validate', () => ({ - useField: vi.fn(), -})) - -vi.mock('vue-i18n', () => ({ - useI18n: () => ({ - t: (key) => key, - }), -})) - -describe('InputTextarea', () => { - let wrapper - - const createWrapper = (props = {}) => { - return mount(InputTextarea, { - props: { - rules: {}, - name: 'input-field-name', - label: 'input-field-label', - placeholder: 'input-field-placeholder', - ...props, - }, - global: { - components: { - BFormGroup, - BFormTextarea, - BFormInvalidFeedback, - }, - }, - }) - } - - beforeEach(() => { - vi.mocked(useField).mockReturnValue({ - value: '', - errorMessage: '', - meta: { valid: true }, - }) - wrapper = createWrapper() - }) - - it('renders the component InputTextarea', () => { - expect(wrapper.find('[data-test="input-textarea"]').exists()).toBe(true) - }) - - it('has a textarea field', () => { - expect(wrapper.findComponent({ name: 'BFormTextarea' }).exists()).toBe(true) - }) - - describe('properties', () => { - it('has the correct id', () => { - const textarea = wrapper.findComponent({ name: 'BFormTextarea' }) - expect(textarea.attributes('id')).toBe('input-field-name-input-field') - }) - - it('has the correct placeholder', () => { - const textarea = wrapper.findComponent({ name: 'BFormTextarea' }) - expect(textarea.attributes('placeholder')).toBe('input-field-placeholder') - }) - - it('has the correct label', () => { - const label = wrapper.find('label') - expect(label.text()).toBe('input-field-label') - }) - - it('has the correct label-for attribute', () => { - const label = wrapper.find('label') - expect(label.attributes('for')).toBe('input-field-name-input-field') - }) - }) - - describe('input value changes', () => { - it('updates the model value when input changes', async () => { - const wrapper = mount(InputTextarea, { - props: { - rules: {}, - name: 'input-field-name', - label: 'input-field-label', - placeholder: 'input-field-placeholder', - }, - global: { - components: { - BFormGroup, - BFormInvalidFeedback, - BFormTextarea, - }, - }, - }) - - const textarea = wrapper.find('textarea') - await textarea.setValue('New Text') - - expect(wrapper.vm.currentValue).toBe('New Text') - }) - }) - - describe('disabled state', () => { - it('disables the textarea when disabled prop is true', async () => { - await wrapper.setProps({ disabled: true }) - const textarea = wrapper.findComponent({ name: 'BFormTextarea' }) - expect(textarea.attributes('disabled')).toBeDefined() - }) - }) - - it('shows error message when there is an error', async () => { - vi.mocked(useField).mockReturnValue({ - value: '', - errorMessage: 'This field is required', - meta: { valid: false }, - }) - - wrapper = createWrapper() - await wrapper.vm.$nextTick() - - const errorFeedback = wrapper.findComponent({ name: 'BFormInvalidFeedback' }) - expect(errorFeedback.exists()).toBe(true) - expect(errorFeedback.text()).toBe('This field is required') - }) -}) diff --git a/frontend/src/components/Inputs/InputTextarea.vue b/frontend/src/components/Inputs/InputTextarea.vue deleted file mode 100644 index e44179fc0..000000000 --- a/frontend/src/components/Inputs/InputTextarea.vue +++ /dev/null @@ -1,64 +0,0 @@ - - - - - diff --git a/frontend/src/components/Inputs/ValidatedInput.spec.js b/frontend/src/components/Inputs/ValidatedInput.spec.js new file mode 100644 index 000000000..1e82dac74 --- /dev/null +++ b/frontend/src/components/Inputs/ValidatedInput.spec.js @@ -0,0 +1,92 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest' +import { mount } from '@vue/test-utils' +import ValidatedInput from '@/components/Inputs/ValidatedInput.vue' +import * as yup from 'yup' +import { BFormInvalidFeedback, BFormInput, BFormTextarea, BFormGroup } from 'bootstrap-vue-next' +import LabeledInput from '@/components/Inputs/LabeledInput.vue' + +vi.mock('vue-i18n', () => ({ + useI18n: () => ({ + t: (key) => key, + n: (n) => String(n), + }), +})) + +describe('ValidatedInput', () => { + let wrapper + const createWrapper = (props = {}) => + mount(ValidatedInput, { + props: { + label: 'Test Label', + modelValue: '', + name: 'testInput', + rules: yup.string().required().min(3).default(''), + ...props, + }, + global: { + mocks: { + $t: (key) => key, + $i18n: { + locale: 'en', + }, + $n: (n) => String(n), + }, + components: { + BFormInvalidFeedback, + BFormInput, + BFormTextarea, + BFormGroup, + LabeledInput, + }, + }, + }) + + beforeEach(() => { + wrapper = createWrapper() + }) + + it('renders the label and input', () => { + expect(wrapper.text()).toContain('Test Label') + const input = wrapper.find('input') + expect(input.exists()).toBe(true) + }) + + it('starts with neutral validation state', () => { + const input = wrapper.find('input') + expect(input.classes()).not.toContain('is-valid') + expect(input.classes()).not.toContain('is-invalid') + }) + + it('shows green border when value is valid before blur', async () => { + await wrapper.setProps({ modelValue: 'validInput' }) + await wrapper.vm.$nextTick() + const input = wrapper.find('input') + expect(input.classes()).toContain('is-valid') + expect(input.classes()).not.toContain('is-invalid') + }) + + it('does not show red border before blur even if invalid', async () => { + await wrapper.setProps({ modelValue: 'a' }) + const input = wrapper.find('input') + expect(input.classes()).not.toContain('is-invalid') + }) + + it('shows red border and error message after blur when input is invalid', async () => { + await wrapper.setProps({ modelValue: 'a' }) + const input = wrapper.find('input') + await input.trigger('blur') + await wrapper.vm.$nextTick() + expect(input.classes()).toContain('is-invalid') + expect(wrapper.text()).toContain('this must be at least 3 characters') + }) + + it('emits update:modelValue on input', async () => { + const input = wrapper.find('input') + await input.setValue('hello') + await wrapper.vm.$nextTick() + expect(wrapper.emitted()['update:modelValue']).toBeTruthy() + const [value, name] = wrapper.emitted()['update:modelValue'][0] + expect(value).toBe('hello') + expect(name).toBe('testInput') + }) +}) diff --git a/frontend/src/components/Inputs/ValidatedInput.vue b/frontend/src/components/Inputs/ValidatedInput.vue index 04c346292..6eaba5681 100644 --- a/frontend/src/components/Inputs/ValidatedInput.vue +++ b/frontend/src/components/Inputs/ValidatedInput.vue @@ -9,7 +9,8 @@ :required="!isOptional" :label="label" :name="name" - :state="valid" + :state="smartValidState" + @blur="afterFirstInput = true" @update:modelValue="updateValue" > @@ -19,7 +20,7 @@ + + diff --git a/frontend/src/locales/de.json b/frontend/src/locales/de.json index 8b441b982..c9609fb34 100644 --- a/frontend/src/locales/de.json +++ b/frontend/src/locales/de.json @@ -203,22 +203,43 @@ "username": "Benutzername", "username-placeholder": "Wähle deinen Benutzernamen", "validation": { - "gddCreationTime": { - "min": "Die Stunden sollten mindestens {min} groß sein", - "max": "Die Stunden sollten höchstens {max} groß sein", - "decimal-places": "Die Stunden sollten maximal zwei Nachkommastellen enthalten" + "amount": { + "min": "Der Betrag sollte mindestens {min} groß sein.", + "max": "Der Betrag sollte höchstens {max} groß sein.", + "decimal-places": "Der Betrag sollte maximal zwei Nachkommastellen enthalten.", + "typeError": "Der Betrag sollte eine Zahl zwischen {min} und {max} mit höchstens zwei Nachkommastellen sein." }, - "gddSendAmount": "Das Feld {_field_} muss eine Zahl zwischen {min} und {max} mit höchstens zwei Nachkommastellen sein", - "is-not": "Du kannst dir selbst keine Gradidos überweisen", + "contributionDate": { + "required": "Das Beitragsdatum ist ein Pflichtfeld.", + "min": "Das Frühste Beitragsdatum ist {min}.", + "max": "Das Späteste Beitragsdatum ist heute, der {max}." + }, + "contributionMemo": { + "min": "Die Tätigkeitsbeschreibung sollte mindestens {min} Zeichen lang sein.", + "max": "Die Tätigkeitsbeschreibung sollte höchstens {max} Zeichen lang sein.", + "required": "Die Tätigkeitsbeschreibung ist ein Pflichtfeld." + }, + "hours": { + "min": "Die Stunden sollten mindestens {min} groß sein.", + "max": "Die Stunden sollten höchstens {max} groß sein.", + "decimal-places": "Die Stunden sollten maximal zwei Nachkommastellen enthalten.", + "typeError": "Die Stunden sollten eine Zahl zwischen {min} und {max} mit höchstens zwei Nachkommastellen sein." + }, + "gddSendAmount": "Das Feld {_field_} muss eine Zahl zwischen {min} und {max} mit höchstens zwei Nachkommastellen sein.", + "identifier": { + "required": "Der Empfänger ist ein Pflichtfeld.", + "typeError": "Der Empfänger muss eine Email, ein Nutzernamen oder eine Gradido ID sein." + }, + "is-not": "Du kannst dir selbst keine Gradidos überweisen!", "memo": { - "min": "Die Tätigkeitsbeschreibung sollte mindestens {min} Zeichen lang sein", - "max": "Die Tätigkeitsbeschreibung sollte höchstens {max} Zeichen lang sein" + "min": "Die Nachricht sollte mindestens {min} Zeichen lang sein.", + "max": "Die Nachricht sollte höchstens {max} Zeichen lang sein.", + "required": "Die Nachricht ist ein Pflichtfeld." }, "requiredField": "{fieldName} ist ein Pflichtfeld", "username-allowed-chars": "Der Nutzername darf nur aus Buchstaben (ohne Umlaute), Zahlen, Binde- oder Unterstrichen bestehen.", "username-hyphens": "Binde- oder Unterstriche müssen zwischen Buchstaben oder Zahlen stehen.", - "username-unique": "Der Nutzername ist bereits vergeben.", - "valid-identifier": "Muss eine Email, ein Nutzernamen oder eine gradido ID sein." + "username-unique": "Der Nutzername ist bereits vergeben." }, "your_amount": "Dein Betrag" }, diff --git a/frontend/src/locales/en.json b/frontend/src/locales/en.json index ca4877682..67abc2fe7 100644 --- a/frontend/src/locales/en.json +++ b/frontend/src/locales/en.json @@ -203,22 +203,43 @@ "username": "Username", "username-placeholder": "Choose your username", "validation": { - "gddCreationTime": { - "min": "The hours should be at least {min} in size", - "max": "The hours should not be larger than {max}", - "decimal-places": "The hours should contain a maximum of two decimal places" + "amount": { + "min": "The amount should be at least {min} in size.", + "max": "The amount should not be larger than {max}.", + "decimal-places": "The amount should contain a maximum of two decimal places.", + "typeError": "The amount should be a number between {min} and {max} with at most two digits after the decimal point." }, - "gddSendAmount": "The {_field_} field must be a number between {min} and {max} with at most two digits after the decimal point", - "is-not": "You cannot send Gradidos to yourself", + "contributionDate": { + "required": "The contribution date is a required field.", + "min": "The earliest contribution date is {min}.", + "max": "The latest contribution date is today, {max}." + }, + "contributionMemo": { + "min": "The job description should be at least {min} characters long.", + "max": "The job description should not be longer than {max} characters.", + "required": "The job description is required." + }, + "hours": { + "min": "The hours should be at least {min} in size.", + "max": "The hours should not be larger than {max}.", + "decimal-places": "The hours should contain a maximum of two decimal places.", + "typeError": "The hours should be a number between {min} and {max} with at most two digits after the decimal point." + }, + "gddSendAmount": "The {_field_} field must be a number between {min} and {max} with at most two digits after the decimal point.", + "identifier": { + "required": "The recipient is a required field.", + "typeError": "The recipient must be an email, a username or a Gradido ID." + }, + "is-not": "You cannot send Gradidos to yourself!", "memo": { - "min": "The job description should be at least {min} characters long", - "max": "The job description should not be longer than {max} characters" + "min": "The message should be at least {min} characters long.", + "max": "The message should not be longer than {max} characters.", + "required": "The message is required." }, "requiredField": "The {fieldName} field is required", "username-allowed-chars": "The username may only contain letters, numbers, hyphens or underscores.", "username-hyphens": "Hyphens or underscores must be in between letters or numbers.", - "username-unique": "This username is already taken.", - "valid-identifier": "Must be a valid email, username or gradido ID." + "username-unique": "This username is already taken." }, "your_amount": "Your amount" }, diff --git a/frontend/src/validation-rules.js b/frontend/src/validation-rules.js index 793929b3e..104cb4fa0 100644 --- a/frontend/src/validation-rules.js +++ b/frontend/src/validation-rules.js @@ -11,9 +11,7 @@ import nl from '@vee-validate/i18n/dist/locale/nl.json' import tr from '@vee-validate/i18n/dist/locale/tr.json' import { useI18n } from 'vue-i18n' -// Email and username regex patterns remain the same -const EMAIL_REGEX = - /^(([^<>()\[\]\\.,;:\s@"]+(\.[^<>()\[\]\\.,;:\s@"]+)*)|(".+"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/ +// username regex pattern remain the same const USERNAME_REGEX = /^(?=.{3,20}$)[a-zA-Z0-9]+(?:[_-][a-zA-Z0-9]+?)*$/ export const loadAllRules = (i18nCallback, apollo) => { @@ -48,22 +46,6 @@ export const loadAllRules = (i18nCallback, apollo) => { defineRule('max', max) // ------ Custom rules ------ - defineRule('gddSendAmount', (value, { min, max }) => { - value = value.replace(',', '.') - return value.match(/^[0-9]+(\.[0-9]{0,2})?$/) && Number(value) >= min && Number(value) <= max - ? true - : i18nCallback.t('form.validation.gddSendAmount', { - min: i18nCallback.n(min, 'ungroupedDecimal'), - max: i18nCallback.n(max, 'ungroupedDecimal'), - }) - }) - - defineRule('gddCreationTime', (value, { min, max }) => { - return value >= min && value <= max - ? true - : i18nCallback.t('form.validation.gddCreationTime', { min, max }) - }) - defineRule('is_not', (value, [otherValue]) => { return value !== otherValue ? true @@ -122,13 +104,4 @@ export const loadAllRules = (i18nCallback, apollo) => { }) return data.checkUsername || i18nCallback.t('form.validation.username-unique') }) - - defineRule('validIdentifier', (value) => { - const isEmail = !!EMAIL_REGEX.test(value) - const isUsername = !!value.match(USERNAME_REGEX) - const isGradidoId = validateUuid(value) && versionUuid(value) === 4 - return ( - isEmail || isUsername || isGradidoId || i18nCallback.t('form.validation.valid-identifier') - ) - }) } diff --git a/frontend/src/validationSchemas.js b/frontend/src/validationSchemas.js index 01a1ec8ab..fd995d63a 100644 --- a/frontend/src/validationSchemas.js +++ b/frontend/src/validationSchemas.js @@ -1,4 +1,10 @@ import { string } from 'yup' +import { validate as validateUuid, version as versionUuid } from 'uuid' + +// Email and username regex patterns remain the same +const EMAIL_REGEX = + /^(([^<>()\[\]\\.,;:\s@"]+(\.[^<>()\[\]\\.,;:\s@"]+)*)|(".+"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/ +const USERNAME_REGEX = /^(?=.{3,20}$)[a-zA-Z0-9]+(?:[_-][a-zA-Z0-9]+?)*$/ // TODO: only needed for grace period, before all inputs updated for using veeValidate + yup export const isLanguageKey = (str) => @@ -16,6 +22,16 @@ export const translateYupErrorString = (error, t) => { } export const memo = string() - .required('contribution.yourActivity') + .required('form.validation.memo.required') .min(5, ({ min }) => ({ key: 'form.validation.memo.min', values: { min } })) .max(255, ({ max }) => ({ key: 'form.validation.memo.max', values: { max } })) + +export const identifier = string() + .required('form.validation.identifier.required') + .test('valid-identifier', 'form.validation.identifier.typeError', (value) => { + const isEmail = !!EMAIL_REGEX.test(value) + const isUsername = !!value.match(USERNAME_REGEX) + // TODO: use valibot and rules from shared + const isGradidoId = validateUuid(value) && versionUuid(value) === 4 + return isEmail || isUsername || isGradidoId + })