0
1
Fork 0

Merge pull request 'Address details and VnForm' (!72) from wbuezas/hedera-web-mindshore:feature/AddressDetails into 4922-vueMigration

Reviewed-on: verdnatura/hedera-web#72
Reviewed-by: Javier Segarra <jsegarra@verdnatura.es>
This commit is contained in:
Javier Segarra 2024-07-24 14:19:39 +00:00
commit fb267b910b
12 changed files with 760 additions and 56 deletions

View File

@ -38,6 +38,7 @@ export default boot(({ app }) => {
app.config.globalProperties.$jApi = jApi;
app.provide('jApi', jApi);
app.provide('api', api);
});
export { api, jApi };

View File

@ -4,7 +4,7 @@ import messages from 'src/i18n';
const i18n = createI18n({
locale: navigator.language || navigator.userLanguage,
fallbackLocale: 'en',
fallbackLocale: 'en-US',
globalInjection: true,
missingWarn: false,
fallbackWarn: false,
@ -17,7 +17,6 @@ const i18n = createI18n({
export default boot(({ app }) => {
// Set i18n instance on app
app.use(i18n);
window.i18n = i18n.global;
});
export { i18n };

View File

@ -0,0 +1,198 @@
<script setup>
import { ref, inject, onMounted, computed } from 'vue';
import { useI18n } from 'vue-i18n';
import useNotify from 'src/composables/useNotify.js';
import {
generateUpdateSqlQuery,
generateInsertSqlQuery
} from 'src/js/db/sqlService.js';
const props = defineProps({
title: {
type: String,
default: ''
},
table: {
type: String,
default: ''
},
schema: {
type: String,
default: ''
},
// Objeto que define las pks de la tabla. Usado para generar las queries sql correspondientes.
// Debe ser definido como un objeto de pares key-value, donde la clave es el nombre de la columna de la pk.
pks: {
type: Object,
default: () => {}
},
createModelDefault: {
type: Object,
default: () => ({
field: '',
value: ''
})
},
// Objeto que contiene la consulta SQL y los parámetros necesarios para obtener los datos iniciales del formulario.
// `query` debe ser una cadena de texto que representa la consulta SQL.
// `params` es un objeto que mapea los parámetros de la consulta a sus valores.
fetchFormDataSql: {
type: Object,
default: () => ({
query: '',
params: {}
})
},
// Objeto con los datos iniciales del form, si este objeto es definido, no se ejecuta la query fetchFormDataSql
formInitialData: {
type: Object,
default: () => {}
},
// Array de columnas que no se deben actualizar
columnsToIgnoreUpdate: {
type: Array,
default: () => []
},
autoLoad: {
type: Boolean,
default: true
},
isEditMode: {
type: Boolean,
default: true
},
defaultActions: {
type: Boolean,
default: true
}
});
const emit = defineEmits(['onDataSaved']);
const { t } = useI18n();
const jApi = inject('jApi');
const { notify } = useNotify();
const loading = ref(false);
const formData = ref({});
const addressFormRef = ref(null);
const modelInfo = ref(null);
// Array de nombre de columnas de la tabla
const tableColumns = computed(
() => modelInfo.value?.columns.map(col => col.name) || []
);
// Array de nombre de columnas que fueron actualizadas y no estan en columnsToIgnoreUpdate
const updatedColumns = computed(() => {
return tableColumns.value.filter(
colName =>
modelInfo.value?.data[0][colName] !== formData.value[colName] &&
!props.columnsToIgnoreUpdate.includes(colName)
);
});
const fetchFormData = async () => {
if (!props.fetchFormDataSql.query) return;
loading.value = true;
const { results } = await jApi.execQuery(
props.fetchFormDataSql.query,
props.fetchFormDataSql.params
);
modelInfo.value = results[0];
if (!modelInfo.value.data[0]) {
modelInfo.value.data[0] = {};
// Si no existen datos iniciales, se inicializan con null, en base a las columnas de la tabla
modelInfo.value.columns.forEach(
col => (modelInfo.value.data[0][col.name] = null)
);
}
formData.value = { ...modelInfo.value.data[0] };
loading.value = false;
};
const submit = async () => {
try {
const sqlQuery = generateSqlQuery();
await jApi.execQuery(sqlQuery, props.pks);
emit('onDataSaved');
notify(t('dataSaved'), 'positive');
} catch (error) {
console.error('Error updating address:', error);
notify(t('addressNotUpdated'), 'negative');
}
};
const generateSqlQuery = () => {
if (props.isEditMode) {
return generateUpdateSqlQuery(
props.schema,
props.table,
props.pks,
updatedColumns.value,
formData.value
);
} else {
return generateInsertSqlQuery(
props.schema,
props.table,
formData.value,
updatedColumns.value,
props.createModelDefault
);
}
};
onMounted(async () => {
if (!props.formInitialData && props.autoLoad) {
fetchFormData();
}
});
defineExpose({
formData,
submit
});
</script>
<template>
<Teleport :to="$actions">
<QBtn
v-if="defaultActions"
:label="t('save')"
type="submit"
icon="check"
rounded
no-caps
:disabled="!updatedColumns.length"
@click="addressFormRef.submit()"
/>
</Teleport>
<QCard class="form-container">
<QForm
v-if="!loading"
ref="addressFormRef"
class="column full-width q-gutter-y-xs"
@submit="submit()"
>
<span class="text-h6 text-bold">
{{ title }}
</span>
<slot name="form" :data="formData" />
</QForm>
<QSpinner v-else color="primary" size="3em" :thickness="2" />
</QCard>
</template>
<style lang="scss" scoped>
.form-container {
width: 100%;
height: max-content;
max-width: 544px;
padding: 32px;
display: flex;
justify-content: center;
}
</style>

View File

@ -0,0 +1,124 @@
<script setup>
import { computed, ref } from 'vue';
import { useI18n } from 'vue-i18n';
const emit = defineEmits([
'update:modelValue',
'update:options',
'keyup.enter',
'remove'
]);
const $props = defineProps({
modelValue: {
type: [String, Number],
default: null
},
isOutlined: {
type: Boolean,
default: false
},
info: {
type: String,
default: ''
},
clearable: {
type: Boolean,
default: true
}
});
const { t } = useI18n();
const requiredFieldRule = val => !!val || t('globals.fieldRequired');
const vnInputRef = ref(null);
const value = computed({
get() {
return $props.modelValue;
},
set(value) {
emit('update:modelValue', value);
}
});
const hover = ref(false);
const styleAttrs = computed(() => {
return $props.isOutlined
? { dense: true, outlined: true, rounded: true }
: {};
});
const focus = () => {
vnInputRef.value.focus();
};
defineExpose({
focus
});
const inputRules = [
val => {
const { min } = vnInputRef.value.$attrs;
if (min >= 0) {
if (Math.floor(val) < min) return t('inputMin', { value: min });
}
}
];
</script>
<template>
<div
:rules="$attrs.required ? [requiredFieldRule] : null"
@mouseover="hover = true"
@mouseleave="hover = false"
>
<QInput
ref="vnInputRef"
v-model="value"
v-bind="{ ...$attrs, ...styleAttrs }"
:type="$attrs.type"
:class="{ required: $attrs.required }"
:clearable="false"
:rules="inputRules"
:lazy-rules="true"
hide-bottom-space
@keyup.enter="emit('keyup.enter')"
>
<template v-if="$slots.prepend" #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"
@click="
() => {
value = null;
emit('remove');
}
"
/>
<QIcon v-if="info" name="info">
<QTooltip max-width="350px">
{{ info }}
</QTooltip>
</QIcon>
</template>
</QInput>
</div>
</template>
<i18n lang="yaml">
en-US:
inputMin: Must be more than {value}
es-ES:
inputMin: Must be more than {value}
ca-ES:
inputMin: Ha de ser més gran que {value}
fr-FR:
inputMin: Doit être supérieur à {value}
pt-PT:
inputMin: Deve ser maior que {value}
</i18n>

View File

@ -0,0 +1,188 @@
<script setup>
import { ref, toRefs, computed, watch, onMounted } from 'vue';
import { useI18n } from 'vue-i18n';
const emit = defineEmits(['update:modelValue', 'update:options']);
const $props = defineProps({
modelValue: {
type: [String, Number, Object],
default: null
},
options: {
type: Array,
default: () => []
},
optionLabel: {
type: [String],
default: 'name'
},
optionValue: {
type: String,
default: 'id'
},
optionFilter: {
type: String,
default: null
},
dataQuery: {
type: String,
default: ''
},
filterOptions: {
type: [Array],
default: () => []
},
isClearable: {
type: Boolean,
default: true
},
defaultFilter: {
type: Boolean,
default: true
},
fields: {
type: Array,
default: null
},
where: {
type: Object,
default: null
},
sortBy: {
type: String,
default: null
},
limit: {
type: [Number, String],
default: '30'
},
focusOnMount: {
type: Boolean,
default: false
},
useLike: {
type: Boolean,
default: true
}
});
const { t } = useI18n();
const requiredFieldRule = val => val ?? t('globals.fieldRequired');
const { optionLabel, optionValue, options } = toRefs($props);
const myOptions = ref([]);
const myOptionsOriginal = ref([]);
const vnSelectRef = ref();
const lastVal = ref();
const value = computed({
get() {
return $props.modelValue;
},
set(value) {
emit('update:modelValue', value);
}
});
watch(options, newValue => {
setOptions(newValue);
});
onMounted(() => {
setOptions(options.value);
if ($props.focusOnMount) {
setTimeout(() => vnSelectRef.value.showPopup(), 300);
}
});
function setOptions(data) {
myOptions.value = JSON.parse(JSON.stringify(data));
myOptionsOriginal.value = JSON.parse(JSON.stringify(data));
}
function filter(val, options) {
const search = val.toString().toLowerCase();
if (!search) return options;
return options.filter(row => {
if ($props.filterOptions.length) {
return $props.filterOptions.some(prop => {
const propValue = String(row[prop]).toLowerCase();
return propValue.includes(search);
});
}
const id = row.id;
const optionLabel = String(row[$props.optionLabel]).toLowerCase();
return id === search || optionLabel.includes(search);
});
}
async function filterHandler(val, update) {
if (!val && lastVal.value === val) {
lastVal.value = val;
return update();
}
lastVal.value = val;
if (!$props.defaultFilter) return update();
const newOptions = filter(val, myOptionsOriginal.value);
update(
() => {
myOptions.value = newOptions;
},
ref => {
if (val !== '' && ref.options.length > 0) {
ref.setOptionIndex(-1);
ref.moveOptionSelection(1, true);
}
}
);
}
</script>
<template>
<QSelect
v-model="value"
:options="myOptions"
:option-label="optionLabel"
:option-value="optionValue"
v-bind="$attrs"
emit-value
map-options
use-input
@filter="filterHandler"
hide-selected
fill-input
ref="vnSelectRef"
lazy-rules
:class="{ required: $attrs.required }"
:rules="$attrs.required ? [requiredFieldRule] : null"
virtual-scroll-slice-size="options.length"
>
<template v-if="isClearable" #append>
<QIcon
v-show="value"
name="close"
@click.stop="value = null"
class="cursor-pointer"
size="xs"
/>
</template>
<template
v-for="(_, slotName) in $slots"
#[slotName]="slotData"
:key="slotName"
>
<slot :name="slotName" v-bind="slotData ?? {}" :key="slotName" />
</template>
</QSelect>
</template>
<style scoped lang="scss">
.q-field--outlined {
max-width: 100%;
}
</style>

View File

@ -74,5 +74,6 @@ export default {
user: 'User',
addresses: 'Addresses',
addressEdit: 'Edit address',
dataSaved: 'Data saved'
dataSaved: 'Data saved',
save: 'Save'
};

View File

@ -72,7 +72,17 @@ export default {
items: 'Artículos',
config: 'Configuración',
user: 'Usuario',
password: 'Contraseña',
remindMe: 'Recuérdame',
logInAsGuest: 'Entrar como invitado',
logIn: 'Iniciar sesión',
loginMail: 'info@verdnatura.es',
loginPhone: '+34 963 242 100',
haveForgottenPassword: '¿Has olvidado tu contraseña?',
notACustomerYet: '¿Todavía no eres cliente?',
signUp: 'Registrarme',
addresses: 'Direcciones',
addressEdit: 'Editar dirección',
dataSaved: 'Datos guardados'
dataSaved: 'Datos guardados',
save: 'Guardar'
};

42
src/js/db/sqlService.js Normal file
View File

@ -0,0 +1,42 @@
export const generateUpdateSqlQuery = (
schema,
table,
pks,
columnsUpdated,
formData
) => {
const setClauses = columnsUpdated
.map(colName => `${colName} = '${formData[colName]}'`)
.join(', ');
const whereClause = Object.keys(pks)
.map(pk => `${pk} = ${pks[pk]}`)
.join(' AND ');
return `
START TRANSACTION;
UPDATE ${schema}.${table} SET ${setClauses} WHERE (${whereClause});
SELECT ${columnsUpdated.join(', ')} FROM ${schema}.${table} WHERE (${whereClause});
COMMIT;
`;
};
export const generateInsertSqlQuery = (
schema,
table,
formData,
columnsUpdated,
createModelDefault
) => {
const columns = [createModelDefault.field, ...columnsUpdated].join(', ');
const values = [
createModelDefault.value,
...columnsUpdated.map(colName => `'${formData[colName]}'`)
].join(', ');
return `
START TRANSACTION;
INSERT INTO ${schema}.${table} (${columns}) VALUES (${values});
SELECT id, ${columnsUpdated.join(', ')} FROM ${schema}.${table} WHERE (id = LAST_INSERT_ID());
COMMIT;
`;
};

View File

@ -1,33 +1,169 @@
<script setup>
import { ref, inject, onMounted, watch } from 'vue';
import { useI18n } from 'vue-i18n';
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';
const router = useRouter();
const route = useRoute();
const { t } = useI18n();
const jApi = inject('jApi');
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 }
};
watch(
() => vnFormRef?.value?.formData?.countryFk,
async val => await getProvinces(val)
);
const goBack = () => router.push({ name: 'AddressesList' });
const getCountries = async () => {
countriesOptions.value = await jApi.query(
`SELECT id, name FROM vn.country
ORDER BY name`
);
};
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 }
);
};
onMounted(() => getCountries());
</script>
<template>
<Teleport :to="$actions">
<QBtn icon="close" :label="t('back')" rounded no-caps />
<QBtn icon="check" :label="t('accept')" rounded no-caps />
</Teleport>
<QPage>//TODO: VISTA A DESARROLLAR!</QPage>
<QPage class="q-pa-md flex justify-center">
<Teleport :to="$actions">
<QBtn
:label="t('back')"
icon="close"
rounded
no-caps
@click="goBack()"
/>
</Teleport>
<VnForm
ref="vnFormRef"
:fetchFormDataSql="fetchAddressDataSql"
:columnsToIgnoreUpdate="['countryFk']"
:createModelDefault="{
field: 'clientFk',
value: 'account.myUser_getId()'
}"
:pks="pks"
:isEditMode="isEditMode"
:title="t('addEditAddress')"
table="myAddress"
schema="hedera"
@onDataSaved="goBack()"
>
<template #form="{ data }">
<VnInput v-model="data.nickname" :label="t('name')" />
<VnInput v-model="data.street" :label="t('address')" />
<VnInput v-model="data.city" :label="t('city')" />
<VnInput v-model="data.postalCode" :label="t('postalCode')" />
<VnSelect
v-model="data.countryFk"
:label="t('country')"
:options="countriesOptions"
@update:modelValue="data.provinceFk = null"
/>
<VnSelect
v-model="data.provinceFk"
:label="t('province')"
:options="provincesOptions"
/>
</template>
</VnForm>
</QPage>
</template>
<style lang="scss" scoped></style>
<style lang="scss" scoped>
.form-container {
width: 100%;
height: max-content;
max-width: 544px;
padding: 32px;
}
</style>
<i18n lang="yaml">
en-US:
back: Back
accept: Accept
addEditAddress: Add or edit address
name: Consignee
address: Address
city: City
postalCode: Zip code
country: Country
province: Province
addressChangedSuccessfully: Address changed successfully
es-ES:
back: Volver
accept: Aceptar
addEditAddress: Añadir o modificar dirección
name: Consignatario
address: Morada
city: Ciudad
postalCode: Código postal
country: País
province: Distrito
addressChangedSuccessfully: Dirección modificada correctamente
ca-ES:
back: Tornar
accept: Acceptar
addEditAddress: Afegir o modificar adreça
name: Consignatari
address: Direcció
city: Ciutat
postalCode: Codi postal
country: País
province: Província
addressChangedSuccessfully: Adreça modificada correctament
fr-FR:
back: Retour
accept: Accepter
addEditAddress: Ajouter ou modifier l'adresse
name: Destinataire
address: Numéro Rue
city: Ville
postalCode: Code postal
country: Pays
province: Province
addressChangedSuccessfully: Adresse modifié avec succès
pt-PT:
back: Voltar
accept: Aceitar
addEditAddress: Adicionar ou modificar morada
name: Consignatario
address: Morada
city: Concelho
postalCode: Código postal
country: País
province: Distrito
addressChangedSuccessfully: Morada modificada corretamente
</i18n>

