diff --git a/package.json b/package.json index 64004f37..da3b8435 100644 --- a/package.json +++ b/package.json @@ -59,7 +59,7 @@ "salix": "cd ../salix && gulp back", "db": "cd ../salix && gulp docker", "cy:open": "npm run db && cypress open", - "test:e2e": "npm run db && cypress run", + "test:e2e": "npm run db && cypress run --headed --config video=false", "test:unit": "vitest", "build": "rm -rf dist/ ; quasar build", "clean": "rm -rf dist/", diff --git a/src/components/common/FormModel.vue b/src/components/common/FormModel.vue index 7f0bfa2e..be7ae1c4 100644 --- a/src/components/common/FormModel.vue +++ b/src/components/common/FormModel.vue @@ -81,7 +81,7 @@ const { isHeaderMounted } = storeToRefs(appStore); const isLoading = ref(false); const formData = ref({}); -const addressFormRef = ref(null); +const formModelRef = ref(null); const hasChanges = ref(!props.observeFormChanges); const isResetting = ref(false); const originalData = ref(null); @@ -119,6 +119,7 @@ onMounted(async () => { ); } }); + async function fetch() { try { let { data } = await api.get(props.url, { @@ -133,9 +134,12 @@ async function fetch() { } async function submit() { - console.log('submit: '); if (props.observeFormChanges && !hasChanges.value) - return notify('globals.noChanges', 'negative'); + return notify('noChanges', 'negative'); + + const isValid = await formModelRef.value.validate(); + console.log('isValid', isValid); + if (!isValid) return; isLoading.value = true; try { @@ -167,7 +171,7 @@ defineExpose({ <QCard class="form-container" v-bind="$attrs"> <QForm v-if="!isLoading" - ref="addressFormRef" + ref="formModelRef" class="form" :class="separationBetweenInputs" > diff --git a/src/components/common/VnInput.vue b/src/components/common/VnInput.vue index 508ce132..7dcf1164 100644 --- a/src/components/common/VnInput.vue +++ b/src/components/common/VnInput.vue @@ -1,15 +1,20 @@ <script setup> -import { computed, ref } from 'vue'; +import { computed, ref, useAttrs, nextTick } from 'vue'; import { useI18n } from 'vue-i18n'; +import { useRequired } from 'src/composables/useRequired'; +const $attrs = useAttrs(); +const { isRequired, requiredFieldRule } = useRequired($attrs); +const { t } = useI18n(); const emit = defineEmits([ 'update:modelValue', 'update:options', 'keyup.enter', - 'remove' + 'remove', + 'blur' ]); -const props = defineProps({ +const $props = defineProps({ modelValue: { type: [String, Number], default: null @@ -25,24 +30,43 @@ const props = defineProps({ clearable: { type: Boolean, default: true + }, + emptyToNull: { + type: Boolean, + default: true + }, + insertable: { + type: Boolean, + default: false + }, + maxlength: { + type: Number, + default: null + }, + uppercase: { + type: Boolean, + default: false } }); -const { t } = useI18n(); -const requiredFieldRule = val => !!val || t('globals.fieldRequired'); const vnInputRef = ref(null); const value = computed({ get() { - return props.modelValue; + return $props.modelValue; }, set(value) { + if ($props.emptyToNull && value === '') value = null; emit('update:modelValue', value); } }); const hover = ref(false); const styleAttrs = computed(() => { - return props.isOutlined - ? { dense: true, outlined: true, rounded: true } + return $props.isOutlined + ? { + dense: true, + outlined: true, + rounded: true + } : {}; }); @@ -51,66 +75,119 @@ const focus = () => { }; defineExpose({ - focus + focus, + vnInputRef }); -const inputRules = [ +const mixinRules = [ + requiredFieldRule, + ...($attrs.rules ?? []), val => { + const { maxlength } = vnInputRef.value; + if (maxlength && +val.length > maxlength) + return t(`maxLength`, { value: maxlength }); const { min, max } = vnInputRef.value.$attrs; - if (min >= 0) { + if (!min) return null; + if (min >= 0) if (Math.floor(val) < min) return t('inputMin', { value: min }); - } + if (!max) return null; if (max > 0) { if (Math.floor(val) > max) return t('inputMax', { value: max }); } } ]; + +const handleKeydown = e => { + if (e.key === 'Backspace') return; + + if ($props.insertable && e.key.match(/[0-9]/)) { + handleInsertMode(e); + } +}; + +const handleInsertMode = e => { + e.preventDefault(); + const input = e.target; + const cursorPos = input.selectionStart; + const { maxlength } = vnInputRef.value; + let currentValue = value.value; + if (!currentValue) currentValue = e.key; + const newValue = e.key; + if (newValue && !isNaN(newValue) && cursorPos < maxlength) { + value.value = + currentValue.substring(0, cursorPos) + + newValue + + currentValue.substring(cursorPos + 1); + } + nextTick(() => { + input.setSelectionRange(cursorPos + 1, cursorPos + 1); + }); +}; + +const handleUppercase = () => { + value.value = value.value?.toUpperCase() || ''; +}; </script> <template> - <div - :rules="$attrs.required ? [requiredFieldRule] : null" - @mouseover="hover = true" - @mouseleave="hover = false" - > + <div @mouseover="hover = true" @mouseleave="hover = false"> <QInput ref="vnInputRef" v-model="value" v-bind="{ ...$attrs, ...styleAttrs }" :type="$attrs.type" - :class="{ required: $attrs.required }" + :class="{ required: isRequired }" + @keyup.enter="emit('keyup.enter')" + @blur="emit('blur')" + @keydown="handleKeydown" :clearable="false" - :rules="inputRules" + :rules="mixinRules" :lazy-rules="true" hide-bottom-space - @keyup.enter="emit('keyup.enter')" + :data-cy="$attrs.dataCy ?? $attrs.label + '_input'" > - <template - v-if="$slots.prepend" - #prepend - > + <template #prepend v-if="$slots.prepend"> <slot name="prepend" /> </template> <template #append> - <slot - v-if="$slots.append && !$attrs.disabled" - name="append" - /> <QIcon - v-if="hover && value && !$attrs.disabled && props.clearable" name="close" size="xs" + :style="{ + visibility: + hover && + value && + !$attrs.disabled && + !$attrs.readonly && + $props.clearable + ? 'visible' + : 'hidden' + }" @click=" () => { value = null; + vnInputRef.focus(); emit('remove'); } " - /> + ></QIcon> + <QIcon - v-if="info" - name="info" + name="match_case" + size="xs" + v-if=" + !$attrs.disabled && !$attrs.readonly && $props.uppercase + " + @click="handleUppercase" + class="uppercase-icon" > + <QTooltip> + {{ t('Convert to uppercase') }} + </QTooltip> + </QIcon> + + <slot name="append" v-if="$slots.append && !$attrs.disabled" /> + <QIcon v-if="info" name="info"> <QTooltip max-width="350px"> {{ info }} </QTooltip> @@ -120,20 +197,44 @@ const inputRules = [ </div> </template> +<style> +.uppercase-icon { + transition: + color 0.3s, + transform 0.2s; + cursor: pointer; +} + +.uppercase-icon:hover { + color: #ed9937; + transform: scale(1.2); +} +</style> + <i18n lang="yaml"> en-US: inputMin: Must be more than {value} + maxLength: The value exceeds {value} characters inputMax: Must be less than {value} + Convert to uppercase: Convert to uppercase es-ES: inputMin: Debe ser mayor a {value} + maxLength: El valor excede los {value} carácteres inputMax: Debe ser menor a {value} + Convert to uppercase: Convertir a mayúsculas ca-ES: inputMin: Ha de ser més gran que {value} - inputMax: Ha de ser menys que {value} + maxLength: El valor excedeix els {value} caràcters + inputMax: Ha de ser menor que {value} + Convert to uppercase: Convertir a majúscules fr-FR: inputMin: Doit être supérieur à {value} - inputMax: Doit être supérieur à {value} + maxLength: La valeur dépasse {value} caractères + inputMax: Doit être inférieur à {value} + Convert to uppercase: Convertir en majuscules pt-PT: inputMin: Deve ser maior que {value} - inputMax: Deve ser maior que {value} + maxLength: O valor excede {value} caracteres + inputMax: Deve ser menor que {value} + Convert to uppercase: Converter para maiúsculas </i18n> diff --git a/src/components/common/VnSelect.vue b/src/components/common/VnSelect.vue index c4a3e1df..65dbb2f2 100644 --- a/src/components/common/VnSelect.vue +++ b/src/components/common/VnSelect.vue @@ -67,7 +67,7 @@ const $props = defineProps({ }); const { t } = useI18n(); -const requiredFieldRule = val => val ?? t('globals.fieldRequired'); +const requiredFieldRule = val => val ?? t('fieldRequired'); const { optionLabel, optionValue, options } = toRefs($props); const myOptions = ref([]); diff --git a/src/composables/useRequired.js b/src/composables/useRequired.js new file mode 100644 index 00000000..a77345b5 --- /dev/null +++ b/src/composables/useRequired.js @@ -0,0 +1,17 @@ +import { useI18n } from 'vue-i18n'; + +export function useRequired($attrs) { + const { t } = useI18n(); + + const isRequired = + typeof $attrs['required'] === 'boolean' + ? $attrs['required'] + : Object.keys($attrs).includes('required'); + const requiredFieldRule = val => + isRequired ? !!val || t('fieldRequired') : null; + + return { + isRequired, + requiredFieldRule + }; +} diff --git a/src/i18n/ca-ES/index.js b/src/i18n/ca-ES/index.js index 456795a5..48d214e0 100644 --- a/src/i18n/ca-ES/index.js +++ b/src/i18n/ca-ES/index.js @@ -134,6 +134,8 @@ export default { introduceSearchTerm: 'Introdueix un terme de cerca', noOrdersFound: `No s'han trobat comandes`, send: 'Enviar', + fieldRequired: 'Aquest camp és obligatori', + noChanges: 'No s’han fet canvis', // Image related translations 'Cant lock cache': 'No es pot bloquejar la memòria cau', 'Bad file format': 'Format de fitxer no reconegut', diff --git a/src/i18n/en-US/index.js b/src/i18n/en-US/index.js index 3e21b66d..80107dd5 100644 --- a/src/i18n/en-US/index.js +++ b/src/i18n/en-US/index.js @@ -167,6 +167,8 @@ export default { introduceSearchTerm: 'Enter a search term', noOrdersFound: 'No orders found', send: 'Send', + fieldRequired: 'Field required', + noChanges: 'No changes', // Image related translations 'Cant lock cache': 'The cache could not be blocked', 'Bad file format': 'Unrecognized file format', diff --git a/src/i18n/es-ES/index.js b/src/i18n/es-ES/index.js index 5034ff70..15639c2d 100644 --- a/src/i18n/es-ES/index.js +++ b/src/i18n/es-ES/index.js @@ -166,6 +166,8 @@ export default { introduceSearchTerm: 'Introduce un término de búsqueda', noOrdersFound: 'No se encontrado pedidos', send: 'Enviar', + fieldRequired: 'Campo requerido', + noChanges: 'No se han hecho cambios', // Image related translations 'Cant lock cache': 'La caché no pudo ser bloqueada', 'Bad file format': 'Formato de archivo no reconocido', diff --git a/src/i18n/fr-FR/index.js b/src/i18n/fr-FR/index.js index d30dee52..2eea9a38 100644 --- a/src/i18n/fr-FR/index.js +++ b/src/i18n/fr-FR/index.js @@ -134,6 +134,8 @@ export default { introduceSearchTerm: 'Entrez un terme de recherche', noOrdersFound: 'Aucune commande trouvée', send: 'Envoyer', + fieldRequired: 'Champ obligatoire', + noChanges: 'Aucun changement', // Image related translations 'Cant lock cache': "Le cache n'a pas pu être verrouillé", 'Bad file format': 'Format de fichier non reconnu', diff --git a/src/i18n/pt-PT/index.js b/src/i18n/pt-PT/index.js index 39bbe5de..460335e0 100644 --- a/src/i18n/pt-PT/index.js +++ b/src/i18n/pt-PT/index.js @@ -133,6 +133,8 @@ export default { introduceSearchTerm: 'Digite um termo de pesquisa', noOrdersFound: 'Nenhum pedido encontrado', send: 'Enviar', + fieldRequired: 'Campo obrigatório', + noChanges: 'Sem alterações', // Image related translations 'Cant lock cache': 'O cache não pôde ser bloqueado', 'Bad file format': 'Formato de arquivo inválido', diff --git a/src/pages/Account/AddressDetails.vue b/src/pages/Account/AddressDetails.vue index 31a372d7..362bc95d 100644 --- a/src/pages/Account/AddressDetails.vue +++ b/src/pages/Account/AddressDetails.vue @@ -116,22 +116,26 @@ onMounted(async () => { v-model="data.nickname" :label="t('name')" data-cy="addressFormNickname" + :required="true" /> <VnInput v-model="data.street" :label="t('address')" data-cy="addressFormStreet" + :required="true" /> <VnInput v-model="data.city" :label="t('city')" data-cy="addressFormCity" + :required="true" /> <VnInput v-model="data.postalCode" type="number" :label="t('postalCode')" data-cy="addressFormPostcode" + :required="true" /> <VnSelect v-model="data.countryFk" @@ -139,12 +143,14 @@ onMounted(async () => { :options="countriesOptions" @update:model-value="data.provinceFk = null" data-cy="addressFormCountry" + :required="true" /> <VnSelect v-model="data.provinceFk" :label="t('province')" :options="provincesOptions" data-cy="addressFormProvince" + :required="true" /> </template> </FormModel>