Merge pull request #3519 from gradido/frontend_fix_send_input

refactor(frontend): transaction and contribution form
This commit is contained in:
einhornimmond 2025-07-29 10:52:22 +02:00 committed by GitHub
commit 8db786c13c
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
16 changed files with 372 additions and 636 deletions

View File

@ -22,7 +22,7 @@
</template>
<script setup>
import { ref, computed, watch, onMounted, onUpdated } from 'vue'
import { ref, computed, onMounted } from 'vue'
import { useQuery } from '@vue/apollo-composable'
import { useRoute } from 'vue-router'
import { selectCommunities } from '@/graphql/queries'
@ -50,6 +50,9 @@ onResult(({ data }) => {
if (data) {
communities.value = data.communities
setDefaultCommunity()
if (data.communities.length === 1) {
validCommunityIdentifier.value = true
}
}
})

View File

@ -2,24 +2,10 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'
import { mount } from '@vue/test-utils'
import ContributionForm from './ContributionForm.vue'
// Mock external components and dependencies
vi.mock('@/components/Inputs/InputAmount', () => ({
default: {
name: 'InputAmount',
template: '<input data-testid="input-amount" />',
},
}))
vi.mock('@/components/Inputs/InputTextarea', () => ({
default: {
name: 'InputTextarea',
template: '<textarea data-testid="input-textarea"></textarea>',
},
}))
vi.mock('vue-i18n', () => ({
useI18n: () => ({
t: (key) => key,
d: (date) => date,
}),
}))

View File

