Address views refactor

This commit is contained in:
William Buezas 2025-03-04 10:32:12 -03:00
parent 47c61a7b35
commit bd1e9b7037
8 changed files with 322 additions and 119 deletions

View File

@ -23,7 +23,7 @@ module.exports = configure(function (ctx) {
// app boot file (/src/boot)
// --> boot files are part of "main.js"
// https://v2.quasar.dev/quasar-cli-webpack/boot-files
boot: ['i18n', 'axios', 'vnDate', 'error-handler', 'app'],
boot: ['i18n', 'axios', 'vnDate', 'app'],
// https://v2.quasar.dev/quasar-cli-webpack/quasar-config-js#Property%3A-css
css: ['app.scss', 'width.scss', 'responsive.scss'],

View File

@ -1,66 +0,0 @@
export default async ({ app }) => {
/*
window.addEventListener('error',
e => onWindowError(e));
window.addEventListener('unhandledrejection',
e => onWindowRejection(e));
,onWindowError(event) {
errorHandler(event.error);
}
,onWindowRejection(event) {
errorHandler(event.reason);
}
*/
app.config.errorHandler = (err, vm, info) => {
errorHandler(err, vm)
}
function errorHandler (err, vm) {
let message
let tMessage
let res = err.response
// XXX: Compatibility with old JSON service
if (err.name === 'JsonException') {
res = {
status: err.statusCode,
data: { error: { message: err.message } }
}
}
if (res) {
const status = res.status
if (status >= 400 && status < 500) {
switch (status) {
case 401:
tMessage = 'loginFailed'
break
case 403:
tMessage = 'authenticationRequired'
vm.$router.push('/login')
break
case 404:
tMessage = 'notFound'
break
default:
message = res.data.error.message
}
} else if (status >= 500) {
tMessage = 'internalServerError'
}
} else {
tMessage = 'somethingWentWrong'
console.error(err)
}
if (tMessage) {
message = vm.$t(tMessage)
}
vm.$q.notify({
message,
type: 'negative'
})
}
}

View File

@ -0,0 +1,232 @@
<script setup>
import { ref, inject, onMounted, computed, Teleport, watch } from 'vue';
import { useI18n } from 'vue-i18n';
import { useAppStore } from 'stores/app';
import { storeToRefs } from 'pinia';
import useNotify from 'src/composables/useNotify.js';
const props = defineProps({
title: {
type: String,
default: ''
},
table: {
type: String,
default: ''
},
schema: {
type: String,
default: ''
},
// Objeto con los datos iniciales del form, si este objeto es definido, no se ejecuta la query fetch
formInitialData: {
type: Object,
default: () => {}
},
autoLoad: {
type: Boolean,
default: true
},
defaultActions: {
type: Boolean,
default: true
},
showBottomActions: {
type: Boolean,
default: false
},
saveFn: {
type: Function,
default: null
},
separationBetweenInputs: {
type: String,
default: 'xs'
},
url: {
type: String,
default: ''
},
urlUpdate: {
type: String,
default: null
},
urlCreate: {
type: String,
default: null
},
observeFormChanges: {
type: Boolean,
default: true,
description:
'Esto se usa principalmente para permitir guardar sin hacer cambios (Útil para la feature de clonar ya que en este caso queremos poder guardar de primeras)'
},
mapper: {
type: Function,
default: null
},
filter: {
type: Object,
default: null
}
});
const emit = defineEmits(['onDataSaved', 'onDataFetched']);
const api = inject('api');
const { t } = useI18n();
const { notify } = useNotify();
const appStore = useAppStore();
const { isHeaderMounted } = storeToRefs(appStore);
const isLoading = ref(false);
const formData = ref({});
const addressFormRef = ref(null);
const hasChanges = ref(!props.observeFormChanges);
const isResetting = ref(false);
const originalData = ref(null);
const separationBetweenInputs = computed(() => {
return `q-gutter-y-${props.separationBetweenInputs}`;
});
const onSubmitSuccess = () => {
emit('onDataSaved');
notify(t('dataSaved'), 'positive');
};
onMounted(async () => {
if (!props.formInitialData) {
if (props.autoLoad && props.url) await fetch();
originalData.value = { ...formData.value };
} else {
formData.value = { ...props.formInitialData };
originalData.value = { ...props.formInitialData };
}
if (props.observeFormChanges) {
watch(
() => formData.value,
(newVal, oldVal) => {
if (!oldVal) return;
hasChanges.value =
!isResetting.value &&
JSON.stringify(newVal) !==
JSON.stringify(originalData.value);
isResetting.value = false;
},
{ deep: true }
);
}
});
async function fetch() {
try {
let { data } = await api.get(props.url, {
params: { filter: JSON.stringify(props.filter) }
});
if (Array.isArray(data)) data = data[0] ?? {};
formData.value = { ...data };
emit('onDataFetched', formData.value);
} catch (e) {
throw e;
}
}
async function submit() {
console.log('submit: ');
if (props.observeFormChanges && !hasChanges.value)
return notify('globals.noChanges', 'negative');
isLoading.value = true;
try {
const body = props.mapper
? props.mapper(formData.value, originalData.value)
: formData.value;
const method = props.urlCreate ? 'post' : 'patch';
const url = props.urlCreate || props.urlUpdate || props.url;
let response;
if (props.saveFn) await props.saveFn(body);
else await api[method](url, body);
onSubmitSuccess();
hasChanges.value = false;
} finally {
isLoading.value = false;
}
}
defineExpose({
formData,
submit
});
</script>
<template>
<QCard class="form-container" v-bind="$attrs">
<QForm
v-if="!isLoading"
ref="addressFormRef"
class="form"
:class="separationBetweenInputs"
>
<span v-if="title" class="text-h6 text-bold">
{{ title }}
</span>
<slot name="form" :data="formData" />
<slot name="extraForm" :data="formData" />
<component
v-if="isHeaderMounted"
:is="showBottomActions ? 'div' : Teleport"
to="#actions"
class="flex row justify-end q-gutter-x-sm"
:class="{ 'q-mt-md': showBottomActions }"
>
<QBtn
v-if="defaultActions && showBottomActions"
:label="t('cancel')"
:icon="showBottomActions ? undefined : 'check'"
rounded
no-caps
flat
v-close-popup
>
<QTooltip>{{ t('cancel') }}</QTooltip>
</QBtn>
<QBtn
v-if="defaultActions"
:label="t('save')"
:icon="showBottomActions ? undefined : 'check'"
rounded
no-caps
flat
:disabled="!showBottomActions && !hasChanges"
@click="submit()"
data-cy="formModelDefaultSaveButton"
>
<QTooltip>{{ t('save') }}</QTooltip>
</QBtn>
<slot name="actions" :data="formData" />
</component>
</QForm>
<QSpinner v-else color="primary" size="3em" :thickness="2" />
</QCard>
</template>
<style lang="scss" scoped>
.form-container {
width: 100%;
height: max-content;
padding: 32px;
max-width: 544px;
display: flex;
justify-content: center;
}
.form {
display: flex;
flex-direction: column;
width: 100%;
}
</style>

View File

@ -5,32 +5,26 @@ import { useRouter, useRoute } from 'vue-router';
import VnInput from 'src/components/common/VnInput.vue';
import VnSelect from 'src/components/common/VnSelect.vue';
import VnForm from 'src/components/common/VnForm.vue';
import FormModel from 'src/components/common/FormModel.vue';
import { useUserStore } from 'stores/user';
import { useAppStore } from 'stores/app';
import { storeToRefs } from 'pinia';
const router = useRouter();
const route = useRoute();
const { t } = useI18n();
const jApi = inject('jApi');
const api = inject('api');
const appStore = useAppStore();
const userStore = useUserStore();
const { isHeaderMounted } = storeToRefs(appStore);
const vnFormRef = ref(null);
const countriesOptions = ref([]);
const provincesOptions = ref([]);
const pks = { id: route.params.id };
const isEditMode = route.params.id !== '0';
const fetchAddressDataSql = {
query: `
SELECT a.id, a.street, a.nickname, a.city, a.postalCode, a.provinceFk, p.countryFk
FROM myAddress a
LEFT JOIN vn.province p ON p.id = a.provinceFk
WHERE a.id = #address
`,
params: { address: route.params.id }
};
const editAddressData = ref(null);
const showForm = ref(false);
watch(
() => vnFormRef?.value?.formData?.countryFk,
@ -40,23 +34,49 @@ watch(
const goBack = () => router.push({ name: 'addressesList' });
const getCountries = async () => {
countriesOptions.value = await jApi.query(
`SELECT id, name FROM vn.country
ORDER BY name`
);
const filter = { fields: ['id', 'name'], order: 'name' };
const { data } = await api.get('Countries', {
params: { filter: JSON.stringify(filter) }
});
countriesOptions.value = data;
};
const getProvinces = async countryFk => {
if (!countryFk) return;
provincesOptions.value = await jApi.query(
`SELECT id, name FROM vn.province
WHERE countryFk = #id
ORDER BY name`,
{ id: countryFk }
);
const filter = {
where: { countryFk },
fields: ['id', 'name'],
order: 'name'
};
const { data } = await api.get('Provinces', {
params: { filter: JSON.stringify(filter) }
});
provincesOptions.value = data;
};
onMounted(() => getCountries());
const getAddressDetails = async () => {
const { data } = await api.get(`Addresses/${route.params.id}`);
if (!data) return;
const { nickname, street, city, postalCode, province, provinceFk } = data;
editAddressData.value = {
nickname,
street,
city,
postalCode,
countryFk: province?.countryFk,
provinceFk
};
};
onMounted(async () => {
if (isEditMode) {
await getAddressDetails();
}
getCountries();
showForm.value = true;
});
</script>
<template>
@ -74,19 +94,21 @@ onMounted(() => getCountries());
</QTooltip>
</QBtn>
</Teleport>
<VnForm
<FormModel
v-if="showForm"
ref="vnFormRef"
:fetch-form-data-sql="fetchAddressDataSql"
:columns-to-ignore-update="['countryFk']"
:create-model-default="{
field: 'clientFk',
value: 'account.myUser_getId()'
}"
:pks="pks"
:is-edit-mode="isEditMode"
:url-create="
!isEditMode
? `Clients/${userStore?.user?.id}/createAddress`
: ''
"
:url-update="
isEditMode
? `Clients/${userStore?.user?.id}/updateAddress/${route?.params?.id}`
: ''
"
:form-initial-data="editAddressData"
:title="t(isEditMode ? 'editAddress' : 'addAddress')"
table="myAddress"
schema="hedera"
@on-data-saved="goBack()"
>
<template #form="{ data }">
@ -125,7 +147,7 @@ onMounted(() => getCountries());
data-cy="addressFormProvince"
/>
</template>
</VnForm>
</FormModel>
</QPage>
</template>

View File

@ -10,13 +10,16 @@ import useNotify from 'src/composables/useNotify.js';
import { useVnConfirm } from 'src/composables/useVnConfirm.js';
import { useAppStore } from 'stores/app';
import { storeToRefs } from 'pinia';
import { useUserStore } from 'stores/user';
const router = useRouter();
const jApi = inject('jApi');
const api = inject('api');
const { notify } = useNotify();
const { t } = useI18n();
const { openConfirmationModal } = useVnConfirm();
const appStore = useAppStore();
const userStore = useUserStore();
const { isHeaderMounted } = storeToRefs(appStore);
const addresses = ref([]);
@ -65,16 +68,13 @@ const changeDefaultAddress = async () => {
notify(t('defaultAddressModified'), 'positive');
};
const removeAddress = async id => {
async function removeAddress(address) {
try {
await jApi.execQuery(
`START TRANSACTION;
UPDATE hedera.myAddress SET isActive = FALSE
WHERE ((id = #id));
SELECT isActive FROM hedera.myAddress WHERE ((id = #id));
COMMIT`,
await api.patch(
`/Clients/${userStore?.user?.id}/updateAddress/${address.id}`,
{
id
...address,
isActive: false
}
);
getActiveAddresses();
@ -82,7 +82,7 @@ const removeAddress = async id => {
} catch (error) {
console.error('Error removing address:', error);
}
};
}
onMounted(async () => {
getDefaultAddress();
@ -145,7 +145,7 @@ onMounted(async () => {
openConfirmationModal(
null,
t('confirmDeleteAddress'),
() => removeAddress(address.id)
() => removeAddress(address)
)
"
>

View File

@ -46,15 +46,28 @@ describe('PendingOrders', () => {
.should('contain', data.postcode);
};
it('should fail if we enter a wrong postcode', () => {
cy.dataCy('newAddressBtn').should('exist');
cy.dataCy('newAddressBtn').click();
cy.dataCy('formModelDefaultSaveButton').should('exist');
cy.dataCy('formModelDefaultSaveButton').should('be.disabled');
const addressFormData = getRandomAddressFormData();
fillFormWithData(addressFormData);
cy.dataCy('formModelDefaultSaveButton').should('not.be.disabled');
cy.dataCy('formModelDefaultSaveButton').click();
cy.checkNotify('negative');
});
it('should create a new address', () => {
cy.dataCy('newAddressBtn').should('exist');
cy.dataCy('newAddressBtn').click();
cy.dataCy('formDefaultSaveButton').should('exist');
cy.dataCy('formDefaultSaveButton').should('be.disabled');
cy.dataCy('formModelDefaultSaveButton').should('exist');
cy.dataCy('formModelDefaultSaveButton').should('be.disabled');
const addressFormData = getRandomAddressFormData();
addressFormData.postcode = '46460'; // Usamos un postcode válido
fillFormWithData(addressFormData);
cy.dataCy('formDefaultSaveButton').should('not.be.disabled');
cy.dataCy('formDefaultSaveButton').click();
cy.dataCy('formModelDefaultSaveButton').should('not.be.disabled');
cy.dataCy('formModelDefaultSaveButton').click();
cy.checkNotify('positive', 'Datos guardados');
verifyAddressCardData(addressFormData);
});
@ -71,9 +84,10 @@ describe('PendingOrders', () => {
});
// Fill form with new data
const addressFormData = getRandomAddressFormData();
addressFormData.postcode = '46460'; // Usamos un postcode válido
fillFormWithData(addressFormData);
cy.dataCy('formDefaultSaveButton').should('not.be.disabled');
cy.dataCy('formDefaultSaveButton').click();
cy.dataCy('formModelDefaultSaveButton').should('not.be.disabled');
cy.dataCy('formModelDefaultSaveButton').click();
cy.checkNotify('positive', 'Datos guardados');
verifyAddressCardData(addressFormData);
});

View File

@ -90,5 +90,6 @@ Cypress.Commands.add('setConfirmDialog', () => {
});
Cypress.Commands.add('checkNotify', (status, content) => {
cy.dataCy(`${status}Notify`).should('contain', content);
if (content) cy.dataCy(`${status}Notify`).should('contain', content);
else cy.dataCy(`${status}Notify`).should('exist');
});