Account view refactor
gitea/hedera-web/pipeline/pr-dev There was a failure building this commit Details
gitea/hedera-web/pipeline/pr-beta This commit looks good Details

This commit is contained in:
William Buezas 2025-03-14 13:34:43 -03:00
parent 47c61a7b35
commit 55e0c68576
14 changed files with 492 additions and 162 deletions

View File

@ -59,7 +59,7 @@
"salix": "cd ../salix && gulp back",
"db": "cd ../salix && gulp docker",
"cy:open": "npm run db && cypress open",
"test:e2e": "npm run db && cypress run",
"test:e2e": "npm run db && cypress run --headed --config video=false",
"test:unit": "vitest",
"build": "rm -rf dist/ ; quasar build",
"clean": "rm -rf dist/",

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,231 @@
<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 formModelRef = 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;
}
}
const submitForm = async evt => {
const isFormValid = await formModelRef.value.validate();
if (isFormValid) await save(evt);
};
async function save() {
if (props.observeFormChanges && !hasChanges.value)
return notify('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;
await Promise.resolve(
props.saveFn ? props.saveFn(body) : api[method](url, body)
);
onSubmitSuccess();
hasChanges.value = false;
} finally {
isLoading.value = false;
}
}
defineExpose({
formData,
submitForm
});
</script>
<template>
<QCard class="form-container" v-bind="$attrs">
<QForm ref="formModelRef" 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
:loading="isLoading"
:disabled="!showBottomActions && !hasChanges"
@click="submitForm()"
data-cy="formModelDefaultSaveButton"
>
<QTooltip>{{ t('save') }}</QTooltip>
</QBtn>
<slot name="actions" :data="formData" />
</component>
</QForm>
</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

