#8163 add VnInput insert functionality and e2e test #987

Merged
jsegarra merged 13 commits from wbuezas/salix-front-mindshore-fork2:8163-VnInputFunctionality into dev 2024-11-25 21:36:10 +00:00
11 changed files with 205 additions and 82 deletions

View File

@ -44,7 +44,7 @@ const onDataSaved = (...args) => {
<template #form-inputs="{ data, validate }"> <template #form-inputs="{ data, validate }">
<VnRow> <VnRow>
<VnInput <VnInput
:label="t('Names')" :label="t('Name')"
v-model="data.name" v-model="data.name"
:rules="validate('city.name')" :rules="validate('city.name')"
/> />

View File

@ -1,5 +1,5 @@
<script setup> <script setup>
import { reactive, ref, watch } from 'vue'; import { reactive, ref } from 'vue';
import { useI18n } from 'vue-i18n'; import { useI18n } from 'vue-i18n';
import FetchData from 'components/FetchData.vue'; import FetchData from 'components/FetchData.vue';
@ -22,12 +22,15 @@ const postcodeFormData = reactive({
townFk: null, townFk: null,
}); });
const townsFetchDataRef = ref(null); const townsFetchDataRef = ref(false);
const provincesFetchDataRef = ref(null); const countriesFetchDataRef = ref(false);
const provincesFetchDataRef = ref(false);
const countriesOptions = ref([]); const countriesOptions = ref([]);
const provincesOptions = ref([]); const provincesOptions = ref([]);
const townsOptions = ref([]); const townsOptions = ref([]);
const town = ref({}); const town = ref({});
const townFilter = ref({});
const countryFilter = ref({});
function onDataSaved(formData) { function onDataSaved(formData) {
const newPostcode = { const newPostcode = {
@ -56,10 +59,60 @@ async function onCityCreated(newTown, formData) {
} }
function setTown(newTown, data) { function setTown(newTown, data) {
data.provinceFk = newTown.provinceFk;
data.countryFk = newTown.province.countryFk;
if (!newTown) return;
town.value = newTown; town.value = newTown;
data.provinceFk = newTown?.provinceFk ?? newTown;
data.countryFk = newTown?.province?.countryFk ?? newTown;
}
async function setCountry(countryFk, data) {
data.townFk = null;
data.provinceFk = null;
data.countryFk = countryFk;
}
async function filterTowns(name) {
if (name !== '') {
townFilter.value.where = {
name: {
like: `%${name}%`,
},
};
await townsFetchDataRef.value?.fetch();
}
}
async function filterCountries(name) {
if (name !== '') {
countryFilter.value.where = {
name: {
like: `%${name}%`,
},
};
await countriesFetchDataRef.value?.fetch();
}
}
async function fetchTowns(countryFk) {
if (!countryFk) return;
townFilter.value.where = {
provinceFk: {
inq: provincesOptions.value.map(({ id }) => id),
},
};
await townsFetchDataRef.value?.fetch();
}
async function handleProvinces(data) {
provincesOptions.value = data;
if (postcodeFormData.countryFk) {
await fetchTowns(postcodeFormData.countryFk);
}
}
async function handleTowns(data) {
townsOptions.value = data;
}
async function handleCountries(data) {
countriesOptions.value = data;
} }
async function setProvince(id, data) { async function setProvince(id, data) {
@ -75,70 +128,6 @@ async function onProvinceCreated(data) {
}); });
postcodeFormData.provinceFk = data.id; postcodeFormData.provinceFk = data.id;
} }
watch(
() => [postcodeFormData.countryFk],
async (newCountryFk, oldValueFk) => {
if (Array.isArray(newCountryFk)) {
newCountryFk = newCountryFk[0];
}
if (Array.isArray(oldValueFk)) {
oldValueFk = oldValueFk[0];
}
if (!!oldValueFk && newCountryFk !== oldValueFk) {
postcodeFormData.provinceFk = null;
postcodeFormData.townFk = null;
}
if (oldValueFk !== newCountryFk) {
await fetchProvinces(newCountryFk);
await fetchTowns(newCountryFk);
}
}
);
async function fetchTowns(countryFk) {
const townsFilter = countryFk
? {
where: {
provinceFk: {
inq: provincesOptions.value.map(({ id }) => id),
},
},
}
: {};
await townsFetchDataRef.value.fetch(townsFilter);
}
async function fetchProvinces(countryFk) {
const provincesFilter = countryFk
? {
where: {
countryFk: countryFk,
},
}
: {};
await provincesFetchDataRef.value.fetch(provincesFilter);
}
watch(
() => postcodeFormData.provinceFk,
async (newProvinceFk, oldValueFk) => {
if (Array.isArray(newProvinceFk)) {
newProvinceFk = newProvinceFk[0];
}
if (newProvinceFk !== oldValueFk) {
await townsFetchDataRef.value.fetch({
where: { provinceFk: newProvinceFk },
});
}
}
);
async function handleProvinces(data) {
provincesOptions.value = data;
}
async function handleTowns(data) {
townsOptions.value = data;
}
async function handleCountries(data) {
countriesOptions.value = data;
}
</script> </script>
<template> <template>
@ -154,12 +143,15 @@ async function handleCountries(data) {
ref="townsFetchDataRef" ref="townsFetchDataRef"
:sort-by="['name ASC']" :sort-by="['name ASC']"
:limit="30" :limit="30"
:filter="townFilter"
@on-fetch="handleTowns" @on-fetch="handleTowns"
auto-load auto-load
url="Towns/location" url="Towns/location"
/> />
<FetchData <FetchData
ref="CountriesFetchDataRef" ref="countriesFetchDataRef"
:limit="30"
:filter="countryFilter"
:sort-by="['name ASC']" :sort-by="['name ASC']"
@on-fetch="handleCountries" @on-fetch="handleCountries"
auto-load auto-load
@ -186,6 +178,7 @@ async function handleCountries(data) {
<VnSelectDialog <VnSelectDialog
:label="t('City')" :label="t('City')"
@update:model-value="(value) => setTown(value, data)" @update:model-value="(value) => setTown(value, data)"
@filter="filterTowns"
:tooltip="t('Create city')" :tooltip="t('Create city')"
v-model="data.townFk" v-model="data.townFk"
:options="townsOptions" :options="townsOptions"
@ -225,7 +218,7 @@ async function handleCountries(data) {
:province-selected="data.provinceFk" :province-selected="data.provinceFk"
@update:model-value="(value) => setProvince(value, data)" @update:model-value="(value) => setProvince(value, data)"
v-model="data.provinceFk" v-model="data.provinceFk"
:clearable="true" @on-province-fetched="handleProvinces"
@on-province-created="onProvinceCreated" @on-province-created="onProvinceCreated"
/> />
<VnSelect <VnSelect
@ -233,10 +226,12 @@ async function handleCountries(data) {
@update:options="handleCountries" @update:options="handleCountries"
:options="countriesOptions" :options="countriesOptions"
hide-selected hide-selected
@filter="filterCountries"
option-label="name" option-label="name"
option-value="id" option-value="id"
v-model="data.countryFk" v-model="data.countryFk"
:rules="validate('postcode.countryFk')" :rules="validate('postcode.countryFk')"
@update:model-value="(value) => setCountry(value, data)"
/> />
</VnRow> </VnRow>
</template> </template>

View File

@ -7,7 +7,7 @@ import VnSelectDialog from 'components/common/VnSelectDialog.vue';
import FetchData from 'components/FetchData.vue'; import FetchData from 'components/FetchData.vue';
import CreateNewProvinceForm from './CreateNewProvinceForm.vue'; import CreateNewProvinceForm from './CreateNewProvinceForm.vue';
const emit = defineEmits(['onProvinceCreated']); const emit = defineEmits(['onProvinceCreated', 'onProvinceFetched']);
const $props = defineProps({ const $props = defineProps({
countryFk: { countryFk: {
type: Number, type: Number,
@ -50,6 +50,7 @@ watch(
filter.value.where.countryFk = $props.countryFk; filter.value.where.countryFk = $props.countryFk;
} else filter.value.where = {}; } else filter.value.where = {};
await provincesFetchDataRef.value.fetch({}); await provincesFetchDataRef.value.fetch({});
emit('onProvinceFetched', provincesOptions.value);
} }
); );
</script> </script>
@ -67,6 +68,7 @@ watch(
:options="provincesOptions" :options="provincesOptions"
:tooltip="t('Create province')" :tooltip="t('Create province')"
hide-selected hide-selected
:clearable="true"
v-model="provinceFk" v-model="provinceFk"
:rules="validate && validate('postcode.provinceFk')" :rules="validate && validate('postcode.provinceFk')"
:acls="[{ model: 'Province', props: '*', accessType: 'WRITE' }]" :acls="[{ model: 'Province', props: '*', accessType: 'WRITE' }]"

View File

@ -1,20 +1,24 @@
<script setup> <script setup>
import { ref, watch } from 'vue'; import { nextTick, ref, watch } from 'vue';
import { QInput } from 'quasar'; import { QInput } from 'quasar';
const props = defineProps({ const $props = defineProps({
modelValue: { modelValue: {
type: String, type: String,
default: '', default: '',
}, },
insertable: {
type: Boolean,
default: false,
},
}); });
const emit = defineEmits(['update:modelValue', 'accountShortToStandard']); const emit = defineEmits(['update:modelValue', 'accountShortToStandard']);
let internalValue = ref(props.modelValue); let internalValue = ref($props.modelValue);
watch( watch(
() => props.modelValue, () => $props.modelValue,
(newVal) => { (newVal) => {
internalValue.value = newVal; internalValue.value = newVal;
} }
@ -28,8 +32,46 @@ watch(
} }
); );
const handleKeydown = (e) => {
if (e.key === 'Backspace') return;
if (e.key === '.') {
accountShortToStandard();
// TODO: Fix this setTimeout, with nextTick doesn't work
setTimeout(() => {
setCursorPosition(0, e.target);
}, 1);
return;
}
if ($props.insertable && e.key.match(/[0-9]/)) {
handleInsertMode(e);
}
};
function setCursorPosition(pos, el = vnInputRef.value) {
el.focus();
el.setSelectionRange(pos, pos);
}
const vnInputRef = ref(false);
const handleInsertMode = (e) => {
e.preventDefault();
const input = e.target;
const cursorPos = input.selectionStart;
const { maxlength } = vnInputRef.value;
let currentValue = internalValue.value;
if (!currentValue) currentValue = e.key;
const newValue = e.key;
if (newValue && !isNaN(newValue) && cursorPos < maxlength) {
internalValue.value =
currentValue.substring(0, cursorPos) +
newValue +
currentValue.substring(cursorPos + 1);
}
nextTick(() => {
input.setSelectionRange(cursorPos + 1, cursorPos + 1);
});
};
function accountShortToStandard() { function accountShortToStandard() {
internalValue.value = internalValue.value.replace( internalValue.value = internalValue.value?.replace(
'.', '.',
'0'.repeat(11 - internalValue.value.length) '0'.repeat(11 - internalValue.value.length)
); );
@ -37,5 +79,5 @@ function accountShortToStandard() {
</script> </script>
<template> <template>
<q-input v-model="internalValue" /> <QInput @keydown="handleKeydown" ref="vnInputRef" v-model="internalValue" />
</template> </template>

View File

@ -1,5 +1,5 @@
<script setup> <script setup>
import { computed, ref, useAttrs } from 'vue'; import { computed, ref, useAttrs, nextTick } from 'vue';
import { useI18n } from 'vue-i18n'; import { useI18n } from 'vue-i18n';
import { useRequired } from 'src/composables/useRequired'; import { useRequired } from 'src/composables/useRequired';
@ -34,6 +34,14 @@ const $props = defineProps({
type: Boolean, type: Boolean,
default: true, default: true,
}, },
insertable: {
type: Boolean,
default: false,
},
maxlength: {
type: Number,
default: null,
},
}); });
const vnInputRef = ref(null); const vnInputRef = ref(null);
@ -69,6 +77,9 @@ const mixinRules = [
requiredFieldRule, requiredFieldRule,
...($attrs.rules ?? []), ...($attrs.rules ?? []),
(val) => { (val) => {
const { maxlength } = vnInputRef.value;
if (maxlength && +val.length > maxlength)
return t(`maxLength`, { value: maxlength });
const { min, max } = vnInputRef.value.$attrs; const { min, max } = vnInputRef.value.$attrs;
if (!min) return null; if (!min) return null;
if (min >= 0) if (Math.floor(val) < min) return t('inputMin', { value: min }); if (min >= 0) if (Math.floor(val) < min) return t('inputMin', { value: min });
@ -78,6 +89,33 @@ const mixinRules = [
} }
}, },
]; ];
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);
});
};
</script> </script>
<template> <template>
@ -89,6 +127,7 @@ const mixinRules = [
:type="$attrs.type" :type="$attrs.type"
:class="{ required: isRequired }" :class="{ required: isRequired }"
@keyup.enter="emit('keyup.enter')" @keyup.enter="emit('keyup.enter')"
@keydown="handleKeydown"
:clearable="false" :clearable="false"
:rules="mixinRules" :rules="mixinRules"
:lazy-rules="true" :lazy-rules="true"
@ -130,9 +169,11 @@ const mixinRules = [
<i18n> <i18n>
en: en:
inputMin: Must be more than {value} inputMin: Must be more than {value}
maxLength: The value exceeds {value} characters
inputMax: Must be less than {value} inputMax: Must be less than {value}
es: es:
inputMin: Debe ser mayor a {value} inputMin: Debe ser mayor a {value}
maxLength: El valor excede los {value} carácteres
inputMax: Debe ser menor a {value} inputMax: Debe ser menor a {value}
</i18n> </i18n>
<style lang="scss"> <style lang="scss">

View File

@ -9,6 +9,7 @@ import VnRow from 'components/ui/VnRow.vue';
import VnInput from 'src/components/common/VnInput.vue'; import VnInput from 'src/components/common/VnInput.vue';
import VnSelect from 'src/components/common/VnSelect.vue'; import VnSelect from 'src/components/common/VnSelect.vue';
import VnLocation from 'src/components/common/VnLocation.vue'; import VnLocation from 'src/components/common/VnLocation.vue';
import VnAccountNumber from 'src/components/common/VnAccountNumber.vue';
const route = useRoute(); const route = useRoute();
const { t } = useI18n(); const { t } = useI18n();
@ -100,10 +101,13 @@ function handleLocation(data, location) {
/> />
</VnRow> </VnRow>
<VnRow> <VnRow>
<VnInput <VnAccountNumber
v-model="data.account" v-model="data.account"
:label="t('supplier.fiscalData.account')" :label="t('supplier.fiscalData.account')"
clearable clearable
data-cy="supplierFiscalDataAccount"
insertable
:maxlength="10"
/> />
<VnSelect <VnSelect
:label="t('supplier.fiscalData.sageTaxTypeFk')" :label="t('supplier.fiscalData.sageTaxTypeFk')"

View File

@ -0,0 +1,39 @@
describe('VnInput Component', () => {
beforeEach(() => {
cy.login('developer');
cy.viewport(1920, 1080);
cy.visit('/#/supplier/1/fiscal-data');
cy.domContentLoad();
});
it('should replace character at cursor position in insert mode', () => {
// Simula escribir en el input
cy.dataCy('supplierFiscalDataAccount').clear();
cy.dataCy('supplierFiscalDataAccount').type('4100000001');
// Coloca el cursor en la posición 0
cy.dataCy('supplierFiscalDataAccount').type('{movetostart}');
// Escribe un número y verifica que se reemplace correctamente
cy.dataCy('supplierFiscalDataAccount').type('999');
cy.dataCy('supplierFiscalDataAccount')
.should('have.value', '9990000001');
});
it('should replace character at cursor position in insert mode', () => {
// Simula escribir en el input
cy.dataCy('supplierFiscalDataAccount').clear();
cy.dataCy('supplierFiscalDataAccount').type('4100000001');
// Coloca el cursor en la posición 0
cy.dataCy('supplierFiscalDataAccount').type('{movetostart}');
// Escribe un número y verifica que se reemplace correctamente en la posicion incial
cy.dataCy('supplierFiscalDataAccount').type('999');
cy.dataCy('supplierFiscalDataAccount')
.should('have.value', '9990000001');
});
it('should respect maxlength prop', () => {
cy.dataCy('supplierFiscalDataAccount').clear();
cy.dataCy('supplierFiscalDataAccount').type('123456789012345');
cy.dataCy('supplierFiscalDataAccount')
.should('have.value', '1234567890'); // asumiendo que maxlength es 10
});
});