View File

@ -128,7 +128,7 @@ onMounted(async () => {
</div>
</div>
</QItemSection>
<QItemSection class="actions-wrapper invisible" side>
<QItemSection class="actions-wrapper" side>
<QBtn
icon="delete"
flat
@ -154,9 +154,14 @@ onMounted(async () => {
</template>
<style lang="scss" scoped>
.address-item:hover {
.address-item {
.actions-wrapper {
visibility: visible !important;
visibility: hidden;
}
&:hover {
.actions-wrapper {
visibility: visible;
}
}
}
</style>

View File

@ -1,3 +1,38 @@
<script setup>
import { useI18n } from 'vue-i18n';
import { userStore } from 'stores/user';
import { onMounted, ref } from 'vue';
import useNotify from 'src/composables/useNotify.js';
import { useRouter, useRoute } from 'vue-router';
const { notify } = useNotify();
const t = useI18n();
const user = userStore();
const route = useRoute();
const router = useRouter();
const email = ref(null);
const password = ref(null);
const remember = ref(false);
const showPwd = ref(false);
onMounted(() => {
if (route.query.emailConfirmed !== undefined) {
notify({
message: t('emailConfirmedSuccessfully'),
type: 'positive'
});
}
if (route.params.email) {
email.value = route.params.email;
password.value.focus();
}
});
async function onLogin() {
await user.login(email.value, password.value, remember.value);
router.push('/');
}
</script>
<template>
<div class="main">
<div class="header">
@ -10,9 +45,8 @@
<QInput v-model="email" :label="$t('user')" autofocus />
<QInput
v-model="password"
ref="password"
:label="$t('password')"
:type="showPwd ? 'password' : 'text'"
:type="!showPwd ? 'password' : 'text'"
>
<template v-slot:append>
<QIcon
@ -69,7 +103,11 @@
</a>
</p>
<p class="contact">
{{ $t('loginPhone') }} · {{ $t('loginMail') }}
<a :href="`tel:${$t('loginPhone')}`">
{{ $t('loginPhone') }}
</a>
·
<a :href="`mailto:${$t('loginMail')}`">{{ $t('loginMail') }}</a>
</p>
</div>
</div>
@ -121,44 +159,6 @@ a {
}
</style>
<script>
import { userStore } from 'stores/user';
export default {
name: 'VnLogin',
data() {
return {
user: userStore(),
email: '',
password: '',
remember: false,
showPwd: true
};
},
mounted() {
if (this.$route.query.emailConfirmed !== undefined) {
this.$q.notify({
message: this.$t('emailConfirmedSuccessfully'),
type: 'positive'
});
}
if (this.$route.params.email) {
this.email = this.$route.params.email;
this.$refs.password.focus();
}
},
methods: {
async onLogin() {
await this.user.login(this.email, this.password, this.remember);
this.$router.push('/');
}
}
};
</script>
<i18n lang="yaml">
en-US:
user: User

View File

@ -6,7 +6,7 @@ const routes = [
{
name: 'login',
path: '/login/:email?',
component: () => import('pages/Login/Login.vue')
component: () => import('pages/Login/LoginView.vue')
},
{
name: 'rememberPassword',
@ -60,7 +60,7 @@ const routes = [
component: () => import('pages/Account/AccountConf.vue')
},
{
name: 'Addresses',
name: 'AddressesList',
path: '/account/address-list',
component: () => import('pages/Account/AddressList.vue')
},