From 3b218a8da3ccb32e0e5846068aa6727fe3cade20 Mon Sep 17 00:00:00 2001 From: einhornimmond Date: Thu, 24 Jul 2025 15:59:09 +0200 Subject: [PATCH 1/9] refactor TransactionForm, remove not longer needed components --- frontend/src/components/CommunitySwitch.vue | 5 +- .../Contributions/ContributionForm.spec.js | 15 --- .../GddSend/TransactionForm.spec.js | 79 +++++------ .../components/GddSend/TransactionForm.vue | 101 +++++++++----- .../src/components/Inputs/InputAmount.spec.js | 125 ------------------ .../src/components/Inputs/InputAmount.vue | 88 ------------ .../src/components/Inputs/InputIdentifier.vue | 61 --------- .../components/Inputs/InputTextarea.spec.js | 125 ------------------ .../src/components/Inputs/InputTextarea.vue | 64 --------- frontend/src/locales/de.json | 5 + frontend/src/locales/en.json | 5 + frontend/src/validationSchemas.js | 18 +++ 12 files changed, 130 insertions(+), 561 deletions(-) delete mode 100644 frontend/src/components/Inputs/InputAmount.spec.js delete mode 100644 frontend/src/components/Inputs/InputAmount.vue delete mode 100644 frontend/src/components/Inputs/InputIdentifier.vue delete mode 100644 frontend/src/components/Inputs/InputTextarea.spec.js delete mode 100644 frontend/src/components/Inputs/InputTextarea.vue 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/locales/de.json b/frontend/src/locales/de.json index 8b441b982..a94099015 100644 --- a/frontend/src/locales/de.json +++ b/frontend/src/locales/de.json @@ -203,6 +203,11 @@ "username": "Benutzername", "username-placeholder": "Wähle deinen Benutzernamen", "validation": { + "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" + }, "gddCreationTime": { "min": "Die Stunden sollten mindestens {min} groß sein", "max": "Die Stunden sollten höchstens {max} groß sein", diff --git a/frontend/src/locales/en.json b/frontend/src/locales/en.json index ca4877682..ec26e866d 100644 --- a/frontend/src/locales/en.json +++ b/frontend/src/locales/en.json @@ -203,6 +203,11 @@ "username": "Username", "username-placeholder": "Choose your username", "validation": { + "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" + }, "gddCreationTime": { "min": "The hours should be at least {min} in size", "max": "The hours should not be larger than {max}", diff --git a/frontend/src/validationSchemas.js b/frontend/src/validationSchemas.js index 01a1ec8ab..9c21aec66 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) => @@ -19,3 +25,15 @@ export const memo = string() .required('contribution.yourActivity') .min(5, ({ min }) => ({ key: 'form.validation.memo.min', values: { min } })) .max(255, ({ max }) => ({ key: 'form.validation.memo.max', values: { max } })) + +export const identifier = string().test( + 'valid-identifier', + 'form.validation.valid-identifier', + (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 + }, +) From 7f5569439dc8c80af46dae44a6bf51c9708a987f Mon Sep 17 00:00:00 2001 From: einhornimmond Date: Thu, 24 Jul 2025 16:09:57 +0200 Subject: [PATCH 2/9] remove some rules, which are now defined with yup --- frontend/src/validation-rules.js | 29 +---------------------------- 1 file changed, 1 insertion(+), 28 deletions(-) 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') - ) - }) } From f1052409c0945d076df1345d0294e320c7da9250 Mon Sep 17 00:00:00 2001 From: einhornimmond Date: Fri, 25 Jul 2025 12:29:43 +0200 Subject: [PATCH 3/9] make validation smart, show invalid after first onBlur --- .../components/GddSend/TransactionForm.vue | 2 +- .../src/components/Inputs/ValidatedInput.vue | 40 +++++++++++++++++-- 2 files changed, 38 insertions(+), 4 deletions(-) diff --git a/frontend/src/components/GddSend/TransactionForm.vue b/frontend/src/components/GddSend/TransactionForm.vue index 5ce9b6e7f..4bf9d7229 100644 --- a/frontend/src/components/GddSend/TransactionForm.vue +++ b/frontend/src/components/GddSend/TransactionForm.vue @@ -178,7 +178,7 @@ const validationSchema = computed(() => { amount: number() .required() .transform((value, originalValue) => { - if (typeof originalValue === 'string' && originalValue !== '') { + if (typeof originalValue === 'string') { return Number(originalValue.replace(',', '.')) } return value diff --git a/frontend/src/components/Inputs/ValidatedInput.vue b/frontend/src/components/Inputs/ValidatedInput.vue index 04c346292..8f4601124 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 @@ + + From 91fa52b7b6ebf88a579866ea9a4a1f90745e2633 Mon Sep 17 00:00:00 2001 From: einhornimmond Date: Fri, 25 Jul 2025 13:32:46 +0200 Subject: [PATCH 4/9] add test for ValidatedInput --- .../components/Inputs/ValidatedInput.spec.js | 92 +++++++++++++++++++ .../src/components/Inputs/ValidatedInput.vue | 6 +- 2 files changed, 95 insertions(+), 3 deletions(-) create mode 100644 frontend/src/components/Inputs/ValidatedInput.spec.js 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 8f4601124..bbd998635 100644 --- a/frontend/src/components/Inputs/ValidatedInput.vue +++ b/frontend/src/components/Inputs/ValidatedInput.vue @@ -48,7 +48,7 @@ const model = ref(props.modelValue !== 0 ? props.modelValue : '') // prevent showing errors on form init const afterFirstInput = ref(false) -const valid = computed(() => props.rules.isValidSync(props.modelValue)) +const valid = computed(() => props.rules.isValidSync(model.value)) // smartValidState controls the visual validation feedback for the input field. // The goal is to avoid showing red (invalid) borders too early, creating a smoother UX: // @@ -67,11 +67,11 @@ const smartValidState = computed(() => { return valid.value ? true : null }) const errorMessage = computed(() => { - if (props.modelValue === undefined || props.modelValue === '' || props.modelValue === null) { + if (model.value === undefined || model.value === '' || model.value === null) { return undefined } try { - props.rules.validateSync(props.modelValue) + props.rules.validateSync(model.value) return undefined } catch (e) { return translateYupErrorString(e.message, t) From 14e596c8a1be5fab765a41647e9c4390233f0116 Mon Sep 17 00:00:00 2001 From: einhornimmond Date: Fri, 25 Jul 2025 13:50:24 +0200 Subject: [PATCH 5/9] trigger validation on hover above submit button --- .../components/Contributions/ContributionForm.vue | 6 +++++- frontend/src/components/GddSend/TransactionForm.vue | 12 +++++++++++- frontend/src/components/Inputs/ValidatedInput.vue | 6 +++++- 3 files changed, 21 insertions(+), 3 deletions(-) diff --git a/frontend/src/components/Contributions/ContributionForm.vue b/frontend/src/components/Contributions/ContributionForm.vue index cb2a31c09..0fc0fa05d 100644 --- a/frontend/src/components/Contributions/ContributionForm.vue +++ b/frontend/src/components/Contributions/ContributionForm.vue @@ -19,6 +19,7 @@ class="mb-4 bg-248" type="date" :rules="validationSchema.fields.contributionDate" + :disable-smart-valid-state="disableSmartValidState" @update:model-value="updateField" />
@@ -33,6 +34,7 @@ :placeholder="$t('contribution.yourActivity')" :rules="validationSchema.fields.memo" textarea="true" + :disable-smart-valid-state="disableSmartValidState" @update:model-value="updateField" /> - + ({ const form = reactive({ ...entityDataToForm.value }) const now = ref(new Date()) // checked every minute, updated if day, month or year changed +const disableSmartValidState = ref(false) const isThisMonth = computed(() => { const formContributionDate = new Date(form.contributionDate) diff --git a/frontend/src/components/GddSend/TransactionForm.vue b/frontend/src/components/GddSend/TransactionForm.vue index 4bf9d7229..c612b24be 100644 --- a/frontend/src/components/GddSend/TransactionForm.vue +++ b/frontend/src/components/GddSend/TransactionForm.vue @@ -63,6 +63,7 @@ :placeholder="$t('form.identifier')" :rules="validationSchema.fields.identifier" :disabled="isBalanceEmpty" + :disable-smart-valid-state="disableSmartValidState" @update:model-value="updateField" />
@@ -84,6 +85,7 @@ :placeholder="'0.01'" :rules="validationSchema.fields.amount" :disabled="isBalanceEmpty" + :disable-smart-valid-state="disableSmartValidState" @update:model-value="updateField" /> @@ -102,6 +104,7 @@ :rules="validationSchema.fields.memo" textarea="true" :disabled="isBalanceEmpty" + :disable-smart-valid-state="disableSmartValidState" @update:model-value="updateField" /> @@ -121,7 +124,13 @@ {{ $t('form.reset') }} - + {{ $t('form.check_now') }} @@ -161,6 +170,7 @@ const props = defineProps({ const entityDataToForm = computed(() => ({ ...props })) const form = reactive({ ...entityDataToForm.value }) +const disableSmartValidState = ref(false) const emit = defineEmits(['set-transaction']) diff --git a/frontend/src/components/Inputs/ValidatedInput.vue b/frontend/src/components/Inputs/ValidatedInput.vue index bbd998635..67362131d 100644 --- a/frontend/src/components/Inputs/ValidatedInput.vue +++ b/frontend/src/components/Inputs/ValidatedInput.vue @@ -39,6 +39,10 @@ const props = defineProps({ type: Object, required: true, }, + disableSmartValidState: { + type: Boolean, + default: false, + }, }) const { t } = useI18n() @@ -61,7 +65,7 @@ const valid = computed(() => props.rules.isValidSync(model.value)) // After first blur: // - show true or false according to the validation result const smartValidState = computed(() => { - if (afterFirstInput.value) { + if (afterFirstInput.value || props.disableSmartValidState) { return valid.value } return valid.value ? true : null From 1c06a9cff643dd9c55c84995fd3fabfe072812ca Mon Sep 17 00:00:00 2001 From: einhornimmond Date: Fri, 25 Jul 2025 14:49:18 +0200 Subject: [PATCH 6/9] use correct validation error messages for contribution and send memo --- .../Contributions/ContributionForm.spec.js | 1 + .../Contributions/ContributionForm.vue | 33 +++++++++++------ .../components/GddSend/TransactionForm.vue | 8 +++- .../src/components/Inputs/ValidatedInput.vue | 3 -- frontend/src/locales/de.json | 37 +++++++++++++------ frontend/src/locales/en.json | 35 ++++++++++++------ frontend/src/validationSchemas.js | 2 +- 7 files changed, 79 insertions(+), 40 deletions(-) diff --git a/frontend/src/components/Contributions/ContributionForm.spec.js b/frontend/src/components/Contributions/ContributionForm.spec.js index 42ef6d02a..7ef941dc8 100644 --- a/frontend/src/components/Contributions/ContributionForm.spec.js +++ b/frontend/src/components/Contributions/ContributionForm.spec.js @@ -5,6 +5,7 @@ import ContributionForm from './ContributionForm.vue' vi.mock('vue-i18n', () => ({ useI18n: () => ({ t: (key) => key, + d: (date) => date, }), })) diff --git a/frontend/src/components/Contributions/ContributionForm.vue b/frontend/src/components/Contributions/ContributionForm.vue index 0fc0fa05d..f4ea3b7cc 100644 --- a/frontend/src/components/Contributions/ContributionForm.vue +++ b/frontend/src/components/Contributions/ContributionForm.vue @@ -43,7 +43,7 @@ :label="$t('form.hours')" placeholder="0.01" step="0.01" - type="number" + type="text" :rules="validationSchema.fields.hours" :disable-smart-valid-state="disableSmartValidState" @update:model-value="updateField" @@ -92,9 +92,8 @@ import { reactive, computed, ref, onMounted, onUnmounted, toRaw } from 'vue' import { useI18n } from 'vue-i18n' import ValidatedInput from '@/components/Inputs/ValidatedInput' import LabeledInput from '@/components/Inputs/LabeledInput' -import { memo as memoSchema } from '@/validationSchemas' import OpenCreationsAmount from './OpenCreationsAmount.vue' -import { object, date as dateSchema, number } from 'yup' +import { object, date as dateSchema, number, string } from 'yup' import { GDD_PER_HOUR } from '../../constants' const amountToHours = (amount) => parseFloat(amount / GDD_PER_HOUR).toFixed(2) @@ -108,7 +107,7 @@ const props = defineProps({ const emit = defineEmits(['upsert-contribution', 'abort']) -const { t } = useI18n() +const { t, d } = useI18n() const entityDataToForm = computed(() => ({ ...props.modelValue, @@ -151,16 +150,26 @@ const validationSchema = computed(() => { // The date field is required and needs to be a valid date // contribution date contributionDate: dateSchema() - .required() - .min(minimalDate.value.toISOString().slice(0, 10)) // min date is first day of last month - .max(now.value.toISOString().slice(0, 10)), // date cannot be in the future - memo: memoSchema, + .required('form.validation.contributionDate.required') + .min(minimalDate.value.toISOString().slice(0, 10), ({ min }) => ({ + key: 'form.validation.contributionDate.min', + values: { min: d(min) }, + })) // min date is first day of last month + .max(now.value.toISOString().slice(0, 10), ({ max }) => ({ + key: 'form.validation.contributionDate.max', + values: { max: d(max) }, + })), // date cannot be in the future + memo: string() + .min(5, ({ min }) => ({ key: 'form.validation.contributionMemo.min', values: { min } })) + .max(255, ({ max }) => ({ key: 'form.validation.contributionMemo.max', values: { max } })) + .required('form.validation.contributionMemo.required'), hours: number() + .typeError({ key: 'form.validation.hours.typeError', values: { min: 0.01, max: maxHours } }) .required() - .transform((value, originalValue) => (originalValue === '' ? undefined : value)) - .min(0.01, ({ min }) => ({ key: 'form.validation.gddCreationTime.min', values: { min } })) - .max(maxHours, ({ max }) => ({ key: 'form.validation.gddCreationTime.max', values: { max } })) - .test('decimal-places', 'form.validation.gddCreationTime.decimal-places', (value) => { + // .transform((value, originalValue) => (originalValue === '' ? undefined : value)) + .min(0.01, ({ min }) => ({ key: 'form.validation.hours.min', values: { min } })) + .max(maxHours, ({ max }) => ({ key: 'form.validation.hours.max', values: { max } })) + .test('decimal-places', 'form.validation.hours.decimal-places', (value) => { if (value === undefined || value === null) return true return /^\d+(\.\d{0,2})?$/.test(value.toString()) }), diff --git a/frontend/src/components/GddSend/TransactionForm.vue b/frontend/src/components/GddSend/TransactionForm.vue index c612b24be..510ba3786 100644 --- a/frontend/src/components/GddSend/TransactionForm.vue +++ b/frontend/src/components/GddSend/TransactionForm.vue @@ -184,9 +184,15 @@ const userName = ref('') const validationSchema = computed(() => { return object({ memo: memoSchema, - identifier: !userIdentifier.value ? identifierSchema.required() : identifierSchema, + identifier: !userIdentifier.value + ? identifierSchema.required('form.validation.identifier.required') + : identifierSchema, amount: number() .required() + .typeError({ + key: 'form.validation.amount.typeError', + values: { min: 0.01, max: props.balance }, + }) .transform((value, originalValue) => { if (typeof originalValue === 'string') { return Number(originalValue.replace(',', '.')) diff --git a/frontend/src/components/Inputs/ValidatedInput.vue b/frontend/src/components/Inputs/ValidatedInput.vue index 67362131d..6eaba5681 100644 --- a/frontend/src/components/Inputs/ValidatedInput.vue +++ b/frontend/src/components/Inputs/ValidatedInput.vue @@ -71,9 +71,6 @@ const smartValidState = computed(() => { return valid.value ? true : null }) const errorMessage = computed(() => { - if (model.value === undefined || model.value === '' || model.value === null) { - return undefined - } try { props.rules.validateSync(model.value) return undefined diff --git a/frontend/src/locales/de.json b/frontend/src/locales/de.json index a94099015..7a68d3819 100644 --- a/frontend/src/locales/de.json +++ b/frontend/src/locales/de.json @@ -204,26 +204,39 @@ "username-placeholder": "Wähle deinen Benutzernamen", "validation": { "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" + "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." }, - "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" + "contributionDate": { + "required": "Das Beitragsdatum ist ein Pflichtfeld.", + "min": "Das Frühste Beitragsdatum ist {min}.", + "max": "Das Späteste Beitragsdatum ist heute, der {max}." }, - "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", + "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.", + "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." + "valid-identifier": "Muss eine Email, ein Nutzernamen oder eine Gradido ID sein." }, "your_amount": "Dein Betrag" }, diff --git a/frontend/src/locales/en.json b/frontend/src/locales/en.json index ec26e866d..69825bd7e 100644 --- a/frontend/src/locales/en.json +++ b/frontend/src/locales/en.json @@ -204,20 +204,33 @@ "username-placeholder": "Choose your username", "validation": { "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" + "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." }, - "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" + "contributionDate": { + "required": "The contribution date is a required field.", + "min": "The earliest contribution date is {min}.", + "max": "The latest contribution date is today, {max}." }, - "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", + "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.", + "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.", diff --git a/frontend/src/validationSchemas.js b/frontend/src/validationSchemas.js index 9c21aec66..eff976c84 100644 --- a/frontend/src/validationSchemas.js +++ b/frontend/src/validationSchemas.js @@ -22,7 +22,7 @@ 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 } })) From 4f52e401d459fd435ec5153b389f132b03cb7f52 Mon Sep 17 00:00:00 2001 From: einhornimmond Date: Fri, 25 Jul 2025 15:19:23 +0200 Subject: [PATCH 7/9] fix link --- .../components/GddSend/TransactionForm.vue | 80 ++++++++++--------- frontend/src/locales/de.json | 7 +- frontend/src/locales/en.json | 7 +- frontend/src/validationSchemas.js | 10 +-- 4 files changed, 58 insertions(+), 46 deletions(-) diff --git a/frontend/src/components/GddSend/TransactionForm.vue b/frontend/src/components/GddSend/TransactionForm.vue index 510ba3786..f37e3171e 100644 --- a/frontend/src/components/GddSend/TransactionForm.vue +++ b/frontend/src/components/GddSend/TransactionForm.vue @@ -130,6 +130,7 @@ lg="6" class="text-lg-end" @mouseover="disableSmartValidState = true" + @mouseleave="debug" > {{ $t('form.check_now') }} @@ -181,41 +182,6 @@ const { toastError } = useAppToast() const radioSelected = ref(props.selected) const userName = ref('') -const validationSchema = computed(() => { - return object({ - memo: memoSchema, - identifier: !userIdentifier.value - ? identifierSchema.required('form.validation.identifier.required') - : identifierSchema, - amount: number() - .required() - .typeError({ - key: 'form.validation.amount.typeError', - values: { min: 0.01, max: props.balance }, - }) - .transform((value, originalValue) => { - if (typeof originalValue === 'string') { - return Number(originalValue.replace(',', '.')) - } - return value - }) - .min(0.01, ({ min }) => ({ key: 'form.validation.amount.min', values: { min } })) - .max(props.balance, ({ max }) => ({ key: 'form.validation.amount.max', values: { max } })) - .test('decimal-places', 'form.validation.amount.decimal-places', (value) => { - if (value === undefined || value === null) return true - return /^\d+(\.\d{0,2})?$/.test(value.toString()) - }), - }) -}) - -const formIsInvalid = computed(() => !validationSchema.value.isValidSync(form)) - -const updateField = (newValue, name) => { - if (typeof name === 'string' && name.length) { - form[name] = newValue - } -} - const userIdentifier = computed(() => { if (route.params.userIdentifier && route.params.communityIdentifier) { return { @@ -226,6 +192,48 @@ const userIdentifier = computed(() => { return null }) +const validationSchema = computed(() => { + const amountSchema = number() + .required() + .typeError({ + key: 'form.validation.amount.typeError', + values: { min: 0.01, max: props.balance }, + }) + .transform((value, originalValue) => { + if (typeof originalValue === 'string') { + return Number(originalValue.replace(',', '.')) + } + return value + }) + .min(0.01, ({ min }) => ({ key: 'form.validation.amount.min', values: { min } })) + .max(props.balance, ({ max }) => ({ key: 'form.validation.amount.max', values: { max } })) + .test('decimal-places', 'form.validation.amount.decimal-places', (value) => { + if (value === undefined || value === null) return true + return /^\d+(\.\d{0,2})?$/.test(value.toString()) + }) + if (!userIdentifier.value && radioSelected.value === SEND_TYPES.send) { + return object({ + memo: memoSchema, + amount: amountSchema, + identifier: identifierSchema, + }) + } else { + // don't need identifier schema if it is a transaction link or identifier was set via url + return object({ + memo: memoSchema, + amount: amountSchema, + }) + } +}) + +const formIsInvalid = computed(() => !validationSchema.value.isValidSync(form)) + +const updateField = (newValue, name) => { + if (typeof name === 'string' && name.length) { + form[name] = newValue + } +} + const isBalanceEmpty = computed(() => props.balance <= 0) const { result: userResult, error: userError } = useQuery( @@ -254,8 +262,8 @@ watch(userError, (error) => { function onSubmit() { const transformedForm = validationSchema.value.cast(form) emit('set-transaction', { - selected: radioSelected.value, ...transformedForm, + selected: radioSelected.value, userName: userName.value, }) } diff --git a/frontend/src/locales/de.json b/frontend/src/locales/de.json index 7a68d3819..c9609fb34 100644 --- a/frontend/src/locales/de.json +++ b/frontend/src/locales/de.json @@ -226,6 +226,10 @@ "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 Nachricht sollte mindestens {min} Zeichen lang sein.", @@ -235,8 +239,7 @@ "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 69825bd7e..67abc2fe7 100644 --- a/frontend/src/locales/en.json +++ b/frontend/src/locales/en.json @@ -226,6 +226,10 @@ "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 message should be at least {min} characters long.", @@ -235,8 +239,7 @@ "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/validationSchemas.js b/frontend/src/validationSchemas.js index eff976c84..fd995d63a 100644 --- a/frontend/src/validationSchemas.js +++ b/frontend/src/validationSchemas.js @@ -26,14 +26,12 @@ export const memo = string() .min(5, ({ min }) => ({ key: 'form.validation.memo.min', values: { min } })) .max(255, ({ max }) => ({ key: 'form.validation.memo.max', values: { max } })) -export const identifier = string().test( - 'valid-identifier', - 'form.validation.valid-identifier', - (value) => { +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 - }, -) + }) From 2768bff37e12aa3a9cf78271ac6c27ec891f354a Mon Sep 17 00:00:00 2001 From: einhornimmond Date: Fri, 25 Jul 2025 15:33:28 +0200 Subject: [PATCH 8/9] fix community switch --- frontend/src/components/GddSend/TransactionForm.vue | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/src/components/GddSend/TransactionForm.vue b/frontend/src/components/GddSend/TransactionForm.vue index f37e3171e..2e94332a2 100644 --- a/frontend/src/components/GddSend/TransactionForm.vue +++ b/frontend/src/components/GddSend/TransactionForm.vue @@ -47,7 +47,7 @@ From 3c064b05d3dfe62c6d400b7e1e95b6ed8b6140ac Mon Sep 17 00:00:00 2001 From: einhornimmond Date: Fri, 25 Jul 2025 15:50:48 +0200 Subject: [PATCH 9/9] remove debug event --- frontend/src/components/GddSend/TransactionForm.vue | 1 - 1 file changed, 1 deletion(-) diff --git a/frontend/src/components/GddSend/TransactionForm.vue b/frontend/src/components/GddSend/TransactionForm.vue index 2e94332a2..8d977b835 100644 --- a/frontend/src/components/GddSend/TransactionForm.vue +++ b/frontend/src/components/GddSend/TransactionForm.vue @@ -130,7 +130,6 @@ lg="6" class="text-lg-end" @mouseover="disableSmartValidState = true" - @mouseleave="debug" > {{ $t('form.check_now') }}