feature/Address-view-refactor #123

Merged
jsegarra merged 6 commits from wbuezas/hedera-web-mindshore:feature/Address-view-refactor into beta 2025-03-21 14:40:28 +00:00
8 changed files with 322 additions and 119 deletions
Showing only changes of commit bd1e9b7037 - Show all commits

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'],
wbuezas marked this conversation as resolved
Review

porque lo quitamos?

porque lo quitamos?
Review

Porque es innecesario, ya hacemos error handling en el archivo de axios, por esto aparecian duplicados los notify de errores.

Porque es innecesario, ya hacemos error handling en el archivo de axios, por esto aparecian duplicados los notify de errores.
// 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');
});