@ -19,6 +19,7 @@
class="mb-4 bg-248"
type="date"
:rules="validationSchema.fields.contributionDate"
:disable-smart-valid-state="disableSmartValidState"
@update:model-value="updateField"
/>
<div v-if="noOpenCreation" class="p-3" data-test="contribution-message">
@ -33,6 +34,7 @@
:placeholder="$t('contribution.yourActivity')"
:rules="validationSchema.fields.memo"
textarea="true"
:disable-smart-valid-state="disableSmartValidState"
@update:model-value="updateField"
/>
<ValidatedInput
@ -41,8 +43,9 @@
: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"
/>
<LabeledInput
@ -68,7 +71,7 @@
{{ $t('form.cancel') }}
</BButton>
</BCol>
<BCol class="text-end mt-lg-0">
<BCol class="text-end mt-lg-0" @mouseover="disableSmartValidState = true">
<BButton
block
type="submit"
@ -89,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)
@ -105,7 +107,7 @@ const props = defineProps({
const emit = defineEmits(['upsert-contribution', 'abort'])
const { t } = useI18n()
const { t, d } = useI18n()
const entityDataToForm = computed(() => ({
...props.modelValue,
@ -121,6 +123,7 @@ const entityDataToForm = computed(() => ({
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)
@ -147,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())
}),

View File

@ -3,8 +3,16 @@ import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest'
import TransactionForm from './TransactionForm'
import { nextTick, ref } from 'vue'
import { SEND_TYPES } from '@/utils/sendTypes'
import { BCard, BForm, BFormRadioGroup, BRow, BCol, BFormRadio, BButton } from 'bootstrap-vue-next'
import { useForm } from 'vee-validate'
import {
BCard,
BForm,
BFormRadioGroup,
BRow,
BCol,
BFormRadio,
BButton,
BFormInvalidFeedback,
} from 'bootstrap-vue-next'
import { useRoute } from 'vue-router'
vi.mock('vue-router', () => ({
@ -35,23 +43,6 @@ vi.mock('@/composables/useToast', () => ({
})),
}))
vi.mock('vee-validate', () => {
const actualUseForm = vi.fn().mockReturnValue({
handleSubmit: vi.fn((callback) => {
return () =>
callback({
identifier: 'test@example.com',
amount: '100,00',
memo: 'Test memo',
})
}),
resetForm: vi.fn(),
defineField: vi.fn(() => [vi.fn(), {}]),
})
return { useForm: actualUseForm }
})
describe('TransactionForm', () => {
let wrapper
@ -64,6 +55,9 @@ describe('TransactionForm', () => {
mocks: {
$t: mockT,
$n: mockN,
$i18n: {
locale: 'en',
},
},
components: {
BCard,
@ -73,12 +67,11 @@ describe('TransactionForm', () => {
BCol,
BFormRadio,
BButton,
BFormInvalidFeedback,
},
stubs: {
'community-switch': true,
'input-identifier': true,
'input-amount': true,
'input-textarea': true,
'validated-input': true,
},
},
props: {
@ -102,15 +95,15 @@ describe('TransactionForm', () => {
describe('with balance <= 0.00 GDD the form is disabled', () => {
it('has a disabled input field of type text', () => {
expect(wrapper.find('input-identifier-stub').attributes('disabled')).toBe('true')
expect(wrapper.find('#identifier').attributes('disabled')).toBe('true')
})
it('has a disabled input field for amount', () => {
expect(wrapper.find('input-amount-stub').attributes('disabled')).toBe('true')
expect(wrapper.find('#amount').attributes('disabled')).toBe('true')
})
it('has a disabled textarea field', () => {
expect(wrapper.find('input-textarea-stub').attributes('disabled')).toBe('true')
expect(wrapper.find('#memo').attributes('disabled')).toBe('true')
})
it('has a message indicating that there are no GDDs to send', () => {
@ -143,41 +136,39 @@ describe('TransactionForm', () => {
describe('identifier field', () => {
it('has an input field of type text', () => {
expect(wrapper.find('input-identifier-stub').exists()).toBe(true)
expect(wrapper.find('#identifier').exists()).toBe(true)
})
it('has a label form.recipient', () => {
expect(wrapper.find('input-identifier-stub').attributes('label')).toBe('form.recipient')
expect(wrapper.find('#identifier').attributes('label')).toBe('form.recipient')
})
it('has a placeholder for identifier', () => {
expect(wrapper.find('input-identifier-stub').attributes('placeholder')).toBe(
'form.identifier',
)
expect(wrapper.find('#identifier').attributes('placeholder')).toBe('form.identifier')
})
})
describe('amount field', () => {
it('has an input field of type text', () => {
expect(wrapper.find('input-amount-stub').exists()).toBe(true)
expect(wrapper.find('#amount').exists()).toBe(true)
})
it('has a label form.amount', () => {
expect(wrapper.find('input-amount-stub').attributes('label')).toBe('form.amount')
expect(wrapper.find('#amount').attributes('label')).toBe('form.amount')
})
it('has a placeholder "0.01"', () => {
expect(wrapper.find('input-amount-stub').attributes('placeholder')).toBe('0.01')
expect(wrapper.find('#amount').attributes('placeholder')).toBe('0.01')
})
})
describe('message text box', () => {
it('has a textarea field', () => {
expect(wrapper.find('input-textarea-stub').exists()).toBe(true)
expect(wrapper.find('#memo').exists()).toBe(true)
})
it('has a label form.message', () => {
expect(wrapper.find('input-textarea-stub').attributes('label')).toBe('form.message')
expect(wrapper.find('#memo').attributes('label')).toBe('form.message')
})
})
@ -233,8 +224,10 @@ describe('TransactionForm', () => {
})
it('emits set-transaction event with correct data when form is submitted', async () => {
wrapper.vm.form.identifier = 'test@example.com'
wrapper.vm.form.amount = '100,00'
wrapper.vm.form.memo = 'Test memo'
await wrapper.findComponent(BForm).trigger('submit.prevent')
expect(wrapper.emitted('set-transaction')).toBeTruthy()
expect(wrapper.emitted('set-transaction')[0][0]).toEqual(
expect.objectContaining({
@ -247,20 +240,10 @@ describe('TransactionForm', () => {
})
it('handles form submission with empty amount', async () => {
vi.mocked(useForm).mockReturnValueOnce({
...vi.mocked(useForm)(),
handleSubmit: vi.fn((callback) => {
return () =>
callback({
identifier: 'test@example.com',
amount: '',
memo: 'Test memo',
})
}),
})
wrapper = createWrapper({ balance: 100.0 })
await nextTick()
wrapper.vm.form.identifier = 'test@example.com'
wrapper.vm.form.memo = 'Test memo'
await wrapper.findComponent(BForm).trigger('submit.prevent')
expect(wrapper.emitted('set-transaction')).toBeTruthy()

View File

@ -46,20 +46,25 @@
<BRow>
<BCol class="fw-bold">
<community-switch
:disabled="isBalanceDisabled"
:model-value="targetCommunity"
@update:model-value="targetCommunity = $event"
:disabled="isBalanceEmpty"
:model-value="form.targetCommunity"
@update:model-value="updateField($event, 'targetCommunity')"
/>
</BCol>
</BRow>
</BCol>
<BCol v-if="radioSelected === SEND_TYPES.send" cols="12">
<div v-if="!userIdentifier">
<input-identifier
<ValidatedInput
id="identifier"
:model-value="form.identifier"
name="identifier"
:label="$t('form.recipient')"
:placeholder="$t('form.identifier')"
:disabled="isBalanceDisabled"
:rules="validationSchema.fields.identifier"
:disabled="isBalanceEmpty"
:disable-smart-valid-state="disableSmartValidState"
@update:model-value="updateField"
/>
</div>
<div v-else class="mb-4">
@ -72,13 +77,17 @@
</div>
</BCol>
<BCol cols="12" lg="6">
<input-amount
<ValidatedInput
id="amount"
:model-value="form.amount"
name="amount"
:label="$t('form.amount')"
:placeholder="'0.01'"
:rules="{ required: true, gddSendAmount: { min: 0.01, max: balance } }"
:disabled="isBalanceDisabled"
></input-amount>
:rules="validationSchema.fields.amount"
:disabled="isBalanceEmpty"
:disable-smart-valid-state="disableSmartValidState"
@update:model-value="updateField"
/>
</BCol>
</BRow>
</BCol>
@ -86,16 +95,21 @@
<BRow>
<BCol>
<input-textarea
<ValidatedInput
id="memo"
:model-value="form.memo"
name="memo"
:label="$t('form.message')"
:placeholder="$t('form.message')"
:rules="{ required: true, min: 5, max: 255 }"
:disabled="isBalanceDisabled"
:rules="validationSchema.fields.memo"
textarea="true"
:disabled="isBalanceEmpty"
:disable-smart-valid-state="disableSmartValidState"
@update:model-value="updateField"
/>
</BCol>
</BRow>
<div v-if="!!isBalanceDisabled" class="text-danger mt-5">
<div v-if="!!isBalanceEmpty" class="text-danger mt-5">
{{ $t('form.no_gdd_available') }}
</div>
<BRow v-else class="test-buttons mt-3">
@ -110,8 +124,14 @@
{{ $t('form.reset') }}
</BButton>
</BCol>
<BCol cols="12" md="6" lg="6" class="text-lg-end">
<BButton block type="submit" variant="gradido">
<BCol
cols="12"
md="6"
lg="6"
class="text-lg-end"
@mouseover="disableSmartValidState = true"
>
<BButton block type="submit" variant="gradido" :disabled="formIsInvalid">
{{ $t('form.check_now') }}
</BButton>
</BCol>
@ -124,15 +144,14 @@
</template>
<script setup>
import { ref, computed, watch } from 'vue'
import { ref, computed, watch, reactive } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useQuery } from '@vue/apollo-composable'
import { useForm } from 'vee-validate'
import { SEND_TYPES } from '@/utils/sendTypes'
import InputIdentifier from '@/components/Inputs/InputIdentifier'
import InputAmount from '@/components/Inputs/InputAmount'
import InputTextarea from '@/components/Inputs/InputTextarea'
import CommunitySwitch from '@/components/CommunitySwitch.vue'
import ValidatedInput from '@/components/Inputs/ValidatedInput.vue'
import { memo as memoSchema, identifier as identifierSchema } from '@/validationSchemas'
import { object, number } from 'yup'
import { user } from '@/graphql/queries'
import CONFIG from '@/config'
import { useAppToast } from '@/composables/useToast'
@ -149,6 +168,10 @@ const props = defineProps({
},
})
const entityDataToForm = computed(() => ({ ...props }))
const form = reactive({ ...entityDataToForm.value })
const disableSmartValidState = ref(false)
const emit = defineEmits(['set-transaction'])
const route = useRoute()
@ -157,18 +180,6 @@ const { toastError } = useAppToast()
const radioSelected = ref(props.selected)
const userName = ref('')
const recipientCommunity = ref({ uuid: '', name: '' })
const { handleSubmit, resetForm, defineField, values } = useForm({
initialValues: {
identifier: props.identifier,
amount: props.amount ? String(props.amount) : '',
memo: props.memo,
targetCommunity: props.targetCommunity,
},
})
const [targetCommunity, targetCommunityProps] = defineField('targetCommunity')
const userIdentifier = computed(() => {
if (route.params.userIdentifier && route.params.communityIdentifier) {
@ -180,7 +191,49 @@ const userIdentifier = computed(() => {
return null
})
const isBalanceDisabled = computed(() => props.balance <= 0)
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(
user,
@ -193,6 +246,7 @@ watch(
(user) => {
if (user) {
userName.value = `${user.firstName} ${user.lastName}`
form.identifier = userIdentifier.value.identifier
}
},
{ immediate: true },
@ -204,19 +258,21 @@ watch(userError, (error) => {
}
})
const onSubmit = handleSubmit((formValues) => {
if (userIdentifier.value) formValues.identifier = userIdentifier.value.identifier
function onSubmit() {
const transformedForm = validationSchema.value.cast(form)
emit('set-transaction', {
...transformedForm,
selected: radioSelected.value,
...formValues,
amount: Number(formValues.amount.replace(',', '.')),
userName: userName.value,
})
})
}
function onReset(event) {
event.preventDefault()
resetForm()
form.amount = props.amount
form.memo = props.memo
form.identifier = props.identifier
form.targetCommunity = props.targetCommunity
radioSelected.value = SEND_TYPES.send
router.replace('/send')
}

View File

@ -1,125 +0,0 @@
import { mount } from '@vue/test-utils'
import { describe, it, expect, beforeEach, vi } from 'vitest'
import InputAmount from './InputAmount'
import { useRoute } from 'vue-router'
import { useI18n } from 'vue-i18n'
import { ref } from 'vue'
import { BFormInput } from 'bootstrap-vue-next'
vi.mock('vue-router', () => ({
useRoute: vi.fn(() => ({
params: {},
path: '/some-path',
})),
}))
vi.mock('vue-i18n', () => ({
useI18n: vi.fn(() => ({
t: (key) => key,
n: (num) => num,
})),
}))
vi.mock('vee-validate', () => ({
useField: vi.fn(() => ({
value: ref(''),
meta: { valid: true },
errorMessage: ref(''),
})),
}))
// Mock toast
const mockToastError = vi.fn()
vi.mock('@/composables/useToast', () => ({
useAppToast: vi.fn(() => ({
toastError: mockToastError,
})),
}))
describe('InputAmount', () => {
let wrapper
const createWrapper = (propsData = {}) => {
return mount(InputAmount, {
props: {
name: 'amount',
label: 'Amount',
placeholder: 'Enter amount',
typ: 'TransactionForm',
modelValue: '12,34',
...propsData,
},
global: {
mocks: {
$route: useRoute(),
...useI18n(),
},
components: {
BFormInput,
},
directives: {
focus: {},
},
stubs: {
BFormGroup: true,
BFormInvalidFeedback: true,
BInputGroup: true,
},
},
})
}
beforeEach(() => {
vi.clearAllMocks()
})
describe('mount in a TransactionForm', () => {
beforeEach(() => {
wrapper = createWrapper()
})
it('renders the component input-amount', () => {
expect(wrapper.find('div.input-amount').exists()).toBe(true)
})
it('normalizes the amount correctly', async () => {
await wrapper.vm.normalizeAmount('12,34')
expect(wrapper.vm.value).toBe('12.34')
})
it('does not normalize invalid input', async () => {
await wrapper.vm.normalizeAmount('12m34')
expect(wrapper.vm.value).toBe('12m34')
})
})
describe('mount in a ContributionForm', () => {
beforeEach(() => {
wrapper = createWrapper({
typ: 'ContributionForm',
modelValue: '12.34',
})
})
it('renders the component input-amount', () => {
expect(wrapper.find('div.input-amount').exists()).toBe(true)
})
it('normalizes the amount correctly', async () => {
await wrapper.vm.normalizeAmount('12.34')
expect(wrapper.vm.value).toBe('12.34')
})
it('does not normalize invalid input', async () => {
await wrapper.vm.normalizeAmount('12m34')
expect(wrapper.vm.value).toBe('12m34')
})
})
it('emits update:modelValue when value changes', async () => {
wrapper = createWrapper()
await wrapper.vm.normalizeAmount('15.67')
expect(wrapper.emitted('update:modelValue')).toBeTruthy()
expect(wrapper.emitted('update:modelValue')[0]).toEqual(['15.67'])
})
})

View File

@ -1,88 +0,0 @@
<template>
<div class="input-amount">
<template v-if="typ === 'TransactionForm'">
<BFormGroup :label="label" :label-for="labelFor" data-test="input-amount">
<BFormInput
:id="labelFor"
v-focus="amountFocused"
:model-value="value"
:class="$route.path === '/send' ? 'bg-248' : ''"
:name="name"
:placeholder="placeholder"
type="text"
:state="meta.valid"
trim
:disabled="disabled"
autocomplete="off"
@update:model-value="normalizeAmount($event)"
@focus="amountFocused = true"
@blur="normalizeAmount($event)"
/>
<BFormInvalidFeedback v-if="errorMessage">
{{ errorMessage }}
</BFormInvalidFeedback>
</BFormGroup>
</template>
<BInputGroup v-else append="GDD" :label="label" :label-for="labelFor">
<BFormInput
:id="labelFor"
v-focus="amountFocused"
:model-value="value"
:name="name"
:placeholder="placeholder"
type="text"
readonly
trim
/>
</BInputGroup>
</div>
</template>
<script setup>
import { ref, computed, watch } from 'vue'
import { useField } from 'vee-validate'
import { useRoute } from 'vue-router'
import { useI18n } from 'vue-i18n'
const props = defineProps({
rules: {
type: Object,
default: () => ({}),
},
typ: { type: String, default: 'TransactionForm' },
name: { type: String, required: true, default: 'Amount' },
label: { type: String, required: true, default: 'Amount' },
placeholder: { type: String, required: true, default: 'Amount' },
balance: { type: Number, default: 0.0 },
disabled: { required: false, type: Boolean, default: false },
})
const emit = defineEmits(['update:modelValue'])
const route = useRoute()
const { n } = useI18n()
const { value, meta, errorMessage } = useField(props.name, props.rules)
const amountFocused = ref(false)
const amountValue = ref(0.0)
const labelFor = computed(() => props.name + '-input-field')
watch(value, (newValue) => {
emit('update:modelValue', newValue)
})
watch(
() => props.modelValue,
(newValue) => {
if (newValue !== value.value) value.value = newValue
},
)
const normalizeAmount = (inputValue) => {
amountFocused.value = false
if (typeof inputValue === 'string' && inputValue.length > 1) {
value.value = inputValue.replace(',', '.')
}
}
</script>

View File

@ -1,61 +0,0 @@
<template>
<BFormGroup :label="label" :label-for="labelFor" data-test="input-identifier">
<BFormInput
:id="labelFor"
:model-value="value"
:name="name"
:placeholder="placeholder"
type="text"
:state="meta.valid"
trim
class="bg-248"
:disabled="disabled"
autocomplete="off"
@update:model-value="value = $event"
/>
<BFormInvalidFeedback v-if="errorMessage">
{{ errorMessage }}
</BFormInvalidFeedback>
</BFormGroup>
</template>
<script setup>
import { computed, watch } from 'vue'
import { useField } from 'vee-validate'
const props = defineProps({
rules: {
type: Object,
default: () => ({
required: true,
validIdentifier: true,
}),
},
name: { type: String, required: true },
label: { type: String, required: true },
placeholder: { type: String, required: true },
modelValue: { type: String },
disabled: { type: Boolean, required: false, default: false },
})
const emit = defineEmits(['update:modelValue', 'onValidation'])
const { value, meta, errorMessage } = useField(props.name, props.rules, {
initialValue: props.modelValue,
})
const labelFor = computed(() => props.name + '-input-field')
watch(value, (newValue) => {
emit('update:modelValue', newValue)
})
watch(
() => props.modelValue,
(newValue) => {
if (newValue !== value.value) {
value.value = newValue
emit('onValidation')
}
},
)
</script>

View File

@ -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')
})
})

View File

@ -1,64 +0,0 @@
<template>
<div>
<BFormGroup :label="label" :label-for="labelFor" data-test="input-textarea">
<BFormTextarea
:id="labelFor"
:model-value="currentValue"
class="bg-248"
:name="name"
:placeholder="placeholder"
:state="meta.valid"
trim
:rows="4"
:max-rows="4"
:disabled="disabled"
no-resize
@update:modelValue="currentValue = $event"
/>
<BFormInvalidFeedback v-if="errorMessage">
{{ translatedErrorString }}
</BFormInvalidFeedback>
</BFormGroup>
</div>
</template>
<script setup>
import { computed } from 'vue'
import { useField } from 'vee-validate'
import { useI18n } from 'vue-i18n'
import { translateYupErrorString } from '@/validationSchemas'
const props = defineProps({
rules: {
type: Object,
default: () => ({}),
},
name: {
type: String,
required: true,
},
label: {
type: String,
required: true,
},
placeholder: {
type: String,
required: true,
},
disabled: {
type: Boolean,
default: false,
},
})
const { value: currentValue, errorMessage, meta } = useField(props.name, props.rules)
const { t } = useI18n()
const translatedErrorString = computed(() => translateYupErrorString(errorMessage, t))
const labelFor = computed(() => `${props.name}-input-field`)
</script>
<style lang="scss" scoped>
:deep(.form-control) {
height: unset;
}
</style>

View File

@ -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')
})
})

View File

@ -9,7 +9,8 @@
:required="!isOptional"
:label="label"
:name="name"
:state="valid"
:state="smartValidState"
@blur="afterFirstInput = true"
@update:modelValue="updateValue"
>
<BFormInvalidFeedback v-if="errorMessage">
@ -19,7 +20,7 @@
</template>
<script setup>
import { computed, ref, watch } from 'vue'
import { computed, ref, watch, onMounted } from 'vue'
import { useI18n } from 'vue-i18n'
import LabeledInput from './LabeledInput'
import { translateYupErrorString } from '@/validationSchemas'
@ -38,19 +39,40 @@ const props = defineProps({
type: Object,
required: true,
},
disableSmartValidState: {
type: Boolean,
default: false,
},
})
const { t } = useI18n()
const model = ref(props.modelValue)
const model = ref(props.modelValue !== 0 ? props.modelValue : '')
// change to true after user leave the input field the first time
// prevent showing errors on form init
const afterFirstInput = ref(false)
const valid = computed(() => props.rules.isValidSync(props.modelValue))
const errorMessage = computed(() => {
if (props.modelValue === undefined || props.modelValue === '' || props.modelValue === null) {
return undefined
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:
//
// - On initial form open, the field is neutral (no validation state shown).
// - If the user enters a value that passes validation, we show a green (valid) state immediately.
// - We only show red (invalid) feedback *after* the user has blurred the field for the first time.
//
// Before first blur:
// - show green if valid, otherwise neutral (null)
// After first blur:
// - show true or false according to the validation result
const smartValidState = computed(() => {
if (afterFirstInput.value || props.disableSmartValidState) {
return valid.value
}
return valid.value ? true : null
})
const errorMessage = computed(() => {
try {
props.rules.validateSync(props.modelValue)
props.rules.validateSync(model.value)
return undefined
} catch (e) {
return translateYupErrorString(e.message, t)
@ -79,4 +101,17 @@ const minValue = computed(() => getTestParameter('min'))
const maxValue = computed(() => getTestParameter('max'))
const resetValue = computed(() => schemaDescription.value.default)
const isOptional = computed(() => schemaDescription.value.optional)
// reset on mount
onMounted(() => {
afterFirstInput.value = false
})
</script>
<!-- disable animation on invalid input -->
<style>
.form-control {
transition: none !important;
transform: none !important;
animation: none !important;
}
</style>

View File

@ -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"
},

View File

@ -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"
},

View File

@ -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')
)
})
}

View File

@ -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
})