feat: #8217 implement onBeforeSave function for form data processing #1631

Open
jsegarra wants to merge 25 commits from 8217_mapper into dev
12 changed files with 147 additions and 104 deletions

View File

@ -1,6 +1,15 @@
<script setup> <script setup>
import axios from 'axios'; import axios from 'axios';
import { onMounted, onUnmounted, computed, ref, watch, nextTick, useAttrs } from 'vue'; import {
onMounted,
onUnmounted,
computed,
ref,
watch,
nextTick,
useAttrs,
toRef,
} from 'vue';
import { onBeforeRouteLeave, useRouter, useRoute } from 'vue-router'; import { onBeforeRouteLeave, useRouter, useRoute } from 'vue-router';
import { useI18n } from 'vue-i18n'; import { useI18n } from 'vue-i18n';
import { useQuasar } from 'quasar'; import { useQuasar } from 'quasar';
@ -12,7 +21,7 @@ import SkeletonForm from 'components/ui/SkeletonForm.vue';
import VnConfirm from './ui/VnConfirm.vue'; import VnConfirm from './ui/VnConfirm.vue';
import { tMobile } from 'src/composables/tMobile'; import { tMobile } from 'src/composables/tMobile';
import { useArrayData } from 'src/composables/useArrayData'; import { useArrayData } from 'src/composables/useArrayData';
import { getDifferences, getUpdatedValues } from 'src/filters'; import { onBeforeSave } from 'src/filters';
const { push } = useRouter(); const { push } = useRouter();
const quasar = useQuasar(); const quasar = useQuasar();
const state = useState(); const state = useState();
@ -67,7 +76,7 @@ const $props = defineProps({
}, },
mapper: { mapper: {
type: Function, type: Function,
default: null, default: onBeforeSave,
}, },
clearStoreOnUnmount: { clearStoreOnUnmount: {
type: Boolean, type: Boolean,
@ -140,6 +149,7 @@ const submitForm = async (evt) => {
await save(evt); await save(evt);
} }
}; };
const mapper = toRef($props, 'mapper');
Review

Asi es reactivo??

Asi es reactivo??
Review

Reactivo? porque, para que??