@ -1,15 +1,20 @@
<script setup>
import { computed, ref } from 'vue';
import { computed, ref, useAttrs, nextTick } from 'vue';
import { useI18n } from 'vue-i18n';
import { useRequired } from 'src/composables/useRequired';
const $attrs = useAttrs();
const { isRequired, requiredFieldRule } = useRequired($attrs);
const { t } = useI18n();
const emit = defineEmits([
'update:modelValue',
'update:options',
'keyup.enter',
'remove'
'remove',
'blur'
]);
const props = defineProps({
const $props = defineProps({
modelValue: {
type: [String, Number],
default: null
@ -25,24 +30,43 @@ const props = defineProps({
clearable: {
type: Boolean,
default: true
},
emptyToNull: {
type: Boolean,
default: true
},
insertable: {
type: Boolean,
default: false
},
maxlength: {
type: Number,
default: null
},
uppercase: {
type: Boolean,
default: false
}
});
const { t } = useI18n();
const requiredFieldRule = val => !!val || t('globals.fieldRequired');
const vnInputRef = ref(null);
const value = computed({
get() {
return props.modelValue;
return $props.modelValue;
},
set(value) {
if ($props.emptyToNull && value === '') value = null;
emit('update:modelValue', value);
}
});
const hover = ref(false);
const styleAttrs = computed(() => {
return props.isOutlined
? { dense: true, outlined: true, rounded: true }
return $props.isOutlined
? {
dense: true,
outlined: true,
rounded: true
}
: {};
});
@ -51,66 +75,119 @@ const focus = () => {
};
defineExpose({
focus
focus,
vnInputRef
});
const inputRules = [
const mixinRules = [
requiredFieldRule,
...($attrs.rules ?? []),
val => {
const { maxlength } = vnInputRef.value;
if (maxlength && +val.length > maxlength)
return t(`maxLength`, { value: maxlength });
const { min, max } = vnInputRef.value.$attrs;
if (min >= 0) {
if (!min) return null;
if (min >= 0)
if (Math.floor(val) < min) return t('inputMin', { value: min });
}
if (!max) return null;
if (max > 0) {
if (Math.floor(val) > max) return t('inputMax', { value: max });
}
}
];
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);
});
};
const handleUppercase = () => {
value.value = value.value?.toUpperCase() || '';
};
</script>
<template>
<div
:rules="$attrs.required ? [requiredFieldRule] : null"
@mouseover="hover = true"
@mouseleave="hover = false"
>
<div @mouseover="hover = true" @mouseleave="hover = false">
<QInput
ref="vnInputRef"
v-model="value"
v-bind="{ ...$attrs, ...styleAttrs }"
:type="$attrs.type"
:class="{ required: $attrs.required }"
:class="{ required: isRequired }"
@keyup.enter="emit('keyup.enter')"
@blur="emit('blur')"
@keydown="handleKeydown"
:clearable="false"
:rules="inputRules"
:rules="mixinRules"
:lazy-rules="true"
hide-bottom-space
@keyup.enter="emit('keyup.enter')"
:data-cy="$attrs.dataCy ?? $attrs.label + '_input'"
>
<template
v-if="$slots.prepend"
#prepend
>
<template #prepend v-if="$slots.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"
:style="{
visibility:
hover &&
value &&
!$attrs.disabled &&
!$attrs.readonly &&
$props.clearable
? 'visible'
: 'hidden'
}"
@click="
() => {
value = null;
vnInputRef.focus();
emit('remove');
}
"
/>
></QIcon>
<QIcon
v-if="info"
name="info"
name="match_case"
size="xs"
v-if="
!$attrs.disabled && !$attrs.readonly && $props.uppercase
"
@click="handleUppercase"
class="uppercase-icon"
>
<QTooltip>
{{ t('Convert to uppercase') }}
</QTooltip>
</QIcon>
<slot name="append" v-if="$slots.append && !$attrs.disabled" />
<QIcon v-if="info" name="info">
<QTooltip max-width="350px">
{{ info }}
</QTooltip>
@ -120,20 +197,44 @@ const inputRules = [
</div>
</template>
<style>
.uppercase-icon {
transition:
color 0.3s,
transform 0.2s;
cursor: pointer;
}
.uppercase-icon:hover {
color: #ed9937;
transform: scale(1.2);
}
</style>
<i18n lang="yaml">
en-US:
inputMin: Must be more than {value}
maxLength: The value exceeds {value} characters
inputMax: Must be less than {value}
Convert to uppercase: Convert to uppercase
es-ES:
inputMin: Debe ser mayor a {value}
maxLength: El valor excede los {value} carácteres
inputMax: Debe ser menor a {value}
Convert to uppercase: Convertir a mayúsculas
ca-ES:
inputMin: Ha de ser més gran que {value}
inputMax: Ha de ser menys que {value}
maxLength: El valor excedeix els {value} caràcters
inputMax: Ha de ser menor que {value}
Convert to uppercase: Convertir a majúscules
fr-FR:
inputMin: Doit être supérieur à {value}
inputMax: Doit être supérieur à {value}
maxLength: La valeur dépasse {value} caractères
inputMax: Doit être inférieur à {value}
Convert to uppercase: Convertir en majuscules
pt-PT:
inputMin: Deve ser maior que {value}
inputMax: Deve ser maior que {value}
maxLength: O valor excede {value} caracteres
inputMax: Deve ser menor que {value}
Convert to uppercase: Converter para maiúsculas
</i18n>

View File

@ -67,7 +67,7 @@ const $props = defineProps({
});
const { t } = useI18n();
const requiredFieldRule = val => val ?? t('globals.fieldRequired');
const requiredFieldRule = val => val ?? t('fieldRequired');
const { optionLabel, optionValue, options } = toRefs($props);
const myOptions = ref([]);

View File

@ -0,0 +1,17 @@
import { useI18n } from 'vue-i18n';
export function useRequired($attrs) {
const { t } = useI18n();
const isRequired =
typeof $attrs['required'] === 'boolean'
? $attrs['required']
: Object.keys($attrs).includes('required');
const requiredFieldRule = val =>
isRequired ? !!val || t('fieldRequired') : null;
return {
isRequired,
requiredFieldRule
};
}

View File

@ -134,6 +134,8 @@ export default {
introduceSearchTerm: 'Introdueix un terme de cerca',
noOrdersFound: `No s'han trobat comandes`,
send: 'Enviar',
fieldRequired: 'Aquest camp és obligatori',
noChanges: 'No shan fet canvis',
// Image related translations
'Cant lock cache': 'No es pot bloquejar la memòria cau',
'Bad file format': 'Format de fitxer no reconegut',

View File

@ -167,6 +167,8 @@ export default {
introduceSearchTerm: 'Enter a search term',
noOrdersFound: 'No orders found',
send: 'Send',
fieldRequired: 'Field required',
noChanges: 'No changes',
// Image related translations
'Cant lock cache': 'The cache could not be blocked',
'Bad file format': 'Unrecognized file format',

View File

@ -166,6 +166,8 @@ export default {
introduceSearchTerm: 'Introduce un término de búsqueda',
noOrdersFound: 'No se encontrado pedidos',
send: 'Enviar',
fieldRequired: 'Campo requerido',
noChanges: 'No se han hecho cambios',
// Image related translations
'Cant lock cache': 'La caché no pudo ser bloqueada',
'Bad file format': 'Formato de archivo no reconocido',

View File

@ -134,6 +134,8 @@ export default {
introduceSearchTerm: 'Entrez un terme de recherche',
noOrdersFound: 'Aucune commande trouvée',
send: 'Envoyer',
fieldRequired: 'Champ obligatoire',
noChanges: 'Aucun changement',
// Image related translations
'Cant lock cache': "Le cache n'a pas pu être verrouillé",
'Bad file format': 'Format de fichier non reconnu',

View File

@ -133,6 +133,8 @@ export default {
introduceSearchTerm: 'Digite um termo de pesquisa',
noOrdersFound: 'Nenhum pedido encontrado',
send: 'Enviar',
fieldRequired: 'Campo obrigatório',
noChanges: 'Sem alterações',
// Image related translations
'Cant lock cache': 'O cache não pôde ser bloqueado',
'Bad file format': 'Formato de arquivo inválido',

View File

@ -3,8 +3,9 @@ import { ref, inject, onMounted, computed } from 'vue';
import { useI18n } from 'vue-i18n';
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 ChangePasswordForm from 'src/components/ui/ChangePasswordForm.vue';
import FormModel from 'src/components/common/FormModel.vue';
import useNotify from 'src/composables/useNotify.js';
import { useUserStore } from 'stores/user';
import { useAppStore } from 'stores/app';
@ -12,54 +13,76 @@ import { storeToRefs } from 'pinia';
const userStore = useUserStore();
const { t } = useI18n();
const jApi = inject('jApi');
const api = inject('api');
const appStore = useAppStore();
const { isHeaderMounted } = storeToRefs(appStore);
const { user } = storeToRefs(userStore);
const { notify } = useNotify();
const vnFormRef = ref(null);
const vnFormRef2 = ref(null);
const changePasswordFormDialog = ref(null);
const showChangePasswordForm = ref(false);
const langOptions = ref([]);
const pks = computed(() => ({ id: userStore?.user?.id }));
const fetchConfigDataSql = {
query: `
SELECT u.id, u.name, u.email, u.nickname,
u.lang, c.isToBeMailed, c.id clientFk
FROM account.myUser u
LEFT JOIN myClient c
ON u.id = c.id`,
params: {}
};
const formInitialData = ref({});
const showForm = ref(false);
const fetchLanguagesSql = async () => {
const fetchLanguages = async () => {
try {
const data = await jApi.query(
'SELECT code, name FROM language WHERE isActive'
);
const filter = { fields: ['code', 'name'], where: { isActive: true } };
const { data } = await api.get('/languages', {
params: { filter: JSON.stringify(filter) }
});
langOptions.value = data;
} catch (error) {
console.error(error);
}
};
const fetchFormInitialData = async () => {
try {
const filter = {
where: { id: user?.value?.id },
fields: ['id', 'name', 'isToBeMailed'],
include: {
relation: 'user',
scope: {
fields: ['nickname', 'lang', 'email']
}
}
};
const { data } = await api.get('/Clients', {
params: { filter: JSON.stringify(filter) }
});
const { user: userData, ...restOfData } = data[0];
formInitialData.value = {
...restOfData,
nickname: userData?.nickname,
lang: userData?.lang,
email: userData?.email
};
} catch (error) {
console.error(error);
} finally {
showForm.value = true;
}
};
const updateUserNickname = async nickname => {
try {
await vnFormRef.value.submit();
await submitAccountData(nickname);
await submitNickname(nickname);
user.value.nickname = nickname;
} catch (error) {
console.error(error);
}
};
const formatMailData = data => {
data.isToBeMailed = Boolean(data.isToBeMailed);
};
const updateConfigLang = async lang => {
try {
await vnFormRef.value.submit();
if (!lang) return;
await submitAccountData({ lang });
userStore.updateUserLang(lang);
const siteLocaleLang = appStore.localeOptions.find(
locale => locale.value === lang
@ -70,7 +93,32 @@ const updateConfigLang = async lang => {
}
};
onMounted(() => fetchLanguagesSql());
const submitAccountData = async data => {
try {
const params = {
...data
};
await api.patch(`/VnUsers/${user?.value?.id}`, params);
notify(t('dataSaved'), 'positive');
} catch (error) {
console.error(error);
}
};
const submitIsToBeMailed = async isToBeMailed => {
try {
const payload = { isToBeMailed };
await api.patch(`/Clients/${user.value.id}`, payload);
notify(t('dataSaved'), 'positive');
} catch (error) {
console.error(error);
}
};
onMounted(async () => {
fetchLanguages();
fetchFormInitialData();
});
</script>
<template>
@ -92,14 +140,14 @@ onMounted(() => fetchLanguagesSql());
@click="showChangePasswordForm = true"
/>
</Teleport>
<VnForm
<FormModel
v-if="showForm"
ref="vnFormRef"
:save-fn="submitFormFn"
:form-initial-data="formInitialData"
:title="t('personalInformation')"
:fetch-form-data-sql="fetchConfigDataSql"
:pks="pks"
table="myUser"
schema="account"
:default-actions="false"
:show-bottom-actions="false"
:defaultActions="false"
>
<template #form="{ data }">
<VnInput
@ -111,14 +159,16 @@ onMounted(() => fetchLanguagesSql());
<VnInput
v-model="data.email"
:label="t('email')"
@keyup.enter="vnFormRef.submit()"
@blur="vnFormRef.submit()"
@keyup.enter="submitAccountData({ email: data.email })"
@blur="submitAccountData({ email: data.email })"
/>
<VnInput
v-model="data.nickname"
:label="t('nickname')"
@keyup.enter="updateUserNickname(data.nickname)"
@blur="updateUserNickname(data.nickname)"
@keyup.enter="
updateUserNickname({ nickname: data.nickname })
"
@blur="updateUserNickname({ nickname: data.nickname })"
data-cy="configViewNickname"
/>
<VnSelect
@ -130,30 +180,17 @@ onMounted(() => fetchLanguagesSql());
@update:model-value="updateConfigLang(data.lang)"
data-cy="configViewLang"
/>
<QCheckbox
v-model="data.isToBeMailed"
:label="t('isToBeMailed')"
:toggle-indeterminate="false"
@update:model-value="
submitIsToBeMailed(data.isToBeMailed)
"
dense
/>
</template>
<template #extraForm>
<VnForm
class="no-form-container q-mt-md"
ref="vnFormRef2"
:pks="pks"
table="myClient"
schema="hedera"
:fetch-form-data-sql="fetchConfigDataSql"
:default-actions="false"
@on-data-fetched="$event => formatMailData($event)"
>
<template #form="{ data }">
<QCheckbox
v-model="data.isToBeMailed"
:label="t('isToBeMailed')"
:toggle-indeterminate="false"
@update:model-value="vnFormRef2.submit()"
dense
/>
</template>
</VnForm>
</template>
</VnForm>
</FormModel>
</QPage>
<QDialog
ref="changePasswordFormDialog"

View File

@ -38,8 +38,8 @@ export const useUserStore = defineStore('user', () => {
router.push({ name: 'login' });
}
} else {
await fetchTokenConfig();
await fetchUser();
await fetchTokenConfig();
await supplantInit();
startInterval();
}