mirror of
https://github.com/IT4Change/gradido.git
synced 2026-02-06 09:56:05 +00:00
Merge pull request #3519 from gradido/frontend_fix_send_input
refactor(frontend): transaction and contribution form
This commit is contained in:
commit
8db786c13c
@ -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
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@ -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,
|
||||
}),
|
||||
}))
|
||||
|
||||
|
||||
@ -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())
|
||||
}),
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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')
|
||||
}
|
||||
|
||||
@ -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'])
|
||||
})
|
||||
})
|
||||
@ -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>
|
||||
@ -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>
|
||||
@ -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')
|
||||
})
|
||||
})
|
||||
@ -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>
|
||||
92
frontend/src/components/Inputs/ValidatedInput.spec.js
Normal file
92
frontend/src/components/Inputs/ValidatedInput.spec.js
Normal 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')
|
||||
})
|
||||
})
|
||||
@ -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>
|
||||
|
||||
@ -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"
|
||||
},
|
||||
|
||||
@ -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"
|
||||
},
|
||||
|
||||
@ -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')
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
@ -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
|
||||
})
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user