Reactivo? porque, para que??
onMounted(async () => { onMounted(async () => {
nextTick(() => (componentIsRendered.value = true)); nextTick(() => (componentIsRendered.value = true));
@ -223,25 +233,8 @@ async function fetch() {
} }
} }
async function save() { async function handleResponse(promise) {
if ($props.observeFormChanges && !hasChanges.value) const response = await Promise.allSettled([promise]);
return notify('globals.noChanges', 'negative');
isLoading.value = true;
try {
formData.value = trimData(formData.value);
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 || arrayData.store.url;
const response = await Promise.resolve(
$props.saveFn ? $props.saveFn(body) : axios[method](url, body),
);
if ($props.urlCreate) notify('globals.dataCreated', 'positive');
updateAndEmit('onDataSaved', { updateAndEmit('onDataSaved', {
val: formData.value, val: formData.value,
res: response?.data, res: response?.data,
@ -249,6 +242,38 @@ async function save() {
}); });
if ($props.reload) await arrayData.fetch({}); if ($props.reload) await arrayData.fetch({});
hasChanges.value = false; hasChanges.value = false;
}
async function create() {
const promise = $props.saveFn
? $props.saveFn(formData.value)
: axios.post($props.urlCreate, formData.value);
await handleResponse(promise);
notify('globals.dataCreated', 'positive');
}
Review

La parte de la notificacion la pondria directamente aqui, y quitaria el if de handleResponse

La parte de la notificacion la pondria directamente aqui, y quitaria el if de `handleResponse`
Review

Hay una notificación que la he cambiado de sitio, no se si es la que tu dices

Hay una notificación que la he cambiado de sitio, no se si es la que tu dices
async function update() {
const body = mapper.value
? mapper.value(originalData.value, formData.value)
: formData.value;
const url = $props.urlUpdate || $props.url || arrayData.store.url;
const promise = $props.saveFn ? $props.saveFn(body) : axios.patch(url, body);
await handleResponse(promise);
}
async function save() {
if ($props.observeFormChanges && !hasChanges.value)
return notify('globals.noChanges', 'negative');
isLoading.value = true;
try {
formData.value = trimData(formData.value);
if ($props.urlCreate) {
await create();
} else {
await update();
}
Review

if ($props.urlCreate) ?

`if ($props.urlCreate)` ?
} finally { } finally {
isLoading.value = false; isLoading.value = false;
} }
@ -297,12 +322,7 @@ function trimData(data) {
} }
return data; return data;
} }
function onBeforeSave(formData, originalData) {
return getUpdatedValues(
Object.keys(getDifferences(formData, originalData)),
formData,
);
}
async function onKeyup(evt) { async function onKeyup(evt) {
if (evt.key === 'Enter' && !$props.preventSubmit) { if (evt.key === 'Enter' && !$props.preventSubmit) {
const input = evt.target; const input = evt.target;
@ -333,16 +353,15 @@ defineExpose({
<template> <template>
<div class="column items-center full-width"> <div class="column items-center full-width">
<QForm <QForm
id="formModel"
v-on="$attrs" v-on="$attrs"
ref="myForm" ref="myForm"
v-if="formData" v-if="formData"
@submit.prevent="save" @submit.prevent="save"
@keyup.prevent="onKeyup" @keyup.prevent="onKeyup"
@reset="reset" @reset="reset"
class="q-pa-md" class="full-width q-pa-md"
:style="maxWidth ? 'max-width: ' + maxWidth : ''" :style="maxWidth ? 'max-width: ' + maxWidth : ''"
id="formModel"
:mapper="onBeforeSave"
> >
<QCard> <QCard>
<slot <slot
@ -432,9 +451,6 @@ defineExpose({
.q-notifications { .q-notifications {
color: black; color: black;
} }
#formModel {
width: 100%;
}
.q-card { .q-card {
padding: 32px; padding: 32px;

View File

@ -115,6 +115,7 @@ describe('CrudModel', () => {
expect(result).toEqual({ expect(result).toEqual({
a: null, a: null,
b: 4, b: 4,
c: 3,
d: 5, d: 5,
}); });
}); });
@ -241,7 +242,7 @@ describe('CrudModel', () => {
await vm.saveChanges(data); await vm.saveChanges(data);
expect(postMock).toHaveBeenCalledWith(vm.url + '/crud', data); expect(postMock).toHaveBeenCalledWith(`${vm.url}/crud`, data);
expect(vm.isLoading).toBe(false); expect(vm.isLoading).toBe(false);
expect(vm.hasChanges).toBe(false); expect(vm.hasChanges).toBe(false);
expect(vm.originalData).toEqual(JSON.parse(JSON.stringify(vm.formData))); expect(vm.originalData).toEqual(JSON.parse(JSON.stringify(vm.formData)));

View File

@ -3,11 +3,17 @@ import { createWrapper } from 'app/test/vitest/helper';
import { default as axios } from 'axios'; import { default as axios } from 'axios';
import FormModel from 'src/components/FormModel.vue'; import FormModel from 'src/components/FormModel.vue';
import { useState } from 'src/composables/useState';
describe('FormModel', () => { describe('FormModel', () => {
const model = 'mockModel'; const model = 'mockModel';
const url = 'mockUrl'; const url = 'mockUrl';
const formInitialData = { mockKey: 'mockVal' }; const formInitialData = { mockKey: 'mockVal' };
let state;
beforeEach(() => {
state = useState();
state.set(model, formInitialData);
});
describe('modelValue', () => { describe('modelValue', () => {
it('should use the provided model', () => { it('should use the provided model', () => {
@ -94,30 +100,39 @@ describe('FormModel', () => {
expect(vm.hasChanges).toBe(false); expect(vm.hasChanges).toBe(false);
}); });
it('should call axios.patch with the right data', async () => { it('should call axios.post with the right data', async () => {
const spy = vi.spyOn(axios, 'patch').mockResolvedValue({ data: {} }); const spy = vi.spyOn(axios, 'post').mockResolvedValue({ data: {} });
const { vm } = mount({ propsData: { url, model } }); const urlCreate = 'mockUrlCreate';
const { vm } = mount({ propsData: { url, urlCreate, model } });
vm.formData = {}; vm.formData = {};
await vm.$nextTick(); await vm.$nextTick();
vm.formData = { mockKey: 'newVal' }; const formData = { mockKey: 'newVal', mockKey2: 'newVal2' };
vm.formData = formData;
await vm.$nextTick(); await vm.$nextTick();
await vm.save(); await vm.save();
expect(spy).toHaveBeenCalled(); expect(spy).toHaveBeenCalled();
expect(spy).toHaveBeenCalledWith(urlCreate, formData);
vm.formData.mockKey = 'mockVal'; vm.formData.mockKey = 'mockVal';
}); });
it('should call axios.post with the right data', async () => { it('should call axios.patch with the right data', async () => {
const spy = vi.spyOn(axios, 'post').mockResolvedValue({ data: {} }); const spy = vi.spyOn(axios, 'patch').mockResolvedValue({ data: {} });
const { vm } = mount({ const { vm } = mount({
propsData: { url, model, formInitialData, urlCreate: 'mockUrlCreate' }, propsData: {
url,
model,
formInitialData,
},
}); });
await vm.$nextTick(); await vm.$nextTick();
vm.formData.mockKey = 'newVal'; vm.formData.mockKey = 'newVal';
await vm.$nextTick(); await vm.$nextTick();
await vm.save(); await vm.save();
expect(spy).toHaveBeenCalled(); expect(spy).toHaveBeenCalled();
expect(spy).toHaveBeenCalledWith(url, { mockKey: 'newVal' });
vm.formData.mockKey = 'mockVal'; vm.formData.mockKey = 'mockVal';
}); });

View File

@ -21,7 +21,7 @@ watch(
(newValue) => { (newValue) => {
if (!modelValue.value) return; if (!modelValue.value) return;
modelValue.value = formatLocation(newValue) ?? null; modelValue.value = formatLocation(newValue) ?? null;
} },
); );
const mixinRules = [requiredFieldRule]; const mixinRules = [requiredFieldRule];
@ -45,7 +45,7 @@ const formatLocation = (obj, properties = locationProperties) => {
}); });
const filteredParts = parts.filter( const filteredParts = parts.filter(
(part) => part !== null && part !== undefined && part !== '' (part) => part !== null && part !== undefined && part !== '',
); );
return filteredParts.join(', '); return filteredParts.join(', ');

View File

@ -0,0 +1,41 @@
import getDifferences from '../getDifferences';
describe('getDifferences', () => {
it('should handle empty initialData', () => {
const initialData = {};
const formData = { name: 'John' };
expect(getDifferences(initialData, formData)).toEqual({ name: 'John' });
});
it('should detect when formData has a key not present in initialData', () => {
const initialData = { age: 30 };
const formData = { name: 'John' };
expect(getDifferences(initialData, formData)).toEqual({ age: 30, name: 'John' });
});
it('should detect when formData has different value for existing key', () => {
const initialData = { name: 'John', age: 30 };
const formData = { name: 'Jane', age: 30 };
expect(getDifferences(initialData, formData)).toEqual({ name: 'Jane' });
});
it('should detect when formData has null value for existing key', () => {
const initialData = { name: 'John' };
const formData = { name: null };
expect(getDifferences(initialData, formData)).toEqual({ name: null });
});
it('should handle missing keys in formData', () => {
const initialData = { name: 'John', age: 30 };
const formData = { name: 'John' };
expect(getDifferences(initialData, formData)).toEqual({ age: 30 });
});
it('should handle complex objects', () => {
const initialData = { user: { name: 'John', age: 30 } };
const formData = { user: { name: 'John', age: 31 } };
expect(getDifferences(initialData, formData)).toEqual({
user: { name: 'John', age: 31 },
});
});
});

View File

@ -1,19 +1,23 @@
export default function getDifferences(obj1, obj2) { export default function getDifferences(initialData = {}, formData = {}) {
let diff = {}; const diff = {};
delete obj1.$index; delete initialData?.$index;
delete obj2.$index; delete formData?.$index;
for (let key in obj1) { // Añadimos los valores que están en initialData
if (obj2[key] && JSON.stringify(obj1[key]) !== JSON.stringify(obj2[key])) { for (const key in initialData) {
diff[key] = obj2[key]; if (!Object.prototype.hasOwnProperty.call(formData, key)) {
// Caso 1: initialData tiene una clave que no tiene formData
diff[key] = initialData[key];
} else if (JSON.stringify(initialData[key]) !== JSON.stringify(formData[key])) {
// Caso 2 y 3: valores diferentes o null en formData
diff[key] = formData[key];
} }
} }
for (let key in obj2) {
if ( // Añadimos las claves nuevas de formData
obj1[key] === undefined || for (const key in formData) {
JSON.stringify(obj1[key]) !== JSON.stringify(obj2[key]) if (!Object.prototype.hasOwnProperty.call(initialData, key)) {
) { diff[key] = formData[key];
diff[key] = obj2[key];
} }
} }

View File

@ -3,6 +3,7 @@ import toDate from './toDate';
import toDateString from './toDateString'; import toDateString from './toDateString';
import toDateHourMin from './toDateHourMin'; import toDateHourMin from './toDateHourMin';
import toDateHourMinSec from './toDateHourMinSec'; import toDateHourMinSec from './toDateHourMinSec';
import onBeforeSave from './onBeforeSave';
import toRelativeDate from './toRelativeDate'; import toRelativeDate from './toRelativeDate';
import toCurrency from './toCurrency'; import toCurrency from './toCurrency';
import toPercentage from './toPercentage'; import toPercentage from './toPercentage';
@ -19,6 +20,7 @@ import isDialogOpened from './isDialogOpened';
import toCelsius from './toCelsius'; import toCelsius from './toCelsius';
export { export {
onBeforeSave,
getUpdatedValues, getUpdatedValues,
getDifferences, getDifferences,
isDialogOpened, isDialogOpened,

View File

@ -0,0 +1,9 @@
import getDifferences from './getDifferences';
import getUpdatedValues from './getUpdatedValues';
export default function onBeforeSave(originalData, formData) {
return getUpdatedValues(
Object.keys(getDifferences(originalData, formData)),
formData,
);
}

View File

@ -15,13 +15,6 @@ import VnAvatar from 'src/components/ui/VnAvatar.vue';
const route = useRoute(); const route = useRoute();
const { t } = useI18n(); const { t } = useI18n();
const workersOptions = ref([]); const workersOptions = ref([]);
function onBeforeSave(formData, originalData) {
return getUpdatedValues(
Object.keys(getDifferences(formData, originalData)),
formData,
);
}
</script> </script>
<template> <template>
<FetchData <FetchData
@ -34,7 +27,6 @@ function onBeforeSave(formData, originalData) {
<FormModel <FormModel
model="Claim" model="Claim"
:url-update="`Claims/updateClaim/${route.params.id}`" :url-update="`Claims/updateClaim/${route.params.id}`"
:mapper="onBeforeSave"
auto-load auto-load
> >
<template #form="{ data, validate }"> <template #form="{ data, validate }">

View File

@ -8,37 +8,12 @@ import FormModel from 'components/FormModel.vue';
import VnRow from 'components/ui/VnRow.vue'; 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 { getDifferences, getUpdatedValues } from 'src/filters';
const route = useRoute(); const route = useRoute();
const { t } = useI18n(); const { t } = useI18n();
const businessTypes = ref([]); const businessTypes = ref([]);
const contactChannels = ref([]); const contactChannels = ref([]);
const handleSalesModelValue = (val) => {
if (!val) val = '';
return {
or: [
{ id: val },
{ name: val },
{ nickname: { like: '%' + val + '%' } },
{ code: { like: `${val}%` } },
],
};
};
const exprBuilder = (param, value) => {
return {
and: [{ active: { neq: false } }, handleSalesModelValue(value)],
};
};
function onBeforeSave(formData, originalData) {
return getUpdatedValues(
Object.keys(getDifferences(formData, originalData)),
formData,
);
}
</script> </script>
<template> <template>
<FetchData <FetchData
@ -52,12 +27,7 @@ function onBeforeSave(formData, originalData) {
@on-fetch="(data) => (businessTypes = data)" @on-fetch="(data) => (businessTypes = data)"
auto-load auto-load
/> />
<FormModel <FormModel :url-update="`Clients/${route.params.id}`" auto-load model="Customer">
:url-update="`Clients/${route.params.id}`"
auto-load
:mapper="onBeforeSave"
model="Customer"
>
<template #form="{ data, validate }"> <template #form="{ data, validate }">
<VnRow> <VnRow>
<VnInput <VnInput

View File

@ -13,7 +13,6 @@ 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 VnCheckbox from 'src/components/common/VnCheckbox.vue'; import VnCheckbox from 'src/components/common/VnCheckbox.vue';
import { getDifferences, getUpdatedValues } from 'src/filters';
import VnConfirm from 'src/components/ui/VnConfirm.vue'; import VnConfirm from 'src/components/ui/VnConfirm.vue';
const quasar = useQuasar(); const quasar = useQuasar();
@ -31,12 +30,6 @@ function handleLocation(data, location) {
data.provinceFk = provinceFk; data.provinceFk = provinceFk;
data.countryFk = countryFk; data.countryFk = countryFk;
} }
function onBeforeSave(formData, originalData) {
return getUpdatedValues(
Object.keys(getDifferences(formData, originalData)),
formData,
);
}
async function checkEtChanges(data, _, originalData) { async function checkEtChanges(data, _, originalData) {
const equalizatedHasChanged = originalData.isEqualizated != data.isEqualizated; const equalizatedHasChanged = originalData.isEqualizated != data.isEqualizated;
@ -75,7 +68,6 @@ async function acceptPropagate({ isEqualizated }) {
:url-update="`Clients/${route.params.id}/updateFiscalData`" :url-update="`Clients/${route.params.id}/updateFiscalData`"
auto-load auto-load
model="Customer" model="Customer"
:mapper="onBeforeSave"
observe-form-changes observe-form-changes
@on-data-saved="checkEtChanges" @on-data-saved="checkEtChanges"
> >

View File

@ -39,6 +39,7 @@ const entityId = computed(() => {
where: { itemFk: entityId }, where: { itemFk: entityId },
}" }"
@on-fetch="(data) => (itemBotanicalsForm = data)" @on-fetch="(data) => (itemBotanicalsForm = data)"
:mapper="(_, data) => data"
> >
<template #form="{ data }"> <template #form="{ data }">
<VnRow> <VnRow>