Merge branch 'master' into increase_initialien_count

This commit is contained in:
einhornimmond 2024-11-12 13:39:47 +01:00 committed by GitHub
commit 9b0dc7df9a
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
80 changed files with 1943 additions and 887 deletions

View File

@ -35,7 +35,10 @@
@reset="resetHomeCommunityEditable"
>
<template #view>
<label>{{ $t('federation.gmsApiKey') }}&nbsp;{{ gmsApiKey }}</label>
<div class="d-flex">
<p style="text-wrap: nowrap">{{ $t('federation.gmsApiKey') }}&nbsp;</p>
<span class="d-block" style="overflow-x: auto">{{ gmsApiKey }}</span>
</div>
<BFormGroup>
{{ $t('federation.coordinates') }}
<span v-if="isValidLocation">
@ -198,131 +201,3 @@ const resetHomeCommunityEditable = () => {
gmsApiKey.value = originalGmsApiKey.value
}
</script>
<!--<script>-->
<!--import { formatDistanceToNow } from 'date-fns'-->
<!--import { de, enUS as en, fr, es, nl } from 'date-fns/locale'-->
<!--import EditableGroup from '@/components/input/EditableGroup'-->
<!--import FederationVisualizeItem from './FederationVisualizeItem.vue'-->
<!--import { updateHomeCommunity } from '../../graphql/updateHomeCommunity'-->
<!--import Coordinates from '../input/Coordinates.vue'-->
<!--import EditableGroupableLabel from '../input/EditableGroupableLabel.vue'-->
<!--const locales = { en, de, es, fr, nl }-->
<!--export default {-->
<!-- name: 'CommunityVisualizeItem',-->
<!-- components: {-->
<!-- Coordinates,-->
<!-- EditableGroup,-->
<!-- FederationVisualizeItem,-->
<!-- EditableGroupableLabel,-->
<!-- },-->
<!-- props: {-->
<!-- item: { type: Object },-->
<!-- },-->
<!-- data() {-->
<!-- return {-->
<!-- formatDistanceToNow,-->
<!-- locale: this.$i18n.locale,-->
<!-- details: false,-->
<!-- gmsApiKey: this.item.gmsApiKey,-->
<!-- location: this.item.location,-->
<!-- originalGmsApiKey: this.item.gmsApiKey,-->
<!-- originalLocation: this.item.location,-->
<!-- }-->
<!-- },-->
<!-- computed: {-->
<!-- verified() {-->
<!-- if (!this.item.federatedCommunities || this.item.federatedCommunities.length === 0) {-->
<!-- return false-->
<!-- }-->
<!-- return (-->
<!-- this.item.federatedCommunities.filter(-->
<!-- (federatedCommunity) =>-->
<!-- new Date(federatedCommunity.verifiedAt) >= new Date(federatedCommunity.lastAnnouncedAt),-->
<!-- ).length > 0-->
<!-- )-->
<!-- },-->
<!-- icon() {-->
<!-- return this.verified ? 'check' : 'x-circle'-->
<!-- },-->
<!-- variant() {-->
<!-- return this.verified ? 'success' : 'danger'-->
<!-- },-->
<!-- lastAnnouncedAt() {-->
<!-- if (!this.item.federatedCommunities || this.item.federatedCommunities.length === 0) return ''-->
<!-- const minDate = new Date(0)-->
<!-- const lastAnnouncedAt = this.item.federatedCommunities.reduce(-->
<!-- (lastAnnouncedAt, federateCommunity) => {-->
<!-- if (!federateCommunity.lastAnnouncedAt) return lastAnnouncedAt-->
<!-- const date = new Date(federateCommunity.lastAnnouncedAt)-->
<!-- return date > lastAnnouncedAt ? date : lastAnnouncedAt-->
<!-- },-->
<!-- minDate,-->
<!-- )-->
<!-- if (lastAnnouncedAt !== minDate) {-->
<!-- return formatDistanceToNow(lastAnnouncedAt, {-->
<!-- includeSecond: true,-->
<!-- addSuffix: true,-->
<!-- locale: locales[this.locale],-->
<!-- })-->
<!-- }-->
<!-- return ''-->
<!-- },-->
<!-- createdAt() {-->
<!-- if (this.item.createdAt) {-->
<!-- return formatDistanceToNow(new Date(this.item.createdAt), {-->
<!-- includeSecond: true,-->
<!-- addSuffix: true,-->
<!-- locale: locales[this.locale],-->
<!-- })-->
<!-- }-->
<!-- return ''-->
<!-- },-->
<!-- isLocationChanged() {-->
<!-- return this.originalLocation !== this.location-->
<!-- },-->
<!-- isGMSApiKeyChanged() {-->
<!-- return this.originalGmsApiKey !== this.gmsApiKey-->
<!-- },-->
<!-- isValidLocation() {-->
<!-- return this.location && this.location.latitude && this.location.longitude-->
<!-- },-->
<!-- },-->
<!-- methods: {-->
<!-- toggleDetails() {-->
<!-- this.details = !this.details-->
<!-- },-->
<!-- handleUpdateHomeCommunity() {-->
<!-- this.$apollo-->
<!-- .mutate({-->
<!-- mutation: updateHomeCommunity,-->
<!-- variables: {-->
<!-- uuid: this.item.uuid,-->
<!-- gmsApiKey: this.gmsApiKey,-->
<!-- location: this.location,-->
<!-- },-->
<!-- })-->
<!-- .then(() => {-->
<!-- if (this.isLocationChanged && this.isGMSApiKeyChanged) {-->
<!-- this.toastSuccess(this.$t('federation.toast_gmsApiKeyAndLocationUpdated'))-->
<!-- } else if (this.isGMSApiKeyChanged) {-->
<!-- this.toastSuccess(this.$t('federation.toast_gmsApiKeyUpdated'))-->
<!-- } else if (this.isLocationChanged) {-->
<!-- this.toastSuccess(this.$t('federation.toast_gmsLocationUpdated'))-->
<!-- }-->
<!-- this.originalLocation = this.location-->
<!-- this.originalGmsApiKey = this.gmsApiKey-->
<!-- })-->
<!-- .catch((error) => {-->
<!-- this.toastError(error.message)-->
<!-- })-->
<!-- },-->
<!-- resetHomeCommunityEditable() {-->
<!-- this.location = this.originalLocation-->
<!-- this.gmsApiKey = this.originalGmsApiKey-->
<!-- },-->
<!-- },-->
<!--}-->
<!--</script>-->

View File

@ -3,33 +3,43 @@ import { describe, it, expect, beforeEach, vi } from 'vitest'
import Coordinates from './Coordinates.vue'
import { BFormGroup, BFormInput } from 'bootstrap-vue-next'
const value = {
const modelValue = {
latitude: 56.78,
longitude: 12.34,
}
vi.mock('vue-i18n', () => ({
useI18n: () => ({
t: (key, v) => (key === 'geo-coordinates.format' ? `${v.latitude}, ${v.longitude}` : key),
}),
}))
const mockEditableGroup = {
valueChanged: vi.fn(function () {
this.isValueChanged = true
}),
invalidValues: vi.fn(function () {
this.isValueChanged = false
}),
}
describe('Coordinates', () => {
let wrapper
const createWrapper = (props = {}) => {
return mount(Coordinates, {
props: {
value,
modelValue,
...props,
},
global: {
mocks: {
$t: vi.fn((t, v) => {
if (t === 'geo-coordinates.format') {
return `${v.latitude}, ${v.longitude}`
}
return t
}),
},
stubs: {
BFormGroup,
BFormInput,
},
provide: {
editableGroup: mockEditableGroup,
},
},
})
}
@ -63,19 +73,13 @@ describe('Coordinates', () => {
const latitudeInput = wrapper.find('#home-community-latitude')
const longitudeInput = wrapper.find('#home-community-longitude')
await latitudeInput.setValue(34.56)
await latitudeInput.setValue('34.56')
expect(wrapper.emitted('input')).toBeTruthy()
expect(wrapper.emitted('input')[0][0]).toEqual({
latitude: 34.56,
longitude: 12.34,
})
expect(wrapper.vm.inputValue.latitude).toBe('34.56')
await longitudeInput.setValue('78.90')
await longitudeInput.setValue('78.9')
expect(wrapper.emitted('input')).toBeTruthy()
expect(wrapper.emitted('input')[1][0]).toEqual({
latitude: 34.56,
longitude: '78.90',
})
expect(wrapper.vm.inputValue.longitude).toBe('78.9')
})
it('splits coordinates correctly when entering in latitudeLongitude input', async () => {

View File

@ -1,14 +1,14 @@
<template>
<div>
<div class="mb-4">
<BFormGroup
:label="$t('geo-coordinates.label')"
:invalid-feedback="$t('geo-coordinates.both-or-none')"
:label="t('geo-coordinates.label')"
:invalid-feedback="t('geo-coordinates.both-or-none')"
:state="isValid"
>
<BFormGroup
:label="$t('latitude-longitude-smart')"
:label="t('latitude-longitude-smart')"
label-for="home-community-latitude-longitude-smart"
:description="$t('geo-coordinates.latitude-longitude-smart.describe')"
:description="t('geo-coordinates.latitude-longitude-smart.describe')"
>
<BFormInput
id="home-community-latitude-longitude-smart"
@ -17,7 +17,7 @@
@input="splitCoordinates"
/>
</BFormGroup>
<BFormGroup :label="$t('latitude')" label-for="home-community-latitude">
<BFormGroup :label="t('latitude')" label-for="home-community-latitude">
<BFormInput
id="home-community-latitude"
v-model="inputValue.latitude"
@ -25,7 +25,7 @@
@input="valueUpdated"
/>
</BFormGroup>
<BFormGroup :label="$t('longitude')" label-for="home-community-longitude">
<BFormGroup :label="t('longitude')" label-for="home-community-longitude">
<BFormInput
id="home-community-longitude"
v-model="inputValue.longitude"
@ -37,81 +37,94 @@
</div>
</template>
<script>
export default {
name: 'Coordinates',
props: {
value: {
type: Object,
default: null,
},
<script setup>
import { ref, computed, watch, inject } from 'vue'
import { useI18n } from 'vue-i18n'
import { BFormGroup, BFormInput } from 'bootstrap-vue-next'
const props = defineProps({
modelValue: {
type: Object,
default: null,
},
emits: ['input'],
data() {
return {
inputValue: this.value,
originalValue: this.value,
locationString: this.getLatitudeLongitudeString(this.value),
})
const emit = defineEmits(['update:modelValue'])
const { t } = useI18n()
const editableGroup = inject('editableGroup')
const inputValue = ref(sanitizeLocation(props.modelValue))
const originalValue = ref(props.modelValue)
const locationString = ref(getLatitudeLongitudeString(props.modelValue))
const isValid = computed(() => {
return (
(!isNaN(parseFloat(inputValue.value.longitude)) &&
!isNaN(parseFloat(inputValue.value.latitude))) ||
(inputValue.value.longitude === '' && inputValue.value.latitude === '')
)
})
const isChanged = computed(() => {
return inputValue.value !== originalValue.value
})
function splitCoordinates() {
const parts = locationString.value.split(',').map((part) => part.trim())
if (parts.length === 2) {
const [lat, lon] = parts
if (!isNaN(parseFloat(lon)) && !isNaN(parseFloat(lat))) {
inputValue.value.longitude = parseFloat(lon)
inputValue.value.latitude = parseFloat(lat)
}
},
computed: {
isValid() {
return (
(!isNaN(parseFloat(this.inputValue.longitude)) &&
!isNaN(parseFloat(this.inputValue.latitude))) ||
(this.inputValue.longitude === '' && this.inputValue.latitude === '')
)
},
isChanged() {
return this.inputValue !== this.originalValue
},
},
methods: {
splitCoordinates(value) {
// default format for geo-coordinates: 'latitude, longitude'
const parts = this.locationString.split(',').map((part) => part.trim())
if (parts.length === 2) {
const [lat, lon] = parts
if (!isNaN(parseFloat(lon) && !isNaN(parseFloat(lat)))) {
this.inputValue.longitude = parseFloat(lon)
this.inputValue.latitude = parseFloat(lat)
}
}
this.valueUpdated()
},
sanitizeLocation(location) {
if (!location) return { latitude: '', longitude: '' }
const parseNumber = (value) => {
const number = parseFloat(value)
return isNaN(number) ? '' : number
}
return {
latitude: parseNumber(location.latitude),
longitude: parseNumber(location.longitude),
}
},
getLatitudeLongitudeString({ latitude, longitude } = {}) {
return latitude && longitude ? this.$t('geo-coordinates.format', { latitude, longitude }) : ''
},
valueUpdated() {
this.locationString = this.getLatitudeLongitudeString(this.inputValue)
this.inputValue = this.sanitizeLocation(this.inputValue)
if (this.isValid && this.isChanged) {
if (this.$parent.valueChanged) {
this.$parent.valueChanged()
}
} else {
if (this.$parent.invalidValues) {
this.$parent.invalidValues()
}
}
this.$emit('input', this.inputValue)
},
},
}
valueUpdated()
}
function sanitizeLocation(location) {
if (!location) return { latitude: '', longitude: '' }
const parseNumber = (value) => {
const number = parseFloat(value)
return isNaN(number) ? '' : number
}
return {
latitude: parseNumber(location.latitude),
longitude: parseNumber(location.longitude),
}
}
function getLatitudeLongitudeString(locationData) {
return locationData?.latitude && locationData?.longitude
? t('geo-coordinates.format', {
latitude: locationData.latitude,
longitude: locationData.longitude,
})
: ''
}
function valueUpdated() {
locationString.value = getLatitudeLongitudeString(inputValue.value)
inputValue.value = sanitizeLocation(inputValue.value)
if (isValid.value && isChanged.value) {
editableGroup.valueChanged()
} else {
editableGroup.invalidValues()
}
emit('update:modelValue', inputValue.value)
}
watch(
() => props.modelValue,
(newValue) => {
inputValue.value = sanitizeLocation(newValue)
originalValue.value = newValue
locationString.value = getLatitudeLongitudeString(newValue)
},
)
</script>

View File

@ -1,7 +1,7 @@
<template>
<div>
<slot v-if="!isEditing" :is-editing="isEditing" name="view"></slot>
<slot v-else :is-editing="isEditing" name="edit" @input="valueChanged"></slot>
<slot v-if="!isEditing" :is-editing="isEditing" name="view" />
<slot v-else :is-editing="isEditing" name="edit" @update:model-value="valueChanged" />
<BFormGroup v-if="allowEdit && !isEditing">
<BButton :variant="variant" @click="enableEdit">
<IBiPencilFill />
@ -12,7 +12,7 @@
<BButton :variant="variant" :disabled="!isValueChanged" class="save-button" @click="save">
{{ $t('save') }}
</BButton>
<BButton variant="secondary" class="close-button" @click="close">
<BButton variant="secondary" class="close-button ms-2" @click="close">
{{ $t('close') }}
</BButton>
</BFormGroup>
@ -22,6 +22,14 @@
<script>
export default {
name: 'EditableGroup',
provide() {
return {
editableGroup: {
valueChanged: this.valueChanged,
invalidValues: this.invalidValues,
},
}
},
props: {
allowEdit: {
type: Boolean,
@ -58,6 +66,7 @@ export default {
close() {
this.$emit('reset')
this.isEditing = false
this.isValueChanged = false
},
},
}

View File

@ -3,7 +3,7 @@ import { describe, it, expect, beforeEach, vi } from 'vitest'
import EditableGroupableLabel from './EditableGroupableLabel.vue'
import { BFormGroup, BFormInput } from 'bootstrap-vue-next'
const value = 'test label value'
const modelValue = 'test label value'
const label = 'Test Label'
const idName = 'test-id-name'
@ -16,7 +16,7 @@ describe('EditableGroupableLabel', () => {
components: {
EditableGroupableLabel,
},
props: ['value', 'label', 'idName'],
props: ['modelValue', 'label', 'idName'],
methods: {
onInput: vi.fn(),
...parentMethods,
@ -24,7 +24,7 @@ describe('EditableGroupableLabel', () => {
}
return mount(Parent, {
props: {
value,
modelValue,
label,
idName,
...props,
@ -55,7 +55,7 @@ describe('EditableGroupableLabel', () => {
it('renders BFormInput with correct props', () => {
const formInput = wrapper.findComponent({ name: 'BFormInput' })
expect(formInput.props('id')).toBe(idName)
expect(formInput.props('modelValue')).toBe(value)
expect(formInput.props('modelValue')).toBe(modelValue)
})
// it('emits input event with the correct value when input changes', async () => {
@ -76,7 +76,7 @@ describe('EditableGroupableLabel', () => {
const newValue = 'new label value'
const input = wrapper.findComponent({ name: 'BFormInput' })
await input.vm.$emit('input', newValue)
await input.vm.$emit('update:model-value', newValue)
expect(valueChangedMock).toHaveBeenCalled()
})
@ -86,8 +86,8 @@ describe('EditableGroupableLabel', () => {
wrapper = createWrapper({}, { invalidValues: invalidValuesMock })
const input = wrapper.findComponent({ name: 'BFormInput' })
await input.vm.$emit('input', 'new label value')
await input.vm.$emit('input', value)
await input.vm.$emit('update:model-value', 'new label value')
await input.vm.$emit('update:model-value', modelValue)
expect(invalidValuesMock).toHaveBeenCalled()
})
@ -97,7 +97,7 @@ describe('EditableGroupableLabel', () => {
wrapper = createWrapper({}, { valueChanged: valueChangedMock })
const input = wrapper.findComponent({ name: 'BFormInput' })
await input.vm.$emit('input', value)
await input.vm.$emit('input', modelValue)
expect(valueChangedMock).not.toHaveBeenCalled()
})

View File

@ -1,6 +1,6 @@
<template>
<BFormGroup :label="label" :label-for="idName">
<BFormInput :id="idName" v-model="inputValue" @input="updateValue" />
<BFormInput :id="idName" :model-value="modelValue" @update:model-value="inputValue = $event" />
</BFormGroup>
</template>
@ -8,7 +8,7 @@
export default {
name: 'EditableGroupableLabel',
props: {
value: {
modelValue: {
type: String,
required: false,
default: null,
@ -22,16 +22,20 @@ export default {
required: true,
},
},
emits: ['input'],
emits: ['update:model-value'],
data() {
return {
inputValue: this.value,
originalValue: this.value,
inputValue: this.modelValue,
originalValue: this.modelValue,
}
},
watch: {
inputValue() {
this.updateValue()
},
},
methods: {
updateValue(value) {
this.inputValue = value
updateValue() {
if (this.inputValue !== this.originalValue) {
if (this.$parent.valueChanged) {
this.$parent.valueChanged()
@ -41,7 +45,7 @@ export default {
this.$parent.invalidValues()
}
}
this.$emit('input', this.inputValue)
this.$emit('update:model-value', this.inputValue)
},
},
}

View File

@ -1,7 +1,7 @@
# This file defines the production settings. It is overwritten by docker-compose.override.yml,
# which defines the development settings. The override.yml is loaded by default. Therefore it
# is required to explicitly define if you want an production build:
# > docker-compose -f docker-compose.yml up
# > docker-compose -f docker-compose.yml up
services:
@ -67,13 +67,13 @@ services:
environment:
- MARIADB_ALLOW_EMPTY_PASSWORD=1
- MARIADB_USER=root
networks:
networks:
- internal-net
ports:
ports:
- 3306:3306
volumes:
volumes:
- db_vol:/var/lib/mysql
########################################################
# BACKEND ##############################################
########################################################
@ -264,12 +264,12 @@ services:
# Application only envs
#env_file:
# - ./frontend/.env
#########################################################
## NGINX ################################################
#########################################################
nginx:
build:
build:
context: ./nginx/
networks:
- external-net
@ -277,17 +277,17 @@ services:
depends_on:
- frontend
- backend
- admin
- admin
ports:
- 80:80
volumes:
- ./logs/nginx:/var/log/nginx
volumes:
- ./logs/nginx:/var/log/nginx
networks:
external-net:
internal-net:
internal: true
volumes:
db_vol:
db_vol:

View File

@ -50,7 +50,6 @@ module.exports = {
'vue/v-on-event-hyphenation': 0, // TODO remove at the end of migration and fix
'vue/require-default-prop': 0, // TODO remove at the end of migration and fix
'vue/no-computed-properties-in-data': 0, // TODO remove at the end of migration and fix
'@intlify/vue-i18n/no-dynamic-keys': 'error',
'@intlify/vue-i18n/no-missing-keys': 0, // TODO remove at the end of migration and fix
'@intlify/vue-i18n/no-unused-keys': [
'error',

View File

@ -22,13 +22,14 @@
"@babel/node": "^7.13.13",
"@babel/preset-env": "^7.13.12",
"@morev/vue-transitions": "^3.0.2",
"@types/leaflet": "^1.9.12",
"@vee-validate/i18n": "^4.13.2",
"@vee-validate/rules": "^4.13.2",
"@vee-validate/yup": "^4.13.2",
"@vitejs/plugin-vue": "3.2.0",
"@vitejs/plugin-vue": "5.1.4",
"@vue-leaflet/vue-leaflet": "^0.10.1",
"@vue/apollo-composable": "^4.0.2",
"@vue/apollo-option": "^4.0.0",
"@vue/compat": "^3.4.31",
"apollo-boost": "^0.4.9",
"autoprefixer": "^10.4.19",
"babel-core": "^7.0.0-bridge.0",
@ -45,22 +46,22 @@
"graphql-tag": "^2.12.6",
"identity-obj-proxy": "^3.0.0",
"jwt-decode": "^3.1.2",
"leaflet": "^1.9.4",
"leaflet-geosearch": "^4.0.0",
"portal-vue": "^3.0.0",
"qrcanvas-vue": "3",
"regenerator-runtime": "^0.13.7",
"tua-body-scroll-lock": "^1.5.1",
"uuid": "^9.0.0",
"vee-validate": "^4.13.2",
"vite": "3.2.10",
"vite-plugin-commonjs": "^0.10.1",
"vue": "3.4.31",
"vue-apollo": "^3.1.2",
"vue-avatar": "^2.3.3",
"vue-flatpickr-component": "^8.1.2",
"vue-i18n": "^9.13.1",
"vue-loading-overlay": "^3.4.2",
"vue-router": "^4.4.0",
"vue-timer-hook": "^1.0.84",
"vue-timers": "^2.0.4",
"vuex": "^4.1.0",
"vuex-persistedstate": "^4.1.0",
"yup": "^1.4.0"
@ -70,7 +71,6 @@
"@iconify-json/bi": "^1.1.23",
"@intlify/eslint-plugin-vue-i18n": "^1.4.0",
"@vitest/coverage-v8": "^2.0.5",
"@vue/compiler-sfc": "^3.4.35",
"@vue/eslint-config-prettier": "^4.0.1",
"@vue/test-utils": "^2.4.5",
"babel-plugin-component": "^1.1.0",

View File

@ -1,68 +1,5 @@
// import { shallowMount, RouterLinkStub } from '@vue/test-utils'
// import App from './App'
//
// const localVue = global.localVue
// const mockStoreCommit = jest.fn()
//
// const stubs = {
// RouterLink: RouterLinkStub,
// RouterView: true,
// }
//
// describe('App', () => {
// const mocks = {
// $i18n: {
// locale: 'en',
// },
// $t: jest.fn((t) => t),
// $store: {
// commit: mockStoreCommit,
// state: {
// token: null,
// },
// },
// $route: {
// meta: {
// requiresAuth: false,
// },
// params: {},
// },
// }
//
// let wrapper
//
// const Wrapper = () => {
// return shallowMount(App, { localVue, mocks, stubs })
// }
//
// describe('mount', () => {
// beforeEach(() => {
// wrapper = Wrapper()
// })
//
// it('renders the App', () => {
// expect(wrapper.find('#app').exists()).toBe(true)
// })
//
// it('has a component AuthLayout', () => {
// expect(wrapper.findComponent({ name: 'AuthLayout' }).exists()).toBe(true)
// })
//
// describe('route requires authorization', () => {
// beforeEach(async () => {
// mocks.$route.meta.requiresAuth = true
// wrapper = Wrapper()
// })
//
// it('has a component DashboardLayout', () => {
// expect(wrapper.findComponent({ name: 'DashboardLayout' }).exists()).toBe(true)
// })
// })
// })
// })
import { shallowMount } from '@vue/test-utils'
import { describe, it, expect, beforeEach, vi } from 'vitest'
import { describe, it, expect, beforeEach } from 'vitest'
import App from './App'
import DashboardLayout from '@/layouts/DashboardLayout'
import AuthLayout from '@/layouts/AuthLayout'

View File

@ -189,8 +189,10 @@ a:hover,
// .btn-primary pim {
.btn-primary {
background-color: #5a7b02;
border-color: #5e72e4;
background-color: #5a7b02 !important;
border-color: #5e72e4 !important;
border-radius: 25px !important;
color: #fff !important;
}
.gradido-font-large {
@ -345,3 +347,24 @@ a:hover,
.gdd-toaster-body {
color: rgb(255 255 255);
}
.gdd-toaster-body-darken {
color: #2c2c2c;
}
input.rounded-input {
border-radius: 17px;
}
.fs-7 {
font-size: 14px !important;
}
.vue3-avatar.container {
padding: 0 !important;
}
.avatar {
font-family: inherit !important;
font-weight: normal !important;
}

View File

@ -0,0 +1,135 @@
<template>
<div
class="app-avatar d-flex justify-content-center align-items-center rounded-circle"
:style="{
width: `${size}px`,
height: `${size}px`,
backgroundColor,
textTransform: 'uppercase',
}"
>
<span
:style="{
fontSize: `${size * 0.4}px`,
lineHeight: '1',
color: props.color,
}"
class="font-medium"
>
{{ computedInitials }}
</span>
</div>
</template>
<script setup>
import { computed } from 'vue'
const props = defineProps({
size: {
type: Number,
default: 50,
},
color: {
type: String,
default: '',
},
name: {
type: String,
default: '',
},
initials: {
type: String,
default: '',
},
})
// Enhanced color palette with better contrast ratios
const colorPalette = [
{ bg: '#4A5568', text: '#FFFFFF' }, // Slate Blue
{ bg: '#2C7A7B', text: '#FFFFFF' }, // Teal
{ bg: '#805AD5', text: '#FFFFFF' }, // Purple
{ bg: '#DD6B20', text: '#FFFFFF' }, // Orange
{ bg: '#3182CE', text: '#FFFFFF' }, // Blue
{ bg: '#38A169', text: '#FFFFFF' }, // Green
{ bg: '#E53E3E', text: '#FFFFFF' }, // Red
{ bg: '#6B46C1', text: '#FFFFFF' }, // Indigo
{ bg: '#2B6CB0', text: '#FFFFFF' }, // Dark Blue
{ bg: '#9C4221', text: '#FFFFFF' }, // Brown
]
// Generate consistent index based on string
function stringToIndex(str) {
let hash = 0
for (let i = 0; i < str.length; i++) {
hash = str.charCodeAt(i) + ((hash << 5) - hash)
}
return Math.abs(hash % colorPalette.length)
}
// Parse any color format to RGB
function parseColor(color) {
const div = document.createElement('div')
div.style.color = color
document.body.appendChild(div)
const computed = window.getComputedStyle(div).color
document.body.removeChild(div)
const match = computed.match(/^rgb\((\d+),\s*(\d+),\s*(\d+)\)$/)
if (!match) return [0, 0, 0]
return [parseInt(match[1]), parseInt(match[2]), parseInt(match[3])]
}
// Calculate relative luminance using WCAG formula
function getLuminance(r, g, b) {
const [rs, gs, bs] = [r, g, b].map((c) => {
c = c / 255
return c <= 0.03928 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4)
})
return 0.2126 * rs + 0.7152 * gs + 0.0722 * bs
}
// Calculate contrast ratio using WCAG formula
function getContrastRatio(l1, l2) {
const lighter = Math.max(l1, l2)
const darker = Math.min(l1, l2)
return (lighter + 0.05) / (darker + 0.05)
}
// Get text color based on background ensuring WCAG AA compliance (4.5:1 minimum)
function getTextColor(backgroundColor) {
const [r, g, b] = parseColor(backgroundColor)
const bgLuminance = getLuminance(r, g, b)
const whiteLuminance = getLuminance(255, 255, 255)
const blackLuminance = getLuminance(0, 0, 0)
const whiteContrast = getContrastRatio(whiteLuminance, bgLuminance)
const blackContrast = getContrastRatio(blackLuminance, bgLuminance)
// Return the color with better contrast (minimum 4.5:1 for WCAG AA)
return whiteContrast >= blackContrast ? '#FFFFFF' : '#000000'
}
const computedInitials = computed(() => {
if (props.initials) return props.initials
return props.name
.split(' ')
.map((word) => word[0])
.join('')
.toUpperCase()
.slice(0, 2)
})
const backgroundColor = computed(() => {
const colorIndex = stringToIndex(computedInitials.value || props.name)
return colorPalette[colorIndex].bg
})
const textColor = computed(() => {
if (props.color) {
return getTextColor(props.color)
}
const colorIndex = stringToIndex(computedInitials.value || props.name)
return colorPalette[colorIndex].text
})
</script>

View File

@ -1,5 +1,5 @@
<template>
<div class="breadcrumb bg-transparent">
<div class="page-breadcrumb breadcrumb bg-transparent">
<h1>{{ pageTitle }}</h1>
</div>
</template>
@ -17,3 +17,10 @@ export default {
},
}
</script>
<style scoped>
.page-breadcrumb {
margin-bottom: 1rem;
padding: 0.75rem 1rem;
}
</style>

View File

@ -90,5 +90,4 @@ function setDefaultCommunity() {
}
onMounted(setDefaultCommunity)
onUpdated(setDefaultCommunity)
</script>

View File

@ -16,7 +16,17 @@
<parse-message v-bind="message" data-test="message" class="p-2"></parse-message>
</BCol>
<BCol cols="2">
<avatar :username="storeName.username" :initials="storeName.initials"></avatar>
<!-- <avatar-->
<!-- class="vue3-avatar"-->
<!-- :name="storeName.username"-->
<!-- :initials="storeName.initials"-->
<!-- :border="false"-->
<!-- />-->
<app-avatar
class="vue3-avatar"
:name="storeName.username"
:initials="storeName.initials"
/>
</BCol>
</BRow>
</div>
@ -28,14 +38,30 @@
<parse-message v-bind="message" data-test="message"></parse-message>
</BCol>
<BCol cols="2">
<avatar :username="storeName.username" :initials="storeName.initials"></avatar>
<!-- <avatar-->
<!-- class="vue3-avatar"-->
<!-- :name="storeName.username"-->
<!-- :initials="storeName.initials"-->
<!-- :border="false"-->
<!-- />-->
<app-avatar
class="vue3-avatar"
:name="storeName.username"
:initials="storeName.initials"
/>
</BCol>
</BRow>
</div>
<div v-else>
<BRow class="mb-3 p-2 is-moderator">
<BCol cols="2">
<avatar :username="moderationName.username" :initials="moderationName.initials"></avatar>
<!-- <avatar-->
<!-- class="vue3-avatar"-->
<!-- :name="moderationName.username"-->
<!-- :initials="moderationName.initials"-->
<!-- :border="false"-->
<!-- />-->
<app-avatar :name="moderationName.username" :initials="moderationName.initials" />
</BCol>
<BCol cols="10">
<div class="font-weight-bold">
@ -54,13 +80,13 @@
</template>
<script>
import Avatar from 'vue-avatar'
import ParseMessage from '@/components/ContributionMessages/ParseMessage'
import AppAvatar from '@/components/AppAvatar.vue'
export default {
name: 'ContributionMessagesListItem',
components: {
Avatar,
AppAvatar,
ParseMessage,
},
props: {

View File

@ -6,13 +6,21 @@
>
<BRow>
<BCol cols="3" lg="2" md="2">
<avatar
<!-- <avatar-->
<!-- v-if="firstName"-->
<!-- :name="username.username"-->
<!-- :initials="username.initials"-->
<!-- :border="false"-->
<!-- color="#fff"-->
<!-- class="vue3-avatar fw-bold"-->
<!-- />-->
<app-avatar
v-if="firstName"
:username="username.username"
:name="username.username"
:initials="username.initials"
color="#fff"
class="fw-bold"
></avatar>
class="vue3-avatar fw-bold"
/>
<BAvatar v-else rounded="lg" :variant="variant" size="4.55em">
<variant-icon :icon="icon" variant="white" />
</BAvatar>
@ -112,13 +120,13 @@
<script setup>
import { ref, computed, watch } from 'vue'
import Avatar from 'vue-avatar'
import CollapseIcon from '../TransactionRows/CollapseIcon'
import ContributionMessagesList from '@/components/ContributionMessages/ContributionMessagesList'
import { listContributionMessages } from '@/graphql/queries'
import { useAppToast } from '@/composables/useToast'
import { useI18n } from 'vue-i18n'
import { useLazyQuery, useQuery } from '@vue/apollo-composable'
import AppAvatar from '@/components/AppAvatar.vue'
const props = defineProps({
id: {

View File

@ -162,10 +162,11 @@ describe('CollapseLinksList', () => {
transactionLinks: [{ id: 1 }, { id: 2 }],
pageSize: 5,
})
await wrapper.vm.$nextTick()
})
it('renders text in plural with page size links to load', () => {
expect(mockT).toHaveBeenCalledWith('link-load', 2, { n: 5 })
it('renders text with pageSize as number of links to load', () => {
expect(mockT).toHaveBeenCalledWith('link-load-more', { n: 5 })
})
})
})

View File

@ -13,7 +13,7 @@
<div class="mb-3">
<BButton
v-if="!pending && transactionLinks.length < transactionLinkCount"
class="test-button-load-more"
class="test-button-load-more w-100 rounded-5"
block
variant="outline-primary"
@click.stop="loadMoreLinks"
@ -50,8 +50,8 @@ export default {
buttonText() {
const i = this.transactionLinkCount - this.transactionLinks.length
if (i === 1) return this.$t('link-load', 0)
if (i <= this.pageSize) return this.$t('link-load', 1, { n: i })
return this.$t('link-load', 2, { n: this.pageSize })
if (i <= this.pageSize) return this.$t('link-load', { n: i })
return this.$t('link-load-more', { n: this.pageSize })
},
},
methods: {

View File

@ -2,21 +2,34 @@
<div class="decayinformation-startblock">
<div class="my-4">
<div class="fw-bold pb-2">{{ $t('form.memo') }}</div>
<div>{{ memo }}</div>
<div v-html="displayData" />
</div>
<div class="mt-3 mb-3 text-center">
<b>{{ $t('decay.before_startblock_transaction') }}</b>
</div>
</div>
</template>
<script>
export default {
name: 'DecayInformationStartBlock',
props: {
memo: {
type: String,
required: true,
},
},
<script setup>
import { computed } from 'vue'
const props = defineProps({
memo: String,
})
const formatLinks = (text) => {
// URL regex pattern
const urlPattern = /(\b(https?|ftp):\/\/[-A-Z0-9+&@#\/%?=~_|!:,.;]*[-A-Z0-9+&@#\/%=~_|])/gim
// Email regex pattern
const emailPattern = /(\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Z|a-z]{2,}\b)/g
// Replace URLs with clickable links
text = text.replace(urlPattern, '<a href="$1" target="_blank">$1</a>')
// Replace email addresses with mailto links
text = text.replace(emailPattern, '<a href="mailto:$1">$1</a>')
return text
}
const displayData = computed(() => formatLinks(props.memo))
</script>

View File

@ -2,7 +2,7 @@
<div class="decayinformation-long px-1">
<div class="word-break mb-5 mt-lg-3">
<div class="fw-bold pb-2">{{ $t('form.memo') }}</div>
<div class="">{{ memo }}</div>
<div @click.stop v-html="displayData" />
</div>
<div class="mb-3">
<IBiDropletHalf class="me-2" />
@ -73,6 +73,7 @@
</template>
<script>
import DurationRow from '@/components/TransactionRows/DurationRow'
import { computed } from 'vue'
export default {
name: 'DecayInformationLong',
@ -89,5 +90,27 @@ export default {
type: Object,
},
},
computed: {
displayData() {
return this.formatLinks(this.memo)
},
},
methods: {
formatLinks(text) {
const urlPattern = /(\b(https?|ftp):\/\/[-A-Z0-9+&@#\/%?=~_|!:,.;]*[-A-Z0-9+&@#\/%=~_|])/gim
const emailPattern = /(\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Z|a-z]{2,}\b)/g
// Replace URLs with clickable links
text = text.replace(
urlPattern,
'<a href="$1" target="_blank" rel="noopener noreferrer">$1</a>',
)
// Replace email addresses with mailto links
text = text.replace(emailPattern, '<a href="mailto:$1">$1</a>')
return text
},
},
}
</script>

View File

@ -11,27 +11,19 @@
>
<BRow class="mb-4 gap-5">
<BCol>
<BRow class="bg-248 gradido-border-radius position-relative">
<BFormRadio
name="shipping"
size="md"
reverse
:value="SEND_TYPES.send"
class="transaction-form-radio"
>
<BRow
class="bg-248 gradido-border-radius position-relative transaction-form-radio"
>
<BFormRadio name="shipping" size="md" reverse :value="SEND_TYPES.send">
{{ $t('send_gdd') }}
</BFormRadio>
</BRow>
</BCol>
<BCol>
<BRow class="bg-248 gradido-border-radius position-relative">
<BFormRadio
name="shipping"
:value="SEND_TYPES.link"
size="md"
reverse
class="transaction-form-radio"
>
<BRow
class="bg-248 gradido-border-radius position-relative transaction-form-radio"
>
<BFormRadio name="shipping" :value="SEND_TYPES.link" size="md" reverse>
{{ $t('send_per_link') }}
</BFormRadio>
</BRow>
@ -287,7 +279,7 @@ label {
display: flex !important;
justify-content: space-between;
> input {
> div > input {
position: absolute;
right: 35px;
top: 50%;

View File

@ -6,7 +6,9 @@
{{ $t('form.send_transaction_success') }}
</div>
<div class="text-center mt-5">
<b-button variant="primary" @click="$emit('on-back')">{{ $t('form.close') }}</b-button>
<BButton variant="primary" @click="$emit('on-back')">
{{ $t('form.close') }}
</BButton>
</div>
</div>
</template>

View File

@ -24,27 +24,18 @@
</transaction-list-item>
</div>
<div class="mt-3">
<div v-for="({ id, typeId }, index) in transactions" :key="`l2-` + id">
<div v-for="transaction in transactions" :key="`l2-` + transaction.id">
<transaction-list-item
v-if="typeId !== 'DECAY'"
:type-id="typeId"
v-if="transaction.typeId !== 'DECAY'"
:type-id="transaction.typeId"
class="pointer mb-3 bg-white app-box-shadow gradido-border-radius p-3 test-list-group-item"
>
<template #SEND>
<transaction-send v-bind="transactions[index]" />
<template v-if="transaction.typeId !== 'LINK_SUMMARY'" #item>
<gdd-transaction :transaction="transaction" />
</template>
<template #RECEIVE>
<transaction-receive v-bind="transactions[index]" />
</template>
<template #CREATION>
<transaction-creation v-bind="transactions[index]" />
</template>
<template #LINK_SUMMARY>
<template v-else #LINK_SUMMARY>
<transaction-link-summary
v-bind="transactions[index]"
v-bind="transaction"
:transaction-link-count="transactionLinkCount"
@update-transactions="updateTransactions"
/>
@ -75,19 +66,15 @@
<script>
import TransactionListItem from '@/components/TransactionListItem'
import TransactionDecay from '@/components/Transactions/TransactionDecay'
import TransactionSend from '@/components/Transactions/TransactionSend'
import TransactionReceive from '@/components/Transactions/TransactionReceive'
import TransactionCreation from '@/components/Transactions/TransactionCreation'
import TransactionLinkSummary from '@/components/Transactions/TransactionLinkSummary'
import GddTransaction from '@/components/Transactions/GddTransaction.vue'
export default {
name: 'GddTransactionList',
components: {
GddTransaction,
TransactionListItem,
TransactionDecay,
TransactionSend,
TransactionReceive,
TransactionCreation,
TransactionLinkSummary,
},
props: {

View File

@ -64,11 +64,11 @@ describe('InputEmail', () => {
})
it('has the placeholder "input-field-placeholder"', () => {
expect(wrapper.find('input').attributes('placeholder')).toBe('input-field-placeholder')
expect(wrapper.find('input').attributes('placeholder')).toBe('form.email')
})
it('has the label "input-field-label"', () => {
expect(wrapper.find('label').text()).toBe('input-field-label')
expect(wrapper.find('label').text()).toBe('form.email')
})
it('has the label for "input-field-name-input-field"', () => {

View File

@ -11,6 +11,7 @@
:placeholder="defaultTranslations.placeholder"
type="email"
trim
class="rounded-input"
:class="$route.path === '/send' ? 'bg-248' : ''"
:disabled="disabled"
autocomplete="off"
@ -33,14 +34,6 @@ const props = defineProps({
type: String,
default: 'email',
},
label: {
type: String,
default: 'Email',
},
placeholder: {
type: String,
default: 'Email',
},
disabled: {
type: Boolean,
default: false,
@ -54,8 +47,8 @@ const { value, errorMessage, validate, meta } = useField(() => props.name, 'requ
const { t } = useI18n()
const defaultTranslations = computed(() => ({
label: props.label ?? t('form.email'),
placeholder: props.placeholder ?? t('form.email'),
label: t('form.email'),
placeholder: t('form.email'),
}))
const normalizeEmail = (emailAddress) => {

View File

@ -80,7 +80,7 @@ describe('InputPassword', () => {
})
it('has the placeholder "input-field-placeholder"', () => {
expect(wrapper.find('input').attributes('placeholder')).toEqual('input-field-placeholder')
expect(wrapper.find('input').attributes('placeholder')).toEqual('form.password')
})
it('has the value ""', () => {
@ -88,7 +88,7 @@ describe('InputPassword', () => {
})
it('has the label "input-field-label"', () => {
expect(wrapper.find('label').text()).toEqual('input-field-label')
expect(wrapper.find('label').text()).toEqual('form.password')
})
it('has the label for "input-field-name-input-field"', () => {

View File

@ -50,14 +50,6 @@ const props = defineProps({
type: String,
default: 'password',
},
label: {
type: String,
default: 'Password',
},
placeholder: {
type: String,
default: 'Password',
},
immediate: {
type: Boolean,
default: false,
@ -78,18 +70,11 @@ const { value, errorMessage, meta, errors, validate } = useField(name, props.rul
validateOnMount: props.immediate,
})
// onMounted(async () => {
// await nextTick()
// if (props.immediate) {
// await validate()
// }
// })
const { t } = useI18n()
const defaultTranslations = computed(() => ({
label: props.label ?? t('form.password'),
placeholder: props.placeholder ?? t('form.password'),
label: t('form.password'),
placeholder: t('form.password'),
}))
const showPassword = ref(false)
@ -109,3 +94,9 @@ const ariaMsg = computed(() => ({
const labelFor = computed(() => `${props.name}-input-field`)
</script>
<style scoped>
input {
border-radius: 17px 0 0 17px;
}
</style>

View File

@ -4,6 +4,7 @@ import { createRouter, createWebHistory, RouterLink } from 'vue-router'
import { createStore } from 'vuex'
import Navbar from './Navbar.vue'
import { BImg, BNavbar, BNavbarBrand, BNavbarNav } from 'bootstrap-vue-next'
import AppAvatar from '@/components/AppAvatar.vue'
// Mock vue-avatar
vi.mock('vue-avatar', () => ({
@ -50,6 +51,7 @@ describe('Navbar', () => {
BNavbarBrand,
BImg,
RouterLink,
AppAvatar,
},
},
props: {
@ -72,11 +74,11 @@ describe('Navbar', () => {
describe('.avatar element', () => {
it('is rendered', () => {
expect(wrapper.findComponent({ name: 'Avatar' }).exists()).toBe(true)
expect(wrapper.findComponent({ name: 'AppAvatar' }).exists()).toBe(true)
})
it("has the user's initials", () => {
const avatar = wrapper.findComponent({ name: 'Avatar' })
const avatar = wrapper.findComponent({ name: 'AppAvatar' })
expect(avatar.props('initials')).toBe('TU')
})
})

View File

@ -20,12 +20,14 @@
<router-link to="/settings">
<div class="d-flex align-items-center">
<div class="me-3">
<avatar
:username="username.username"
<app-avatar
class="vue3-avatar"
:name="username.username"
:initials="username.initials"
:border="false"
:color="'#fff'"
:size="61"
></avatar>
/>
</div>
<div>
<div data-test="navbar-item-username">{{ username.username }}</div>
@ -45,13 +47,8 @@
</template>
<script>
import Avatar from 'vue-avatar'
export default {
name: 'Navbar',
components: {
Avatar,
},
props: {
balance: { type: Number, required: true },
},
@ -129,8 +126,9 @@ button.navbar-toggler > span.navbar-toggler-icon {
.navbar-element {
z-index: 1000;
position: fixed;
width: 100%;
background-color: #f5f5f5e6;
left: 0;
right: 0;
}
.sheet-img {
@ -139,3 +137,9 @@ button.navbar-toggler > span.navbar-toggler-icon {
}
}
</style>
<style scoped>
:deep(.container-fluid) {
padding: 0 !important;
}
</style>

View File

@ -107,6 +107,8 @@ const props = defineProps({
shadow: { type: Boolean, default: true },
})
const emit = defineEmits(['closeSidebar'])
const route = useRoute()
const communityLink = ref(null)
@ -134,6 +136,7 @@ watch(
link.classList.remove('active-route')
link.classList.remove('router-link-exact-active')
}
emit('closeSidebar')
},
)
</script>

View File

@ -7,24 +7,55 @@
no-header-close
horizontal
skip-animation
:model-value="isMobileMenuOpen"
@update:model-value="isMobileMenuOpen = $event"
>
<div class="mobile-sidebar-wrapper py-2">
<BImg src="img/svg/lines.png" />
<sidebar :shadow="false" @admin="emit('admin')" @logout="emit('logout')" />
<sidebar
:shadow="false"
@admin="emit('admin')"
@close-sidebar="closeMenu"
@logout="emit('logout')"
/>
</div>
<div v-b-toggle.sidebar-mobile class="simple-overlay" />
</BCollapse>
</template>
<script setup>
import { onUnmounted, ref, watch } from 'vue'
import { lock, unlock } from 'tua-body-scroll-lock'
const isMobileMenuOpen = ref(false)
const emit = defineEmits(['admin', 'logout'])
const closeMenu = () => {
isMobileMenuOpen.value = false
}
watch(
() => isMobileMenuOpen.value,
(newVal) => {
if (newVal) {
lock()
} else {
unlock()
}
},
)
onUnmounted(() => {
unlock()
})
</script>
<style>
.mobile-sidebar-wrapper {
width: 220px;
background-color: #fff;
z-index: 10;
z-index: 1001;
position: absolute;
border-bottom-right-radius: 26px;
border-top-right-radius: 26px;
@ -38,6 +69,7 @@ const emit = defineEmits(['admin', 'logout'])
left: 0;
top: 0;
bottom: 0;
z-index: 1001;
}
.simple-overlay {
@ -46,7 +78,7 @@ const emit = defineEmits(['admin', 'logout'])
top: 0;
bottom: 0;
background-color: #212529;
z-index: 9;
z-index: 99;
opacity: 0.6;
width: calc(100vw - 200px);
}

View File

@ -1,6 +1,6 @@
import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest'
import { mount } from '@vue/test-utils'
import { nextTick } from 'vue'
import { nextTick, ref } from 'vue'
import SessionLogoutTimeout from './SessionLogoutTimeout.vue'
import { useLazyQuery } from '@vue/apollo-composable'
import { useStore } from 'vuex'
@ -13,25 +13,28 @@ vi.mock('vuex', () => ({
vi.mock('@vue/apollo-composable', () => ({
useLazyQuery: vi.fn(() => ({
load: vi.fn(),
loading: false,
error: { value: null },
loading: ref(false),
error: ref(null),
})),
}))
// Mock bootstrap-vue-next
const mockShow = vi.fn()
const mockHide = vi.fn()
vi.mock('bootstrap-vue-next', () => ({
useModal: vi.fn(() => ({
show: mockShow,
hide: mockHide,
})),
BModal: { template: '<div><slot></slot><slot name="modal-footer"></slot></div>' },
BCard: { template: '<div><slot></slot></div>' },
BCardText: { template: '<div><slot></slot></div>' },
BRow: { template: '<div><slot></slot></div>' },
BCol: { template: '<div><slot></slot></div>' },
BButton: { template: '<button><slot></slot></button>' },
BModal: {
name: 'BModal',
props: ['modelValue'],
template: '<div><slot></slot><slot name="modal-footer"></slot></div>',
},
BCard: { name: 'BCard', template: '<div><slot></slot></div>' },
BCardText: { name: 'BCardText', template: '<div><slot></slot></div>' },
BRow: { name: 'BRow', template: '<div><slot></slot></div>' },
BCol: { name: 'BCol', template: '<div><slot></slot></div>' },
BButton: { name: 'BButton', template: '<button><slot></slot></button>' },
}))
const setTokenTime = (seconds) => {
@ -59,12 +62,12 @@ describe('SessionLogoutTimeout', () => {
beforeEach(() => {
vi.useFakeTimers()
mockShow.mockClear()
mockHide.mockClear()
})
afterEach(() => {
vi.clearAllMocks()
vi.useRealTimers()
})
it('renders the component div.session-logout-timeout', () => {
@ -72,69 +75,115 @@ describe('SessionLogoutTimeout', () => {
expect(wrapper.find('div.session-logout-timeout').exists()).toBe(true)
})
describe('tokenExpiresInSeconds computed property', () => {
it('returns 0 when token is expired', async () => {
wrapper = createWrapper(setTokenTime(-60))
await nextTick()
expect(wrapper.vm.tokenExpiresInSeconds).toBe(0)
})
it('returns remaining seconds when token is not expired', async () => {
wrapper = createWrapper(setTokenTime(120))
await nextTick()
expect(wrapper.vm.tokenExpiresInSeconds).toBeGreaterThan(0)
expect(wrapper.vm.tokenExpiresInSeconds).toBeLessThanOrEqual(120)
})
})
describe('checkExpiration', () => {
it('shows modal when token expires in less than 75 seconds', async () => {
describe('token expiration', () => {
it('shows modal when remaining time is 75 seconds or less', async () => {
wrapper = createWrapper(setTokenTime(74))
await nextTick()
vi.runAllTimers()
vi.runOnlyPendingTimers()
await nextTick()
expect(mockShow).toHaveBeenCalled()
const modal = wrapper.findComponent({ name: 'BModal' })
expect(modal.props('modelValue')).toBe(true)
})
it('emits logout when token is expired', async () => {
wrapper = createWrapper(setTokenTime(-1))
it('does not show modal when remaining time is more than 75 seconds', async () => {
// 76 don't work, because value will be rounded down with floor, so with running the code time were passing,
// and 76 will be rounded down to 75
wrapper = createWrapper(setTokenTime(77))
await nextTick()
const modal = wrapper.findComponent({ name: 'BModal' })
expect(modal.props('modelValue')).toBe(false)
})
it('emits logout when time expires', async () => {
wrapper = createWrapper(setTokenTime(2))
await nextTick()
vi.runAllTimers()
await nextTick()
expect(wrapper.emitted('logout')).toBeTruthy()
})
})
describe('handleOk', () => {
it('hides modal and does not emit logout on successful verification', async () => {
it('hides modal and continues session on successful verification', async () => {
const mockLoad = vi.fn().mockResolvedValue({})
vi.mocked(useLazyQuery).mockReturnValue({
load: mockLoad,
loading: false,
error: { value: null },
loading: ref(false),
error: ref(null),
})
wrapper = createWrapper()
await wrapper.vm.handleOk({ preventDefault: vi.fn() })
await wrapper.findComponent({ name: 'BButton' }).trigger('click')
await nextTick()
expect(mockLoad).toHaveBeenCalled()
expect(mockHide).toHaveBeenCalledWith('modalSessionTimeOut')
expect(wrapper.emitted('logout')).toBeFalsy()
})
it('emits logout on failed verification', async () => {
const mockLoad = vi.fn().mockRejectedValue(new Error('Verification failed'))
it('emits logout on verification failure', async () => {
const mockLoad = vi.fn().mockResolvedValue({})
vi.mocked(useLazyQuery).mockReturnValue({
load: mockLoad,
loading: false,
error: { value: new Error('Verification failed') },
loading: ref(false),
error: ref(new Error('Verification failed')),
})
wrapper = createWrapper()
await wrapper.vm.handleOk({ preventDefault: vi.fn() })
await wrapper.findComponent({ name: 'BButton' }).trigger('click')
await nextTick()
expect(mockLoad).toHaveBeenCalled()
expect(wrapper.emitted('logout')).toBeTruthy()
})
it('emits logout when verification throws an error', async () => {
const mockLoad = vi.fn().mockRejectedValue(new Error('Network error'))
vi.mocked(useLazyQuery).mockReturnValue({
load: mockLoad,
loading: ref(false),
error: ref(null),
})
wrapper = createWrapper()
await wrapper.findComponent({ name: 'BButton' }).trigger('click')
await nextTick()
expect(mockLoad).toHaveBeenCalled()
expect(wrapper.emitted('logout')).toBeTruthy()
})
})
describe('time formatting', () => {
it('formats remaining time correctly', async () => {
wrapper = createWrapper(setTokenTime(65))
await nextTick()
const warningText = wrapper.find('.text-warning')
// second will be rounded with floor
expect(warningText.text()).toContain('64')
})
it('shows 00 when time is expired', async () => {
wrapper = createWrapper(setTokenTime(-1))
await nextTick()
const warningText = wrapper.find('.text-warning')
expect(warningText.text()).toContain('00')
})
})
describe('cleanup', () => {
it('clears interval on unmount', () => {
const clearIntervalSpy = vi.spyOn(window, 'clearInterval')
wrapper = createWrapper()
wrapper.unmount()
expect(clearIntervalSpy).toHaveBeenCalled()
})
})
})

View File

@ -18,7 +18,7 @@
</div>
<div class="p-3 h2 text-warning">
{{ $t('session.logoutIn') }}
<b>{{ tokenExpiresInSeconds }}</b>
<b>{{ formatTime(remainingTime) }}</b>
{{ $t('time.seconds') }}
</div>
</BCardText>
@ -43,91 +43,79 @@
</template>
<script setup>
import { ref, computed, onBeforeUnmount, watch } from 'vue'
import { ref, computed, watch, onMounted, onUnmounted } from 'vue'
import { useStore } from 'vuex'
import { useLazyQuery } from '@vue/apollo-composable'
import { verifyLogin } from '@/graphql/queries'
import { useModal } from 'bootstrap-vue-next'
import { BButton, BCard, BCardText, BCol, BModal, BRow, useModal } from 'bootstrap-vue-next'
const store = useStore()
const emit = defineEmits(['logout'])
const sessionModalModel = ref(false)
const { show: showModal, hide: hideModal } = useModal('modalSessionTimeOut')
const { hide: hideModal } = useModal('modalSessionTimeOut')
const { load: verifyLoginQuery, loading, error } = useLazyQuery(verifyLogin)
const timerInterval = ref(null)
const remainingTime = ref(0)
let intervalId = null
const now = ref(new Date().getTime())
const tokenExpirationTime = computed(() => new Date(store.state.tokenTime * 1000))
const tokenExpiresInSeconds = computed(() => {
const remainingSecs = Math.floor(
(new Date(store.state.tokenTime * 1000).getTime() - now.value) / 1000,
)
return remainingSecs <= 0 ? 0 : remainingSecs
const isTokenValid = computed(() => {
return remainingTime.value > 0
})
const updateNow = () => {
now.value = new Date().getTime()
const calculateRemainingTime = () => {
const now = new Date()
const diff = tokenExpirationTime.value - now
remainingTime.value = Math.max(0, Math.floor(diff / 1000))
// Show modal if remaining time is 75 seconds or less
if (remainingTime.value <= 75) {
sessionModalModel.value = true
}
// Clear interval if time expired
if (remainingTime.value <= 0) {
clearInterval(intervalId)
}
}
const checkExpiration = () => {
if (tokenExpiresInSeconds.value < 75 && timerInterval.value && !sessionModalModel.value) {
showModal()
const formatTime = (seconds) => {
if (seconds <= 0) return '00'
return `${seconds.toString().padStart(2, '0')}`
}
onMounted(() => {
calculateRemainingTime()
if (isTokenValid.value) {
intervalId = setInterval(calculateRemainingTime, 1000)
}
if (tokenExpiresInSeconds.value === 0) {
stopTimer()
})
onUnmounted(() => {
if (intervalId) {
clearInterval(intervalId)
}
})
watch(remainingTime, (newTime) => {
if (newTime <= 0) {
emit('logout')
}
}
})
const handleOk = async (bvModalEvent) => {
bvModalEvent.preventDefault()
try {
await verifyLoginQuery()
if (error.value) {
emit('logout')
throw new Error('Login verification failed')
}
hideModal('modalSessionTimeOut')
} catch {
stopTimer()
emit('logout')
}
}
const startTimer = () => {
stopTimer()
timerInterval.value = setInterval(() => {
updateNow()
checkExpiration()
}, 1000)
}
const stopTimer = () => {
if (timerInterval.value) {
clearInterval(timerInterval.value)
timerInterval.value = null
}
}
watch(
tokenExpiresInSeconds,
(newValue) => {
if (newValue < 75 && !timerInterval.value) {
startTimer()
} else if (newValue >= 75 && timerInterval.value) {
stopTimer()
}
},
{ immediate: true },
)
onBeforeUnmount(() => {
stopTimer()
})
checkExpiration()
</script>

View File

@ -0,0 +1,84 @@
<template>
<div
class="skeleton-loader my-2"
:class="{ 'with-animation': !disableAnimation }"
:style="{
width: computedWidth,
height: computedHeight,
borderRadius: computedRadius,
}"
/>
</template>
<script setup>
import { computed } from 'vue'
const props = defineProps({
width: {
type: [String, Number],
default: '100%',
},
height: {
type: [String, Number],
default: '1rem',
},
disableAnimation: {
type: Boolean,
default: false,
},
fullRadius: {
type: Boolean,
default: false,
},
useGradidoRadius: {
type: Boolean,
},
})
const computedWidth = computed(() => {
if (typeof props.width === 'number') return `${props.width}px`
return props.width
})
const computedHeight = computed(() => {
if (typeof props.height === 'number') return `${props.height}px`
return props.height
})
const computedRadius = computed(() => {
return props.fullRadius ? '100%' : props.useGradidoRadius ? '26px' : '0'
})
</script>
<style scoped>
.skeleton-loader {
background: #e9ecef; /* Bootstrap 5 gray-200 color */
border-radius: 0.25rem;
}
.with-animation {
position: relative;
overflow: hidden;
}
.with-animation::after {
position: absolute;
inset: 0;
transform: translateX(-100%);
background: linear-gradient(
90deg,
rgb(255 255 255 / 0%) 0%,
rgb(255 255 255 / 20%) 20%,
rgb(255 255 255 / 50%) 60%,
rgb(255 255 255 / 0%) 100%
);
animation: shimmer 2s infinite;
content: '';
}
@keyframes shimmer {
100% {
transform: translateX(100%);
}
}
</style>

View File

@ -54,6 +54,7 @@ export default {
font-size: 14px;
text-wrap: nowrap;
color: black !important;
border-radius: 25px;
}
}

View File

@ -4,25 +4,24 @@
<BCol class="h3">{{ $t('transaction.lastTransactions') }}</BCol>
</BRow>
<div v-for="(transaction, index) in transactions" :key="transaction.id">
<BRow
v-if="
index <= 8 &&
transaction.typeId !== 'DECAY' &&
transaction.typeId !== 'LINK_SUMMARY' &&
transaction.typeId !== 'CREATION'
"
align-v="center"
class="mb-4"
>
<div v-for="transaction in filteredTransactions" :key="transaction.id">
<BRow align-v="center" class="mb-4">
<BCol cols="auto">
<div class="align-items-center">
<avatar
<!-- <avatar-->
<!-- class="vue3-avatar"-->
<!-- :size="72"-->
<!-- :color="'#fff'"-->
<!-- :name="`${transaction.linkedUser.firstName} ${transaction.linkedUser.lastName}`"-->
<!-- :initials="`${transaction.linkedUser.firstName[0]}${transaction.linkedUser.lastName[0]}`"-->
<!-- :border="false"-->
<!-- />-->
<app-avatar
:size="72"
:color="'#fff'"
:username="`${transaction.linkedUser.firstName} ${transaction.linkedUser.lastName}`"
:name="`${transaction.linkedUser.firstName} ${transaction.linkedUser.lastName}`"
:initials="`${transaction.linkedUser.firstName[0]}${transaction.linkedUser.lastName[0]}`"
></avatar>
/>
</div>
</BCol>
<BCol class="p-1">
@ -31,14 +30,19 @@
<div class="fw-bold">
<name :linked-user="transaction.linkedUser" font-color="text-dark" />
</div>
<div class="d-flex mt-3">
<div class="small">
<button
class="transaction-details-link d-flex mt-3"
role="link"
:data-href="`/transactions#transaction-${transaction.id}`"
@click="handleRedirect(transaction.id)"
>
<span class="small">
{{ $filters.GDD(transaction.amount) }}
</div>
<div class="small ms-3 text-end">
</span>
<span class="small ms-3 text-end">
{{ $d(new Date(transaction.balanceDate), 'short') }}
</div>
</div>
</span>
</button>
</BCol>
</BRow>
</BCol>
@ -46,23 +50,50 @@
</div>
</div>
</template>
<script>
import Avatar from 'vue-avatar'
<script setup>
import Name from '@/components/TransactionRows/Name'
import { useRoute, useRouter } from 'vue-router'
import { useStore } from 'vuex'
import { computed } from 'vue'
import AppAvatar from '@/components/AppAvatar.vue'
const props = defineProps({
transactions: {
default: () => [],
type: Array,
},
})
export default {
name: 'LastTransactions',
components: {
Avatar,
Name,
},
props: {
transactions: {
default: () => [],
type: Array,
},
transactionCount: { type: Number, default: 0 },
transactionLinkCount: { type: Number, default: 0 },
},
const router = useRouter()
const route = useRoute()
const store = useStore()
const handleRedirect = (id) => {
store.dispatch('changeTransactionToHighlightId', id)
if (route.name !== 'Transactions') router.replace({ name: 'Transactions' })
}
const filteredTransactions = computed(() => {
return props.transactions
.filter(
(transaction) =>
transaction.typeId !== 'DECAY' &&
transaction.typeId !== 'LINK_SUMMARY' &&
transaction.typeId !== 'CREATION',
)
.slice(0, 8)
})
</script>
<style scoped lang="scss">
.transaction-details-link {
color: var(--bs-body-color) !important;
border: none;
background-color: transparent;
border-bottom: 1px solid transparent;
transition: border-bottom-color 0.15s ease-in-out;
}
.transaction-details-link:hover {
border-color: #383838;
}
</style>

View File

@ -245,6 +245,7 @@ import { BAvatar, BCol, BCollapse, BRow } from 'bootstrap-vue-next'
import TransactionCollapse from '@/components/TransactionCollapse.vue'
import { GdtEntryType } from '@/graphql/enums'
import VariantIcon from '@/components/VariantIcon.vue'
import { createStore } from 'vuex'
const mockToastError = vi.fn()
vi.mock('@/composables/useToast', () => ({
@ -268,6 +269,13 @@ describe('Transaction', () => {
const Wrapper = () => {
return mount(Transaction, {
global: {
plugins: [
createStore({
state: {
transactionToHighlightId: '',
},
}),
],
mocks: {
$d: (value) => value?.toString() ?? '',
$n: (value) => value?.toString() ?? '',

View File

@ -49,11 +49,12 @@
</template>
<script setup>
import { ref, computed, onMounted, getCurrentInstance } from 'vue'
import { ref, computed, onMounted, getCurrentInstance, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import CollapseIcon from './TransactionRows/CollapseIcon'
import TransactionCollapse from './TransactionCollapse'
import { GdtEntryType } from '../graphql/enums'
import { useStore } from 'vuex'
const props = defineProps({
amount: Number,
@ -71,10 +72,14 @@ const props = defineProps({
const collapseStatus = ref([])
const visible = ref(false)
const store = useStore()
const { t, n } = useI18n()
const collapseId = computed(() => 'gdt-collapse-' + String(props.id))
const transactionToHighlightId = computed(() => store.state.transactionToHighlightId)
const getLinesByType = computed(() => {
switch (props.gdtEntryType) {
case GdtEntryType.FORM:
@ -116,6 +121,12 @@ const getLinesByType = computed(() => {
}
})
watch(transactionToHighlightId, () => {
if (parseInt(transactionToHighlightId.value) === props.id) {
visible.value = true
}
})
// onMounted(() => {
// // Note: This event listener setup might need to be adjusted for Vue 3
// const root = getCurrentInstance().appContext.config.globalProperties

View File

@ -1,6 +1,6 @@
<template>
<div class="transaction-link gradido-custom-background">
<BRow :class="validLink ? '' : 'bg-muted text-dark'" class="mb-2 pt-2 pb-2">
<BRow :class="{ 'light-gray-text': !validLink }" class="mb-2 pt-2 pb-2">
<BCol cols="1">
<variant-icon icon="link45deg" variant="danger" />
</BCol>
@ -153,4 +153,8 @@ const toggleQrModal = () => {
width: 1.5rem;
height: 1.5rem;
}
.light-gray-text {
color: #adb5bd !important;
}
</style>

View File

@ -1,5 +1,6 @@
<template>
<div>
<slot name="item" />
<slot :name="typeId"></slot>
</div>
</template>

View File

@ -5,7 +5,7 @@
<div class="text-end">{{ $t('form.memo') }}</div>
</BCol>
<BCol cols="7">
<div class="gdd-transaction-list-message">{{ memo }}</div>
<div class="gdd-transaction-list-message" v-html="displayData" />
</BCol>
</BRow>
</div>
@ -19,5 +19,27 @@ export default {
required: true,
},
},
computed: {
displayData() {
return this.formatLinks(this.memo)
},
},
methods: {
formatLinks(text) {
const urlPattern = /(\b(https?|ftp):\/\/[-A-Z0-9+&@#\/%?=~_|!:,.;]*[-A-Z0-9+&@#\/%=~_|])/gim
const emailPattern = /(\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Z|a-z]{2,}\b)/g
// Replace URLs with clickable links
text = text.replace(
urlPattern,
'<a href="$1" target="_blank" rel="noopener noreferrer">$1</a>',
)
// Replace email addresses with mailto links
text = text.replace(emailPattern, '<a href="mailto:$1">$1</a>')
return text
},
},
}
</script>

View File

@ -1,4 +1,4 @@
import { mount } from '@vue/test-utils'
import { mount, RouterLinkStub } from '@vue/test-utils'
import { describe, it, expect, beforeEach, vi } from 'vitest'
import Name from './Name'
import { BLink } from 'bootstrap-vue-next'
@ -44,7 +44,7 @@ describe('Name', () => {
global: {
mocks,
stubs: {
BLink,
RouterLink: RouterLinkStub,
},
},
props: propsData,
@ -88,31 +88,18 @@ describe('Name', () => {
it('has a link', () => {
expect(
wrapper
.find('div.gdd-transaction-list-item-name')
.findComponent({ name: 'BLink' })
.exists(),
wrapper.find('div.gdd-transaction-list-item-name').findComponent(RouterLinkStub).exists(),
).toBe(true)
})
describe('click link', () => {
beforeEach(async () => {
await wrapper.findComponent({ name: 'BLink' }).trigger('click')
})
it('pushes router to send', () => {
expect(routerPushMock).toHaveBeenCalledWith({
path: '/send',
})
})
it('pushes params for gradidoID and community UUID', () => {
expect(routerPushMock).toHaveBeenCalledWith({
params: {
communityIdentifier: 'community UUID',
userIdentifier: 'gradido-ID',
},
})
it('RouterLink has correct to prop', () => {
const routerLink = wrapper.findComponent(RouterLinkStub)
expect(routerLink.props().to).toEqual({
name: 'Send',
params: {
communityIdentifier: 'community UUID',
userIdentifier: 'gradido-ID',
},
})
})
})

View File

@ -2,9 +2,9 @@
<div class="name">
<div class="gdd-transaction-list-item-name">
<div v-if="linkedUser && linkedUser.gradidoID">
<BLink :class="fontColor" @click.stop="tunnelEmail">
<router-link :class="fontColor" :to="pushTo">
{{ itemText }}
</BLink>
</router-link>
</div>
<span v-else>{{ itemText }}</span>
</div>
@ -45,6 +45,15 @@ export default {
(this.linkedUser.communityName ? ' / ' + this.linkedUser.communityName : '')
: this.text
},
pushTo() {
return {
name: 'Send',
params: {
userIdentifier: this.linkedUser.gradidoID,
communityIdentifier: this.linkedUser.communityUuid,
},
}
},
},
methods: {
async tunnelEmail() {

View File

@ -0,0 +1,167 @@
<template>
<div
:id="`transaction-${props.transaction.id}`"
ref="gddTransaction"
:class="`transaction-slot-${props.transaction.type}`"
:data-transaction-id="`transaction-${props.transaction.id}`"
@click="toggleVisible"
>
<BRow class="align-items-center">
<BCol cols="3" lg="2" md="2">
<component :is="avatarComponent" v-bind="avatarProps">
<variant-icon v-if="isCreationType" icon="gift" variant="white" />
</component>
</BCol>
<BCol>
<div>
<component :is="nameComponent" v-bind="nameProps" />
</div>
<span class="small">{{ $d(new Date(props.transaction.balanceDate), 'short') }}</span>
<span class="ms-4 small">{{ $d(new Date(props.transaction.balanceDate), 'time') }}</span>
</BCol>
<BCol cols="8" lg="3" md="3" sm="8" offset="3" offset-md="0" offset-lg="0">
<div class="small mb-2">
{{ $t(`decay.types.${props.transaction.typeId.toLowerCase()}`) }}
</div>
<div
:class="[
'fw-bold',
{
'gradido-global-color-accent': props.transaction.typeId === 'RECEIVE',
'text-140': props.transaction.typeId === 'SEND',
},
]"
data-test="transaction-amount"
>
{{ $filters.GDD(props.transaction.amount) }}
</div>
<div v-if="props.transaction.linkId" class="small">
{{ $t('via_link') }}
<variant-icon icon="link45deg" variant="muted" class="m-mb-1" />
</div>
</BCol>
<BCol cols="12" md="1" lg="1" class="text-end">
<collapse-icon class="text-end" :visible="visible" />
</BCol>
</BRow>
<BCollapse :model-value="visible" class="pb-4 pt-lg-3">
<decay-information
:type-id="props.transaction.typeId"
:decay="props.transaction.decay"
:amount="props.transaction.amount"
:memo="props.transaction.memo"
:balance="props.transaction.balance"
:previous-balance="props.transaction.previousBalance"
/>
</BCollapse>
</div>
</template>
<script setup>
import { ref, computed, watch } from 'vue'
import { useStore } from 'vuex'
import CollapseIcon from '../TransactionRows/CollapseIcon'
import Name from '../TransactionRows/Name'
import DecayInformation from '../DecayInformations/DecayInformation'
import { BAvatar, BRow } from 'bootstrap-vue-next'
import AppAvatar from '@/components/AppAvatar.vue'
const props = defineProps({
transaction: {
type: Object,
required: true,
},
})
const gddTransaction = ref(null)
const store = useStore()
const visible = ref(false)
const toggleVisible = () => {
visible.value = !visible.value
}
const username = computed(() => ({
username: `${props.transaction?.linkedUser?.firstName} ${props.transaction?.linkedUser?.lastName}`,
initials: `${props.transaction?.linkedUser?.firstName[0]}${props.transaction.linkedUser?.lastName[0]}`,
}))
const isCreationType = computed(() => {
return props.transaction.typeId === 'CREATION'
})
const avatarComponent = computed(() => {
return isCreationType.value ? BAvatar : AppAvatar
})
const avatarProps = computed(() => {
if (isCreationType.value) {
return {
size: 42,
rounded: 'lg',
variant: 'success',
}
} else {
return {
username: username.value.username,
initials: username.value.initials,
color: '#fff',
size: 42,
}
}
})
const nameComponent = computed(() => {
return isCreationType.value ? 'div' : Name
})
const nameProps = computed(() => {
if (isCreationType.value) {
return {
class: 'fw-bold',
}
} else {
return {
class: 'fw-bold',
amount: props.transaction.amount,
linkedUser: props.transaction.linkedUser,
linkId: props.transaction.linkId,
}
}
})
const handleOpenAfterScroll = (scrollY) => {
const handleScrollEnd = () => {
window.removeEventListener('scrollend', handleScrollEnd)
}
window.addEventListener('scrollend', handleScrollEnd)
window.scrollTo(0, scrollY)
}
const transactionToHighlightId = computed(() => store.state.transactionToHighlightId)
watch(
transactionToHighlightId,
async (newValue) => {
if (parseInt(newValue) === props.transaction.id) {
visible.value = true
setTimeout(() => {
const element = document.getElementById(`transaction-${props.transaction.id}`)
const yVal = element.getBoundingClientRect().top + window.pageYOffset - 16
handleOpenAfterScroll(yVal)
}, 300)
await store.dispatch('changeTransactionToHighlightId', '')
}
},
{ immediate: true },
)
</script>
<style lang="scss" scoped>
:deep(.b-avatar-custom > svg) {
height: 2em;
width: 2em;
}
</style>

View File

@ -1,16 +1,16 @@
import { mount } from '@vue/test-utils'
import { describe, it, expect, beforeEach, vi } from 'vitest'
import TransactionReceive from './TransactionReceive'
import Avatar from 'vue-avatar'
import CollapseIcon from '../TransactionRows/CollapseIcon'
import Name from '../TransactionRows/Name'
import DecayInformation from '../DecayInformations/DecayInformation'
import { BCol, BCollapse, BRow } from 'bootstrap-vue-next'
import AppAvatar from '@/components/AppAvatar.vue'
// Mock child components
vi.mock('vue-avatar', () => ({
vi.mock('app-avatar', () => ({
default: {
name: 'Avatar',
name: 'AppAvatar',
render: () => null,
},
}))
@ -86,7 +86,7 @@ describe('TransactionReceive', () => {
$d: (date, format) => `Mocked ${format} date for ${date}`,
},
components: {
Avatar,
AppAvatar,
CollapseIcon,
Name,
DecayInformation,

View File

@ -3,12 +3,14 @@
<BRow class="align-items-center">
<BCol cols="3" lg="2" md="2">
<!-- <b-avatar :text="avatarText" variant="success" size="3em"></b-avatar> -->
<avatar
:username="username.username"
<app-avatar
class="vue3-avatar"
:name="username.username"
:initials="username.initials"
:color="'#fff'"
:size="42"
></avatar>
:border="false"
/>
</BCol>
<BCol>
<div>
@ -46,7 +48,6 @@
</div>
</template>
<script>
import Avatar from 'vue-avatar'
import CollapseIcon from '../TransactionRows/CollapseIcon'
import Name from '../TransactionRows/Name'
import DecayInformation from '../DecayInformations/DecayInformation'
@ -54,7 +55,6 @@ import DecayInformation from '../DecayInformations/DecayInformation'
export default {
name: 'TransactionReceive',
components: {
Avatar,
CollapseIcon,
Name,
DecayInformation,

View File

@ -1,15 +1,15 @@
import { mount } from '@vue/test-utils'
import { describe, it, expect, beforeEach, vi } from 'vitest'
import TransactionSend from './TransactionSend'
import Avatar from 'vue-avatar'
import CollapseIcon from '../TransactionRows/CollapseIcon'
import Name from '../TransactionRows/Name'
import DecayInformation from '../DecayInformations/DecayInformation'
import { BCol, BCollapse, BRow } from 'bootstrap-vue-next'
import AppAvatar from '@/components/AppAvatar.vue'
vi.mock('vue-avatar', () => ({
vi.mock('app-avatar', () => ({
default: {
name: 'Avatar',
name: 'AppAvatar',
render: () => null,
},
}))
@ -84,7 +84,7 @@ describe('TransactionSend', () => {
$d: (date, format) => `Mocked ${format} date for ${date}`,
},
components: {
Avatar,
AppAvatar,
CollapseIcon,
Name,
DecayInformation,

View File

@ -2,11 +2,13 @@
<div class="transaction-slot-send" @click="visible = !visible">
<BRow class="align-items-center">
<BCol cols="3" lg="2" md="2">
<avatar
:username="username.username"
<app-avatar
class="vue3-avatar"
:name="username.username"
:initials="username.initials"
:color="'#fff'"
:size="42"
:border="false"
/>
</BCol>
<BCol>
@ -45,7 +47,6 @@
</div>
</template>
<script>
import Avatar from 'vue-avatar'
import CollapseIcon from '../TransactionRows/CollapseIcon'
import Name from '../TransactionRows/Name'
import DecayInformation from '../DecayInformations/DecayInformation'
@ -53,7 +54,6 @@ import DecayInformation from '../DecayInformations/DecayInformation'
export default {
name: 'TransactionSend',
components: {
Avatar,
CollapseIcon,
Name,
DecayInformation,

View File

@ -0,0 +1,46 @@
<template>
<div class="coordinates-display">
<div class="p-2">
<BButton class="me-1" size="sm" @click="emit('centerMap', 'USER')"><IBiPinMapFill /></BButton>
<span>
{{
$t('settings.GMS.map.userCoords', {
lat: userPosition.lat.toFixed(6),
lng: userPosition.lng.toFixed(6),
})
}}
</span>
</div>
<div class="p-2">
<BButton class="me-1" size="sm" @click="emit('centerMap', 'COMMUNITY')">
<IBiPinMap />
</BButton>
<span>
{{
$t('settings.GMS.map.communityCoords', {
lat: communityPosition.lat.toFixed(6),
lng: communityPosition.lng.toFixed(6),
})
}}
</span>
</div>
</div>
</template>
<script setup>
import { BButton } from 'bootstrap-vue-next'
const emit = defineEmits(['centerMap'])
const props = defineProps({
userPosition: Object,
communityPosition: Object,
})
</script>
<style scoped>
.coordinates-display {
margin-top: 10px;
font-weight: bold;
}
</style>

View File

@ -2,6 +2,7 @@ import { mount } from '@vue/test-utils'
import { describe, it, expect, beforeEach, vi } from 'vitest'
import UserCard from './UserCard.vue'
import { BCol, BRow } from 'bootstrap-vue-next'
import AppAvatar from '@/components/AppAvatar.vue'
vi.mock('vue-avatar', () => ({
default: {
@ -39,6 +40,7 @@ describe('UserCard', () => {
components: {
BRow,
BCol,
AppAvatar,
},
mocks,
},
@ -53,12 +55,12 @@ describe('UserCard', () => {
expect(wrapper.find('.userdata-card').exists()).toBe(true)
})
it('renders the SPAN Element ".vue-avatar--wrapper"', () => {
expect(wrapper.find('.vue-avatar--wrapper').exists()).toBe(true)
it('renders the DIV Element ".app-avatar"', () => {
expect(wrapper.find('.app-avatar').exists()).toBe(true)
})
it('displays the first letters of the firstName and lastName', () => {
expect(wrapper.find('.vue-avatar--wrapper span').text()).toBe('BB')
expect(wrapper.find('.app-avatar').text()).toBe('BB')
})
it('displays the correct balance', () => {

View File

@ -1,12 +1,14 @@
<template>
<div class="userdata-card">
<div class="center-per-margin">
<avatar
:username="username.username"
<app-avatar
class="vue3-avatar"
:name="username.username"
:initials="username.initials"
:color="'#fff'"
:size="90"
></avatar>
:border="false"
/>
</div>
<div class="justify-content-center mt-5 mb-5">
@ -44,14 +46,10 @@
</div>
</template>
<script>
import Avatar from 'vue-avatar'
import CONFIG from '@/config'
export default {
name: 'UserCard',
components: {
Avatar,
},
props: {
balance: { type: Number, default: 0 },
transactionCount: { type: Number, default: 0 },

View File

@ -1,6 +0,0 @@
<template>
<BButton>{{ $t('settings.GMS.location.button') }}</BButton>
</template>
<script setup>
import { BButton } from 'bootstrap-vue-next'
</script>

View File

@ -0,0 +1,91 @@
<template>
<BButton @click="isModalOpen = !isModalOpen">{{ $t('settings.GMS.location.button') }}</BButton>
<BModal :model-value="isModalOpen" fullscreen @update:modelValue="isModalOpen = !isModalOpen">
<template #title>
<h3>{{ $t('settings.GMS.map.headline') }}</h3>
</template>
<template #default>
<BContainer class="bg-white appBoxShadow gradido-border-radius p-4 mt--3">
<user-location-map
v-if="isModalOpen"
:user-marker-coords="userLocation"
:community-marker-coords="communityLocation"
@update:userPosition="updateUserLocation"
/>
</BContainer>
</template>
<template #footer>
<BButton variant="gradido" @click="saveUserLocation">
{{ $t('settings.GMS.location.saveLocation') }}
</BButton>
</template>
</BModal>
</template>
<script setup>
import { ref, computed } from 'vue'
import { useI18n } from 'vue-i18n'
import { useMutation, useQuery } from '@vue/apollo-composable'
import 'leaflet/dist/leaflet.css'
import { updateUserInfos } from '@/graphql/mutations'
import { useAppToast } from '@/composables/useToast'
import UserLocationMap from '@/components/UserSettings/UserLocationMap'
import { BButton, BModal } from 'bootstrap-vue-next'
import { userLocationQuery } from '@/graphql/queries'
import CONFIG from '@/config'
const { t } = useI18n()
const { mutate: updateUserInfo } = useMutation(updateUserInfos)
const { onResult, onError } = useQuery(userLocationQuery, {}, { fetchPolicy: 'network-only' })
const { toastSuccess, toastError } = useAppToast()
const capturedLocation = ref(null)
const isModalOpen = ref(false)
const userLocation = ref({ lat: 0, lng: 0 })
const communityLocation = ref({ lat: 0, lng: 0 })
const emit = defineEmits(['close'])
onResult(({ data }) => {
communityLocation.value.lng = data.userLocation.longitude
communityLocation.value.lat = data.userLocation.latitude
userLocation.value.lng = data?.userLocation?.longitude ?? communityLocation.value.lng
userLocation.value.lat = data?.userLocation?.latitude ?? communityLocation.value.lat
})
onError((err) => {
userLocation.value = defaultLocation.value
communityLocation.value = defaultLocation.value
toastError(err.message)
})
const defaultLocation = computed(() => {
const defaultCommunityCoords = CONFIG.COMMUNITY_LOCATION.split(',')
return {
lat: parseFloat(defaultCommunityCoords[0]),
lng: parseFloat(defaultCommunityCoords[1]),
}
})
const saveUserLocation = async () => {
try {
const loc = { longitude: capturedLocation.value.lng, latitude: capturedLocation.value.lat }
await updateUserInfo({
gmsLocation: {
longitude: capturedLocation.value.lng,
latitude: capturedLocation.value.lat,
},
})
toastSuccess(t('settings.GMS.location.updateSuccess'))
isModalOpen.value = false
} catch (error) {
toastError(error.message)
}
}
const updateUserLocation = (currentUserLocation) => {
capturedLocation.value = currentUserLocation
}
</script>

View File

@ -0,0 +1,218 @@
<template>
<div>
<coordinates-display
:community-position="communityPosition"
:user-position="userPosition"
@centerMap="handleMapCenter"
/>
<div ref="mapContainer" class="map-container" />
</div>
</template>
<script setup>
import { ref, onMounted, onUnmounted, watch, nextTick } from 'vue'
import L from 'leaflet'
import { GeoSearchControl, OpenStreetMapProvider } from 'leaflet-geosearch'
import 'leaflet-geosearch/dist/geosearch.css'
import CoordinatesDisplay from '@/components/UserSettings/CoordinatesDisplay.vue'
import { useI18n } from 'vue-i18n'
const mapContainer = ref(null)
const map = ref(null)
const userMarker = ref(null)
const communityMarker = ref(null)
const searchQuery = ref('')
const userPosition = ref({ lat: 0, lng: 0 })
const communityPosition = ref({ lat: 0, lng: 0 })
const defaultZoom = 13
const emit = defineEmits(['update:userPosition'])
const props = defineProps({
userMarkerCoords: Object,
communityMarkerCoords: Object,
})
const { t } = useI18n()
onMounted(async () => {
if (props.userMarkerCoords) {
userPosition.value = props.userMarkerCoords
}
if (props.communityMarkerCoords) {
communityPosition.value = props.communityMarkerCoords
}
await nextTick()
initMap()
window.addEventListener('resize', handleResize)
})
onUnmounted(() => {
if (map.value) {
map.value.remove()
}
window.removeEventListener('resize', handleResize)
})
function initMap() {
if (mapContainer.value && !map.value) {
map.value = L.map(mapContainer.value, {
center: [userPosition.value.lat, userPosition.value.lng],
zoom: defaultZoom,
zoomControl: false,
})
L.control.zoom({ position: 'topleft' }).addTo(map.value)
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
attribution: '© OpenStreetMap contributors',
maxZoom: 19,
}).addTo(map.value)
// User marker (movable)
userMarker.value = L.marker([userPosition.value.lat, userPosition.value.lng], {
draggable: true,
icon: L.icon({
iconUrl:
'https://raw.githubusercontent.com/pointhi/leaflet-color-markers/master/img/marker-icon-2x-blue.png',
shadowUrl: 'https://cdnjs.cloudflare.com/ajax/libs/leaflet/0.7.7/images/marker-shadow.png',
iconSize: [25, 41],
iconAnchor: [12, 41],
popupAnchor: [1, -34],
shadowSize: [41, 41],
}),
}).addTo(map.value)
userMarker.value.bindPopup(t('settings.GMS.map.userLocationLabel')).openPopup()
// Community marker (fixed)
communityMarker.value = L.marker([communityPosition.value.lat, communityPosition.value.lng], {
draggable: false,
icon: L.icon({
iconUrl:
'https://raw.githubusercontent.com/pointhi/leaflet-color-markers/master/img/marker-icon-2x-red.png',
shadowUrl: 'https://cdnjs.cloudflare.com/ajax/libs/leaflet/0.7.7/images/marker-shadow.png',
iconSize: [25, 41],
iconAnchor: [12, 41],
popupAnchor: [1, -34],
shadowSize: [41, 41],
}),
}).addTo(map.value)
communityMarker.value.bindPopup(t('settings.GMS.map.communityLocationLabel'))
map.value.on('click', onMapClick)
userMarker.value.on('dragend', onMarkerDragEnd)
// GeoSearch control
const provider = new OpenStreetMapProvider()
const searchControl = new GeoSearchControl({
provider: provider,
style: 'button',
showMarker: false,
showPopup: false,
autoClose: true,
retainZoomLevel: false,
animateZoom: true,
keepResult: false,
searchLabel: t('settings.GMS.map.search'),
})
map.value.addControl(searchControl)
map.value.on('geosearch/showlocation', (result) => {
const { x, y, label } = result.location
updateUserPosition({ lat: y, lng: x })
})
// Center map on user position
centerMapOnUser()
}
}
function handleResize() {
if (map.value) {
map.value.invalidateSize()
centerMapOnUser()
}
}
function onMapClick(e) {
updateUserPosition(e.latlng)
}
function onMarkerDragEnd() {
if (userMarker.value) {
updateUserPosition(userMarker.value.getLatLng())
}
}
function updateUserPosition(latlng) {
userPosition.value = { lat: latlng.lat, lng: latlng.lng }
if (userMarker.value) {
userMarker.value.setLatLng(latlng)
userMarker.value.openPopup()
}
centerMapOnUser()
emit('update:userPosition', userPosition.value)
}
function centerMapOnUser() {
if (map.value && userPosition.value) {
map.value.setView([userPosition.value.lat, userPosition.value.lng], map.value.getZoom(), {
animate: true,
pan: {
duration: 0.5,
},
})
}
}
function centerMapOnCommunity() {
if (map.value && communityPosition.value) {
map.value.setView(
[communityPosition.value.lat, communityPosition.value.lng],
map.value.getZoom(),
{
animate: true,
pan: {
duration: 0.5,
},
},
)
}
}
function handleMapCenter(centerMode) {
if (centerMode === 'USER') centerMapOnUser()
else centerMapOnCommunity()
}
watch(userPosition, (newPosition) => {
emit('update:userPosition', newPosition)
})
</script>
<style scoped>
.map-container {
height: 400px;
width: 100%;
}
.leaflet-control-custom a {
background-color: #fff;
width: 30px;
height: 30px;
line-height: 30px;
text-align: center;
text-decoration: none;
color: black;
}
.leaflet-control-custom a:hover {
background-color: #f4f4f4;
}
:deep(.leaflet-control-zoom > a) {
color: #555 !important;
}
</style>

View File

@ -79,7 +79,7 @@ describe('UserNamingFormat', () => {
await dropdownItems[3].trigger('click')
expect(mockUpdateUserData).toHaveBeenCalledWith({
variables: { gmsPublishName: 'PUBLISH_NAME_FIRST_INITIAL' },
gmsPublishName: 'PUBLISH_NAME_FIRST_INITIAL',
})
expect(mockToastSuccess).toHaveBeenCalledWith('success message')
expect(mockStore.commit).toHaveBeenCalledWith('gmsPublishName', 'PUBLISH_NAME_FIRST_INITIAL')

View File

@ -79,7 +79,7 @@ const update = async (option) => {
try {
const variables = {}
variables[props.attrName] = option.value
await updateUserData({ variables })
await updateUserData({ ...variables })
toastSuccess(props.successMessage)
selectedOption.value = option.value
store.commit(props.attrName, option.value)

View File

@ -4,8 +4,10 @@ import UserNewsletter from './UserNewsletter'
import { unsubscribeNewsletter, subscribeNewsletter } from '@/graphql/mutations'
import { createStore } from 'vuex'
import { createI18n } from 'vue-i18n'
import { BFormCheckbox } from 'bootstrap-vue-next'
import * as bootstrapVueNext from 'bootstrap-vue-next'
import { nextTick } from 'vue'
// Mock composables and dependencies
const mockToastError = vi.fn()
const mockToastSuccess = vi.fn()
@ -26,6 +28,7 @@ vi.mock('@vue/apollo-composable', () => ({
} else if (mutation === unsubscribeNewsletter) {
return { mutate: mockUnsubscribeMutate }
}
throw new Error(`Unrecognized mutation: ${mutation}`)
}),
}))
@ -34,7 +37,7 @@ describe('UserNewsletter', () => {
let store
let i18n
const createVuexStore = (initialState) =>
const createVuexStore = (initialState = {}) =>
createStore({
state: {
language: 'de',
@ -42,7 +45,7 @@ describe('UserNewsletter', () => {
...initialState,
},
mutations: {
setNewsletterState(state, value) {
newsletterState(state, value) {
state.newsletterState = value
},
},
@ -63,11 +66,14 @@ describe('UserNewsletter', () => {
const createWrapper = (storeState = {}) => {
store = createVuexStore(storeState)
i18n = createI18nInstance()
return mount(UserNewsletter, {
global: {
plugins: [store, i18n],
stubs: {
BFormCheckbox: true,
plugins: [store, i18n, bootstrapVueNext],
config: {
compilerOptions: {
isCustomElement: (tag) => tag.startsWith('b-'),
},
},
},
})
@ -82,8 +88,9 @@ describe('UserNewsletter', () => {
expect(wrapper.find('div.formusernewsletter').exists()).toBe(true)
})
it('has an edit BFormCheckbox switch', () => {
expect(wrapper.find('[test="BFormCheckbox"]').exists()).toBe(true)
it('has a BFormCheckbox switch', () => {
const checkbox = wrapper.findComponent({ name: 'BFormCheckbox' })
expect(checkbox.exists()).toBe(true)
})
describe('unsubscribe with success', () => {
@ -94,7 +101,12 @@ describe('UserNewsletter', () => {
unsubscribeNewsletter: true,
},
})
wrapper.vm.localNewsletterState = false
const checkbox = wrapper.findComponent({ name: 'BFormCheckbox' })
await checkbox.setValue(false)
await nextTick()
// Ensure all promises are resolved
await Promise.resolve()
})
it('calls the unsubscribe mutation', () => {
@ -118,7 +130,12 @@ describe('UserNewsletter', () => {
subscribeNewsletter: true,
},
})
wrapper.vm.localNewsletterState = true
const checkbox = wrapper.findComponent({ name: 'BFormCheckbox' })
await checkbox.setValue(true)
await nextTick()
// Ensure all promises are resolved
await Promise.resolve()
})
it('calls the subscribe mutation', () => {
@ -138,7 +155,12 @@ describe('UserNewsletter', () => {
beforeEach(async () => {
wrapper = createWrapper({ newsletterState: true })
mockUnsubscribeMutate.mockRejectedValue(new Error('Ouch'))
wrapper.vm.localNewsletterState = false
const checkbox = wrapper.findComponent({ name: 'BFormCheckbox' })
await checkbox.setValue(false)
await nextTick()
// Ensure all promises are resolved
await Promise.resolve()
})
it('resets the newsletterState', () => {

View File

@ -29,20 +29,16 @@ const { mutate: newsletterSubscribe } = useMutation(subscribeNewsletter)
const { mutate: newsletterUnsubscribe } = useMutation(unsubscribeNewsletter)
watch(localNewsletterState, async (newValue, oldValue) => {
if (newValue !== oldValue) {
if (newValue !== undefined && newValue !== null && newValue !== oldValue) {
await onSubmit()
}
})
const onSubmit = async () => {
try {
if (localNewsletterState.value) {
await newsletterSubscribe()
} else {
await newsletterUnsubscribe()
}
localNewsletterState.value ? await newsletterSubscribe() : await newsletterUnsubscribe()
store.commit('setNewsletterState', localNewsletterState.value)
store.commit('newsletterState', localNewsletterState.value)
toastSuccess(
localNewsletterState.value

View File

@ -2,18 +2,18 @@
<div class="skeleton-overview h-100">
<BRow class="text-center">
<BCol>
<b-skeleton-img no-aspect animation="wave" height="118px"></b-skeleton-img>
</BCol>
<BCol cols="6">
<b-skeleton animation="wave" class="mt-4 pt-5"></b-skeleton>
<skeleton-loader-element :height="118" />
</BCol>
<BCol cols="6" />
<BCol>
<div class="b-right m-4">
<BRow>
<BCol><b-skeleton type="avatar"></b-skeleton></BCol>
<BCol>
<b-skeleton></b-skeleton>
<b-skeleton></b-skeleton>
<skeleton-loader-element full-radius :width="61" :height="61" />
</BCol>
<BCol>
<skeleton-loader-element />
<skeleton-loader-element />
</BCol>
</BRow>
</div>
@ -21,28 +21,53 @@
</BRow>
<BRow class="text-center mt-5 pt-5">
<BCol cols="12" lg="">
<b-skeleton animation="wave" width="85%"></b-skeleton>
<b-skeleton animation="wave" width="55%"></b-skeleton>
<b-skeleton animation="wave" width="70%"></b-skeleton>
<skeleton-loader-element width="85%" :height="466" use-gradido-radius />
</BCol>
<BCol cols="12" lg="6">
<b-skeleton animation="wave" width="85%"></b-skeleton>
<b-skeleton animation="wave" width="55%"></b-skeleton>
<b-skeleton animation="wave" width="70%"></b-skeleton>
<b-skeleton animation="wave" width="85%"></b-skeleton>
<b-skeleton animation="wave" width="55%"></b-skeleton>
<b-skeleton animation="wave" width="70%"></b-skeleton>
<BRow class="d-flex justify-content-between">
<skeleton-loader-element width="45%" :height="105" use-gradido-radius />
<skeleton-loader-element width="45%" :height="105" use-gradido-radius />
</BRow>
<skeleton-loader-element :height="450" use-gradido-radius />
</BCol>
<BCol cols="12" lg="">
<b-skeleton animation="wave" width="85%"></b-skeleton>
<b-skeleton animation="wave" width="55%"></b-skeleton>
<b-skeleton animation="wave" width="70%"></b-skeleton>
<skeleton-loader-element width="85%" />
<BRow>
<BCol style="max-width: min-content">
<skeleton-loader-element full-radius :width="61" :height="61" />
</BCol>
<BCol>
<skeleton-loader-element />
<skeleton-loader-element />
</BCol>
</BRow>
<BRow>
<BCol style="max-width: min-content">
<skeleton-loader-element full-radius :width="61" :height="61" />
</BCol>
<BCol>
<skeleton-loader-element />
<skeleton-loader-element />
</BCol>
</BRow>
<BRow>
<BCol style="max-width: min-content">
<skeleton-loader-element full-radius :width="61" :height="61" />
</BCol>
<BCol>
<skeleton-loader-element />
<skeleton-loader-element />
</BCol>
</BRow>
</BCol>
</BRow>
</div>
</template>
<script>
import SkeletonLoaderElement from '@/components/SkeletonLoaderElement.vue'
export default {
name: 'SkeletonOverview',
components: { SkeletonLoaderElement },
}
</script>

View File

@ -22,6 +22,7 @@ export function useAppToast() {
toast(message, {
title: t('navigation.info'),
variant: 'warning',
bodyClass: 'gdd-toaster-body-darken',
})
}

View File

@ -50,6 +50,7 @@ const community = {
COMMUNITY_DESCRIPTION:
process.env.COMMUNITY_DESCRIPTION ?? 'Die lokale Entwicklungsumgebung von Gradido.',
COMMUNITY_SUPPORT_MAIL: process.env.COMMUNITY_SUPPORT_MAIL ?? 'support@supportmail.com',
COMMUNITY_LOCATION: process.env.COMMUNITY_LOCATION ?? '49.280377, 9.690151',
}
const meta = {

View File

@ -28,6 +28,15 @@ export const authenticateGmsUserSearch = gql`
}
`
export const userLocationQuery = gql`
query {
userLocation {
userLocation
communityLocation
}
}
`
export const authenticateHumhubAutoLogin = gql`
query {
authenticateHumhubAutoLogin

View File

@ -1,6 +1,6 @@
import { mount } from '@vue/test-utils'
import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest'
import { nextTick } from 'vue'
import { nextTick, ref } from 'vue'
import DashboardLayout from './DashboardLayout'
import { createStore } from 'vuex'
import { createRouter, createWebHistory } from 'vue-router'
@ -15,11 +15,15 @@ vi.mock('@/composables/useToast', () => ({
}))
const mockQueryFn = vi.fn()
const mockRefetchFn = vi.fn()
const mockMutateFn = vi.fn()
const mockQueryResult = ref(null)
vi.mock('@vue/apollo-composable', () => ({
useLazyQuery: vi.fn(() => ({
load: mockQueryFn,
refetch: mockRefetchFn,
result: mockQueryResult,
onResult: vi.fn(),
onError: vi.fn(),
})),
@ -136,25 +140,25 @@ describe('DashboardLayout', () => {
describe('update transactions', () => {
beforeEach(async () => {
mockQueryFn.mockResolvedValue({
mockQueryResult.value = {
transactionList: {
balance: {
balanceGDT: 100,
balanceGDT: '100',
count: 4,
linkCount: 8,
balance: 1450,
decay: 1250,
balance: '1450',
},
transactions: ['transaction', 'transaction', 'transaction', 'transaction'],
transactions: ['transaction1', 'transaction2', 'transaction3', 'transaction4'],
},
})
await wrapper
.findComponent({ ref: 'router-view' })
.vm.$emit('update-transactions', { currentPage: 2, pageSize: 5 })
await nextTick()
}
mockQueryFn.mockResolvedValue(mockQueryResult.value)
await wrapper.vm.updateTransactions({ currentPage: 2, pageSize: 5 })
await nextTick() // Ensure all promises are resolved
})
it('calls the API', () => {
it('load call to the API', () => {
expect(mockQueryFn).toHaveBeenCalled()
})
@ -164,10 +168,10 @@ describe('DashboardLayout', () => {
it('updates transactions', () => {
expect(wrapper.vm.transactions).toEqual([
'transaction',
'transaction',
'transaction',
'transaction',
'transaction1',
'transaction2',
'transaction3',
'transaction4',
])
})

View File

@ -181,7 +181,7 @@
<content-footer v-if="!$route.meta.hideFooter" />
</BCol>
</BRow>
<!-- <session-logout-timeout @logout="logoutUser" ref="sessionModal" />-->
<session-logout-timeout @logout="logoutUser" />
</div>
</div>
</template>
@ -215,7 +215,11 @@ import { useAppToast } from '@/composables/useToast'
const store = useStore()
const router = useRouter()
const { load: useCommunityStatsQuery } = useLazyQuery(communityStatistics)
const { load: useTransactionsQuery } = useLazyQuery(transactionsQuery)
const {
load: useTransactionsQuery,
refetch: useRefetchTransactionsQuery,
result: transactionQueryResult,
} = useLazyQuery(transactionsQuery, {}, { fetchPolicy: 'network-only' })
const { mutate: useLogoutMutation } = useMutation(logout)
const { t } = useI18n()
const { toastError } = useAppToast()
@ -232,14 +236,8 @@ const darkMode = ref(false)
const skeleton = ref(true)
const totalUsers = ref(null)
const sessionModal = ref(null)
const testModal = () => {
sessionModal.value.showTimeoutModalForTesting()
}
onMounted(() => {
updateTransactions({ currentPage: 0, pageSize: 10 })
updateTransactions({ currentPage: 1, pageSize: 10 })
getCommunityStatistics()
setTimeout(() => {
skeleton.value = false
@ -251,7 +249,7 @@ const logoutUser = async () => {
await useLogoutMutation()
await store.dispatch('logout')
await router.push('/login')
} catch {
} catch (err) {
await store.dispatch('logout')
if (router.currentRoute.value.path !== '/login') await router.push('/login')
}
@ -260,9 +258,9 @@ const logoutUser = async () => {
const updateTransactions = async ({ currentPage, pageSize }) => {
pending.value = true
try {
const result = await useTransactionsQuery()
if (!result) return // TODO this return mitigate an error when this method is called second time but without actual request
const { transactionList } = result
await loadOrFetchTransactionQuery({ currentPage, pageSize })
if (!transactionQueryResult) return
const { transactionList } = transactionQueryResult.value
GdtBalance.value =
transactionList.balance.balanceGDT === null ? 0 : Number(transactionList.balance.balanceGDT)
transactions.value = transactionList.transactions
@ -277,6 +275,13 @@ const updateTransactions = async ({ currentPage, pageSize }) => {
}
}
const loadOrFetchTransactionQuery = async (queryVariables = { currentPage: 1, pageSize: 25 }) => {
return (
(await useTransactionsQuery(transactionsQuery, queryVariables)) ||
(await useRefetchTransactionsQuery(queryVariables))
)
}
const getCommunityStatistics = async () => {
try {
const result = await useCommunityStatsQuery()
@ -298,6 +303,7 @@ const setVisible = (bool) => {
<style>
.breadcrumb {
background-color: transparent;
padding: 0.75rem 1rem;
}
.main-page {
@ -342,7 +348,7 @@ const setVisible = (bool) => {
@media screen and (width <= 450px) {
.breadcrumb {
padding-top: 60px;
padding-top: 55px !important;
}
}
</style>

View File

@ -248,7 +248,8 @@
},
"h": "h",
"language": "Sprache",
"link-load": "den letzten Link nachladen | die letzten {n} Links nachladen | weitere {n} Links nachladen",
"link-load": "den letzten Link nachladen | die letzten {n} Links nachladen",
"link-load-more": "weitere {n} Links nachladen",
"login": "Anmelden",
"math": {
"asterisk": "*",
@ -312,10 +313,20 @@
"disabled": "Daten werden nicht nach GMS exportiert",
"enabled": "Daten werden nach GMS exportiert",
"location": {
"button": "Klick mich!",
"label": "Positionsbestimmung",
"button": "Klick mich!"
"saveLocation": "Standort speichern",
"updateSuccess": "Standort erfolgreich gespeichert"
},
"location-format": "Position auf Karte anzeigen:",
"map": {
"communityCoords": "Ihr Gemeinschafts-Standort: Breitengrad {lat}, Längengrad {lng}",
"communityLocationLabel": "Ihr Gemeinschafts-Standort",
"headline": "Geografische Standorterfassung des Benutzers",
"userCoords": "Ihr Standort: Breitengrad {lat}, Längengrad {lng}",
"userLocationLabel": "Ihr Standort",
"search": "Nach einem Standort suchen"
},
"naming-format": "Namen anzeigen:",
"publish-location": {
"exact": "Genaue Position",

View File

@ -248,7 +248,8 @@
},
"h": "h",
"language": "Language",
"link-load": "Load the last link | Load the last {n} links | Load more {n} links",
"link-load": "Load the last link | Load the last {n} links",
"link-load-more": "Load more {n} links",
"login": "Sign in",
"math": {
"asterisk": "*",
@ -312,10 +313,20 @@
"disabled": "Data not exported to GMS",
"enabled": "Data exported to GMS",
"location": {
"label": "pinpoint location",
"button": "click me!"
"button": "Click me!",
"label": "Pinpoint location",
"saveLocation": "Save Location",
"updateSuccess": "Location successfully saved"
},
"location-format": "Show position on map:",
"map": {
"communityCoords": "Your Community Location: Lat {lat}, Lng {lng}",
"communityLocationLabel": "Your Community-Location",
"headline": "Geographic Location-Capturing of the User",
"userCoords": "Your Location: Lat {lat}, Lng {lng}",
"userLocationLabel": "Your Location",
"search": "Search for a location"
},
"naming-format": "Show Name:",
"publish-location": {
"exact": "exact position",

View File

@ -218,7 +218,8 @@
"recruited-member": "Miembro invitado"
},
"language": "Idioma",
"link-load": "recargar el último enlace |recargar los últimos {n} enlaces | descargar más {n} enlaces",
"link-load": "recargar el último enlace | recargar los últimos {n} enlaces",
"link-load-more": "descargar más {n} enlaces",
"login": "iniciar sesión",
"math": {
"aprox": "~",
@ -275,6 +276,23 @@
"warningText": "Aún estas?"
},
"settings": {
"GMS": {
"location": {
"button": "¡Haz clic aquí!",
"label": "Ubicación exacta",
"saveLocation": "Guardar ubicación",
"updateSuccess": "Ubicación guardada exitosamente"
},
"map": {
"communityCoords": "Ubicación de tu comunidad: Lat {lat}, Lng {lng}",
"communityLocationLabel": "Ubicación de tu comunidad",
"headline": "Captura de ubicación geográfica del usuario",
"userCoords": "Tu ubicación: Lat {lat}, Lng {lng}",
"userLocationLabel": "Tu ubicación",
"search": "Buscar una ubicación",
"success": "Ubicación guardada exitosamente"
}
},
"language": {
"changeLanguage": "Cambiar idioma",
"de": "Deutsch",

View File

@ -226,7 +226,8 @@
},
"h": "h",
"language": "Langage",
"link-load": "Enregistrer le dernier lien | Enregistrer les derniers {n} liens | Enregistrer plus de {n} liens",
"link-load": "Enregistrer le dernier lien | Enregistrer les derniers {n} liens",
"link-load-more": "Enregistrer plus de {n} liens",
"login": "Connexion",
"math": {
"asterisk": "*",
@ -283,6 +284,23 @@
"warningText": "Êtes-vous toujours connecté?"
},
"settings": {
"GMS": {
"location": {
"button": "Cliquez ici !",
"label": "Emplacement précis",
"saveLocation": "Enregistrer l'emplacement",
"updateSuccess": "Emplacement enregistré avec succès"
},
"map": {
"communityCoords": "Emplacement de votre communauté : Lat {lat}, Long {lng}",
"communityLocationLabel": "Emplacement de votre communauté",
"headline": "Capture de la localisation géographique de l'utilisateur",
"userCoords": "Votre emplacement : Lat {lat}, Long {lng}",
"userLocationLabel": "Votre emplacement",
"search": "Rechercher un emplacement",
"success": "Emplacement enregistré avec succès"
}
},
"hideAmountGDD": "Votre montant GDD est caché.",
"hideAmountGDT": "Votre montant GDT est caché.",
"language": {

View File

@ -218,7 +218,8 @@
"recruited-member": "Uitgenodigd lid"
},
"language": "Taal",
"link-load": "de laatste link herladen | de laatste links herladen | verdere {n} links herladen",
"link-load": "de laatste link herladen | de laatste links herladen",
"link-load-more": "verdere {n} links herladen",
"login": "Aanmelding",
"math": {
"aprox": "~",
@ -275,6 +276,23 @@
"warningText": "Ben je er nog?"
},
"settings": {
"GMS": {
"location": {
"button": "Klik hier!",
"label": "Exacte locatie",
"saveLocation": "Locatie opslaan",
"updateSuccess": "Locatie succesvol opgeslagen"
},
"map": {
"communityCoords": "Locatie van je gemeenschap: Lat {lat}, Lng {lng}",
"communityLocationLabel": "Locatie van je gemeenschap",
"headline": "Geografische locatiebepaling van de gebruiker",
"userCoords": "Jouw locatie: Lat {lat}, Lng {lng}",
"userLocationLabel": "Jouw locatie",
"search": "Zoek een locatie",
"success": "Locatie succesvol opgeslagen"
}
},
"language": {
"changeLanguage": "Taal veranderen",
"de": "Deutsch",

View File

@ -204,7 +204,8 @@
"recruited-member": "Davetli üye"
},
"language": "Dil",
"link-load": "Son linki yükle| Son {n} linki yükle | {n} link daha yükle",
"link-load": "Son linki yükle| Son {n} linki yükle",
"link-load-more": "{n} link daha yükle",
"login": "Giriş",
"math": {
"aprox": "~",
@ -245,6 +246,23 @@
"warningText": "Hala orada mısın?"
},
"settings": {
"GMS": {
"location": {
"button": "Buraya tıklayın!",
"label": "Tam konum",
"saveLocation": "Konumu Kaydet",
"updateSuccess": "Konum başarıyla kaydedildi"
},
"map": {
"communityCoords": "Topluluk Konumunuz: Enlem {lat}, Boylam {lng}",
"communityLocationLabel": "Topluluk Konumunuz",
"headline": "Kullanıcının Coğrafi Konum Tespiti",
"userCoords": "Konumunuz: Enlem {lat}, Boylam {lng}",
"userLocationLabel": "Konumunuz",
"search": "Konum ara",
"success": "Konum başarıyla kaydedildi"
}
},
"language": {
"changeLanguage": "Dili değiştir",
"de": "Deutsch",

View File

@ -76,7 +76,7 @@ describe('Circles', () => {
null,
expect.objectContaining({
fetchPolicy: 'network-only',
enabled: false,
enabled: true,
}),
)
expect(mockRefetch).toHaveBeenCalled()

View File

@ -51,7 +51,7 @@ const {
onError,
} = useQuery(authenticateHumhubAutoLogin, null, {
fetchPolicy: 'network-only',
enabled: false,
enabled: true,
})
onResult(({ data }) => {

View File

@ -19,10 +19,11 @@
</BCol>
</BRow>
<BRow>
<BCol cols="12" lg="6">
<BCol class="col-lg-6 col-12">
<BButton
ref="submitBtn"
type="submit"
class="w-100 fs-7"
:variant="meta.valid ? 'gradido' : 'gradido-disable'"
block
:disabled="!meta.valid"

View File

@ -81,100 +81,102 @@
</BCol>
</BRow>
</BTab>
<div v-if="isCommunityService">
<BTab class="community-service-tabs" :title="$t('settings.community')">
<div class="h2">{{ $t('settings.allow-community-services') }}</div>
<div v-if="isHumhub" class="mt-3">
<BRow>
<BTab
v-if="isCommunityService"
class="community-service-tabs"
:title="$t('settings.community')"
>
<div class="h2">{{ $t('settings.allow-community-services') }}</div>
<div v-if="isHumhub" class="mt-3">
<BRow>
<BCol cols="12" md="6" lg="6">
<div class="h3">{{ $t('Humhub.title') }}</div>
</BCol>
<BCol cols="12" md="6" lg="6" class="text-end">
<user-settings-switch
:initial-value="$store.state.humhubAllowed"
:attr-name="'humhubAllowed'"
:disabled="isHumhubActivated"
:enabled-text="$t('settings.humhub.enabled')"
:disabled-text="$t('settings.humhub.disabled')"
:not-allowed-text="$t('settings.humhub.delete-disabled')"
@value-changed="humhubStateSwitch"
/>
</BCol>
</BRow>
<div class="h4">{{ $t('Humhub.desc') }}</div>
<BRow v-if="humhubAllowed" class="mb-4 humhub-publish-name-row">
<BCol cols="12" md="6" lg="6">
{{ $t('settings.humhub.naming-format') }}
</BCol>
<BCol cols="12" md="6" lg="6">
<user-naming-format
:initial-value="$store.state.humhubPublishName"
:attr-name="'humhubPublishName'"
:success-message="$t('settings.humhub.publish-name.updated')"
/>
</BCol>
</BRow>
</div>
<div v-if="isGMS" class="mt-3">
<BRow>
<BCol cols="12" md="6" lg="6">
<div class="h3">{{ $t('GMS.title') }}</div>
</BCol>
<BCol cols="12" md="6" lg="6" class="text-end">
<user-settings-switch
:initial-value="$store.state.gmsAllowed"
:attr-name="'gmsAllowed'"
:enabled-text="$t('settings.GMS.enabled')"
:disabled-text="$t('settings.GMS.disabled')"
@value-changed="gmsStateSwitch"
/>
</BCol>
</BRow>
<div class="h4 mt-3">{{ $t('GMS.desc') }}</div>
<div v-if="gmsAllowed">
<BRow class="mb-4">
<BCol cols="12" md="6" lg="6">
<div class="h3">{{ $t('Humhub.title') }}</div>
</BCol>
<BCol cols="12" md="6" lg="6" class="text-end">
<user-settings-switch
:initial-value="$store.state.humhubAllowed"
:attr-name="'humhubAllowed'"
:disabled="isHumhubActivated"
:enabled-text="$t('settings.humhub.enabled')"
:disabled-text="$t('settings.humhub.disabled')"
:not-allowed-text="$t('settings.humhub.delete-disabled')"
@value-changed="humhubStateSwitch"
/>
</BCol>
</BRow>
<div class="h4">{{ $t('Humhub.desc') }}</div>
<BRow v-if="humhubAllowed" class="mb-4 humhub-publish-name-row">
<BCol cols="12" md="6" lg="6">
{{ $t('settings.humhub.naming-format') }}
{{ $t('settings.GMS.naming-format') }}
</BCol>
<BCol cols="12" md="6" lg="6">
<user-naming-format
:initial-value="$store.state.humhubPublishName"
:attr-name="'humhubPublishName'"
:success-message="$t('settings.humhub.publish-name.updated')"
:initial-value="$store.state.gmsPublishName"
:attr-name="'gmsPublishName'"
:success-message="$t('settings.GMS.publish-name.updated')"
/>
</BCol>
</BRow>
</div>
<div v-if="isGMS" class="mt-3">
<BRow>
<BRow class="mb-4">
<BCol cols="12" md="6" lg="6">
<div class="h3">{{ $t('GMS.title') }}</div>
{{ $t('settings.GMS.location-format') }}
</BCol>
<BCol cols="12" md="6" lg="6" class="text-end">
<user-settings-switch
:initial-value="$store.state.gmsAllowed"
:attr-name="'gmsAllowed'"
:enabled-text="$t('settings.GMS.enabled')"
:disabled-text="$t('settings.GMS.disabled')"
@value-changed="gmsStateSwitch"
/>
<BCol cols="12" md="6" lg="6">
<user-g-m-s-location-format />
</BCol>
</BRow>
<div class="h4 mt-3">{{ $t('GMS.desc') }}</div>
<div v-if="gmsAllowed">
<BRow class="mb-4">
<BCol cols="12" md="6" lg="6">
{{ $t('settings.GMS.naming-format') }}
</BCol>
<BCol cols="12" md="6" lg="6">
<user-naming-format
:initial-value="$store.state.gmsPublishName"
:attr-name="'gmsPublishName'"
:success-message="$t('settings.GMS.publish-name.updated')"
/>
</BCol>
</BRow>
<BRow class="mb-4">
<BCol cols="12" md="6" lg="6">
{{ $t('settings.GMS.location-format') }}
</BCol>
<BCol cols="12" md="6" lg="6">
<user-g-m-s-location-format />
</BCol>
</BRow>
<BRow class="mb-5">
<BCol cols="12" md="6" lg="6">
{{ $t('settings.GMS.location.label') }}
</BCol>
<BCol cols="12" md="6" lg="6">
<user-g-m-s-location />
</BCol>
</BRow>
</div>
</div>
<div v-else>
<BRow>
<BRow class="mb-5">
<BCol cols="12" md="6" lg="6">
<div class="h3 text-muted">{{ $t('GMS.title') }}</div>
{{ $t('settings.GMS.location.label') }}
</BCol>
<BCol cols="12" md="6" lg="6" class="text-end">
<user-settings-switch :disabled="true" />
<BCol cols="12" md="6" lg="6">
<user-gms-location-capturing />
</BCol>
</BRow>
<div class="h4 mt-3 text-muted">{{ $t('GMS.desc') }}</div>
</div>
</BTab>
</div>
</div>
<div v-else>
<BRow>
<BCol cols="12" md="6" lg="6">
<div class="h3 text-muted">{{ $t('GMS.title') }}</div>
</BCol>
<BCol cols="12" md="6" lg="6" class="text-end">
<user-settings-switch :disabled="true" />
</BCol>
</BRow>
<div class="h4 mt-3 text-muted">{{ $t('GMS.desc') }}</div>
</div>
</BTab>
</BTabs>
<!-- TODO<BRow>
@ -200,7 +202,7 @@ import UserPassword from '@/components/UserSettings/UserPassword'
import UserSettingsSwitch from '../components/UserSettings/UserSettingsSwitch.vue'
import UserNamingFormat from '@/components/UserSettings/UserNamingFormat'
import UserGMSLocationFormat from '@/components/UserSettings/UserGMSLocationFormat'
import UserGMSLocation from '@/components/UserSettings/UserGMSLocation'
import UserGmsLocationCapturing from '@/components/UserSettings/UserGmsLocationCapturing'
import UserNewsletter from '@/components/UserSettings/UserNewsletter.vue'
import { BTabs, BTab, BRow, BCol, BFormInput, BFormGroup, BForm, BButton } from 'bootstrap-vue-next'
@ -305,4 +307,12 @@ const humhubStateSwitch = (eventData) => {
:deep(.form-label) {
padding-bottom: 0;
}
:deep(.nav-link) {
color: #383838 !important;
}
:deep(.nav-link.active) {
color: #525f7f !important;
}
</style>

View File

@ -48,7 +48,6 @@ const transactionsGdt = ref([])
const transactionGdtCount = ref(0)
const currentPage = ref(1)
const pageSize = ref(25)
// const tabIndex = ref(0)
const { toastError } = useAppToast()

View File

@ -23,6 +23,8 @@ const routes = [
// communityIdentifier can be community name or community UUID
path: '/send/:communityIdentifier?/:userIdentifier?',
component: () => import('@/pages/Send'),
name: 'Send',
props: true,
meta: {
requiresAuth: true,
pageTitle: 'send',
@ -36,6 +38,7 @@ const routes = [
// },
// },
{
name: 'Transactions',
path: '/transactions',
component: () => import('@/pages/Transactions'),
props: { gdt: false },

View File

@ -75,6 +75,9 @@ export const mutations = {
redirectPath: (state, redirectPath) => {
state.redirectPath = redirectPath || '/overview'
},
setTransactionToHighlightId: (state, id) => {
state.transactionToHighlightId = id
},
}
export const actions = {
@ -119,6 +122,9 @@ export const actions = {
commit('redirectPath', '/overview')
localStorage.clear()
},
changeTransactionToHighlightId({ commit }, id) {
commit('setTransactionToHighlightId', id)
},
}
let store
@ -153,6 +159,7 @@ try {
email: '',
darkMode: false,
redirectPath: '/overview',
transactionToHighlightId: '',
},
getters: {},
// Synchronous mutation of the state

View File

@ -19,13 +19,13 @@ export default defineConfig({
},
resolve: {
alias: {
vue: '@vue/compat',
'@': path.resolve(__dirname, './src'),
assets: path.join(__dirname, 'src/assets'),
},
extensions: ['.mjs', '.js', '.ts', '.jsx', '.tsx', '.json', '.vue'],
},
plugins: [
vue(),
createHtmlPlugin({
minify: true,
inject: {
@ -41,15 +41,6 @@ export default defineConfig({
},
},
}),
vue({
template: {
compilerOptions: {
compatConfig: {
MODE: 2,
},
},
},
}),
Components({
resolvers: [BootstrapVueNextResolver(), IconsResolve()],
dts: true,

View File

@ -22,8 +22,10 @@ export default defineConfig({
transformMode: {
web: [/\.[jt]sx$/],
},
deps: {
inline: [/vee-validate/, 'vitest-canvas-mock'],
server: {
deps: {
inline: [/vee-validate/, 'vitest-canvas-mock'],
},
},
alias: {
'@': path.resolve(__dirname, './src'),

View File

@ -1278,6 +1278,11 @@
"@floating-ui/utils" "^0.2.7"
vue-demi ">=0.13.0"
"@googlemaps/js-api-loader@^1.16.6":
version "1.16.8"
resolved "https://registry.yarnpkg.com/@googlemaps/js-api-loader/-/js-api-loader-1.16.8.tgz#1595a2af80ca07e551fc961d921a2437d1cb3643"
integrity sha512-CROqqwfKotdO6EBjZO/gQGVTbeDps5V7Mt9+8+5Q+jTg5CRMi3Ii/L9PmV3USROrt2uWxtGzJHORmByxyo9pSQ==
"@graphql-typed-document-node/core@^3.1.1":
version "3.2.0"
resolved "https://registry.yarnpkg.com/@graphql-typed-document-node/core/-/core-3.2.0.tgz#5f3d96ec6b2354ad6d8a28bf216a1d97b5426861"
@ -1641,6 +1646,11 @@
resolved "https://registry.yarnpkg.com/@types/estree/-/estree-1.0.5.tgz#a6ce3e556e00fd9895dd872dd172ad0d4bd687f4"
integrity sha512-/kYRxGDLWzHOB7q+wtSUQlFrtcdUccpfy+X+9iMBpHK8QLLhx2wIPYuS5DYtR9Wa/YlZAbIovy7qVdB1Aq6Lyw==
"@types/geojson@*":
version "7946.0.14"
resolved "https://registry.yarnpkg.com/@types/geojson/-/geojson-7946.0.14.tgz#319b63ad6df705ee2a65a73ef042c8271e696613"
integrity sha512-WCfD5Ht3ZesJUsONdhvm84dmzWOiOzOAqOncN0++w0lBw1o8OuDNJF2McvvCef/yBqb/HYRahp1BYtODFQ8bRg==
"@types/json-schema@^7.0.5":
version "7.0.15"
resolved "https://registry.yarnpkg.com/@types/json-schema/-/json-schema-7.0.15.tgz#596a1747233694d50f6ad8a7869fcb6f56cf5841"
@ -1651,6 +1661,13 @@
resolved "https://registry.yarnpkg.com/@types/json5/-/json5-0.0.29.tgz#ee28707ae94e11d2b827bcbe5270bcea7f3e71ee"
integrity sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ==
"@types/leaflet@^1.9.12":
version "1.9.12"
resolved "https://registry.yarnpkg.com/@types/leaflet/-/leaflet-1.9.12.tgz#a6626a0b3fba36fd34723d6e95b22e8024781ad6"
integrity sha512-BK7XS+NyRI291HIo0HCfE18Lp8oA30H1gpi1tf0mF3TgiCEzanQjOqNZ4x126SXzzi2oNSZhZ5axJp1k0iM6jg==
dependencies:
"@types/geojson" "*"
"@types/node@>=6":
version "22.1.0"
resolved "https://registry.yarnpkg.com/@types/node/-/node-22.1.0.tgz#6d6adc648b5e03f0e83c78dc788c2b037d0ad94b"
@ -1739,10 +1756,10 @@
vee-validate "4.13.2"
yup "^1.3.2"
"@vitejs/plugin-vue@3.2.0":
version "3.2.0"
resolved "https://registry.yarnpkg.com/@vitejs/plugin-vue/-/plugin-vue-3.2.0.tgz#a1484089dd85d6528f435743f84cdd0d215bbb54"
integrity sha512-E0tnaL4fr+qkdCNxJ+Xd0yM31UwMkQje76fsDVBBUCoGOUPexu2VDUYHL8P4CwV+zMvWw6nlRw19OnRKmYAJpw==
"@vitejs/plugin-vue@5.1.4":
version "5.1.4"
resolved "https://registry.yarnpkg.com/@vitejs/plugin-vue/-/plugin-vue-5.1.4.tgz#72b8b705cfce36b00b59af196195146e356500c4"
integrity sha512-N2XSI2n3sQqp5w7Y/AN/L2XDjBIRGqXko+eDp42sydYSBeJuSm5a1sLf8zakmo8u7tA8NmBgoDLA1HeOESjp9A==
"@vitest/coverage-v8@^2.0.5":
version "2.0.5"
@ -1813,6 +1830,13 @@
loupe "^3.1.1"
tinyrainbow "^1.2.0"
"@vue-leaflet/vue-leaflet@^0.10.1":
version "0.10.1"
resolved "https://registry.yarnpkg.com/@vue-leaflet/vue-leaflet/-/vue-leaflet-0.10.1.tgz#17330515629d500ac113009a49f8cf98d140e8cc"
integrity sha512-RNEDk8TbnwrJl8ujdbKgZRFygLCxd0aBcWLQ05q/pGv4+d0jamE3KXQgQBqGAteE1mbQsk3xoNcqqUgaIGfWVg==
dependencies:
vue "^3.2.25"
"@vue/apollo-composable@^4.0.2":
version "4.0.2"
resolved "https://registry.yarnpkg.com/@vue/apollo-composable/-/apollo-composable-4.0.2.tgz#ea3c001d25b3bf659aad5ae7a849fed3928aaa35"
@ -1829,15 +1853,6 @@
dependencies:
throttle-debounce "^5.0.0"
"@vue/compat@^3.4.31":
version "3.4.36"
resolved "https://registry.yarnpkg.com/@vue/compat/-/compat-3.4.36.tgz#9af3b95adf614354f2595548f162e7af4891aba9"
integrity sha512-41qHnPjRAjhNGCR77BQeaNKYNyZNqtZDNgg/3LQ3zIfOA8+cqFFPzZ/Ym1K4Viin0RS7bN4C8/fQFaRrvy7lnA==
dependencies:
"@babel/parser" "^7.24.7"
estree-walker "^2.0.2"
source-map-js "^1.2.0"
"@vue/compiler-core@3.4.31":
version "3.4.31"
resolved "https://registry.yarnpkg.com/@vue/compiler-core/-/compiler-core-3.4.31.tgz#b51a76f1b30e9b5eba0553264dff0f171aedb7c6"
@ -1849,14 +1864,14 @@
estree-walker "^2.0.2"
source-map-js "^1.2.0"
"@vue/compiler-core@3.4.36":
version "3.4.36"
resolved "https://registry.yarnpkg.com/@vue/compiler-core/-/compiler-core-3.4.36.tgz#4e28dfcbaa8a85e135f7a94c44372b6d52329e42"
integrity sha512-qBkndgpwFKdupmOPoiS10i7oFdN7a+4UNDlezD0GlQ1kuA1pNrscg9g12HnB5E8hrWSuEftRsbJhL1HI2zpJhg==
"@vue/compiler-core@3.5.12":
version "3.5.12"
resolved "https://registry.yarnpkg.com/@vue/compiler-core/-/compiler-core-3.5.12.tgz#bd70b7dabd12b0b6f31bc53418ba3da77994c437"
integrity sha512-ISyBTRMmMYagUxhcpyEH0hpXRd/KqDU4ymofPgl2XAkY9ZhQ+h0ovEZJIiPop13UmR/54oA2cgMDjgroRelaEw==
dependencies:
"@babel/parser" "^7.24.7"
"@vue/shared" "3.4.36"
entities "^5.0.0"
"@babel/parser" "^7.25.3"
"@vue/shared" "3.5.12"
entities "^4.5.0"
estree-walker "^2.0.2"
source-map-js "^1.2.0"
@ -1868,13 +1883,13 @@
"@vue/compiler-core" "3.4.31"
"@vue/shared" "3.4.31"
"@vue/compiler-dom@3.4.36":
version "3.4.36"
resolved "https://registry.yarnpkg.com/@vue/compiler-dom/-/compiler-dom-3.4.36.tgz#32f5f65d1fb242211df2ddc65a336779cd8b974c"
integrity sha512-eEIjy4GwwZTFon/Y+WO8tRRNGqylaRlA79T1RLhUpkOzJ7EtZkkb8MurNfkqY6x6Qiu0R7ESspEF7GkPR/4yYg==
"@vue/compiler-dom@3.5.12":
version "3.5.12"
resolved "https://registry.yarnpkg.com/@vue/compiler-dom/-/compiler-dom-3.5.12.tgz#456d631d11102535b7ee6fd954cf2c93158d0354"
integrity sha512-9G6PbJ03uwxLHKQ3P42cMTi85lDRvGLB2rSGOiQqtXELat6uI4n8cNz9yjfVHRPIu+MsK6TE418Giruvgptckg==
dependencies:
"@vue/compiler-core" "3.4.36"
"@vue/shared" "3.4.36"
"@vue/compiler-core" "3.5.12"
"@vue/shared" "3.5.12"
"@vue/compiler-sfc@3.4.31":
version "3.4.31"
@ -1891,19 +1906,19 @@
postcss "^8.4.38"
source-map-js "^1.2.0"
"@vue/compiler-sfc@^3.4.35":
version "3.4.36"
resolved "https://registry.yarnpkg.com/@vue/compiler-sfc/-/compiler-sfc-3.4.36.tgz#887809183a273dc0ef8337d5e84ef6a781727ccc"
integrity sha512-rhuHu7qztt/rNH90dXPTzhB7hLQT2OC4s4GrPVqmzVgPY4XBlfWmcWzn4bIPEWNImt0CjO7kfHAf/1UXOtx3vw==
"@vue/compiler-sfc@3.5.12":
version "3.5.12"
resolved "https://registry.yarnpkg.com/@vue/compiler-sfc/-/compiler-sfc-3.5.12.tgz#6688120d905fcf22f7e44d3cb90f8dabc4dd3cc8"
integrity sha512-2k973OGo2JuAa5+ZlekuQJtitI5CgLMOwgl94BzMCsKZCX/xiqzJYzapl4opFogKHqwJk34vfsaKpfEhd1k5nw==
dependencies:
"@babel/parser" "^7.24.7"
"@vue/compiler-core" "3.4.36"
"@vue/compiler-dom" "3.4.36"
"@vue/compiler-ssr" "3.4.36"
"@vue/shared" "3.4.36"
"@babel/parser" "^7.25.3"
"@vue/compiler-core" "3.5.12"
"@vue/compiler-dom" "3.5.12"
"@vue/compiler-ssr" "3.5.12"
"@vue/shared" "3.5.12"
estree-walker "^2.0.2"
magic-string "^0.30.10"
postcss "^8.4.40"
magic-string "^0.30.11"
postcss "^8.4.47"
source-map-js "^1.2.0"
"@vue/compiler-ssr@3.4.31":
@ -1914,13 +1929,13 @@
"@vue/compiler-dom" "3.4.31"
"@vue/shared" "3.4.31"
"@vue/compiler-ssr@3.4.36":
version "3.4.36"
resolved "https://registry.yarnpkg.com/@vue/compiler-ssr/-/compiler-ssr-3.4.36.tgz#5881f9303ad6a4fdf04fb4238ebb483caf040707"
integrity sha512-Wt1zyheF0zVvRJyhY74uxQbnkXV2Le/JPOrAxooR4rFYKC7cFr+cRqW6RU3cM/bsTy7sdZ83IDuy/gLPSfPGng==
"@vue/compiler-ssr@3.5.12":
version "3.5.12"
resolved "https://registry.yarnpkg.com/@vue/compiler-ssr/-/compiler-ssr-3.5.12.tgz#5f1a3fbd5c44b79a6dbe88729f7801d9c9218bde"
integrity sha512-eLwc7v6bfGBSM7wZOGPmRavSWzNFF6+PdRhE+VFJhNCgHiF8AM7ccoqcv5kBXA2eWUfigD7byekvf/JsOfKvPA==
dependencies:
"@vue/compiler-dom" "3.4.36"
"@vue/shared" "3.4.36"
"@vue/compiler-dom" "3.5.12"
"@vue/shared" "3.5.12"
"@vue/devtools-api@^6.0.0-beta.11", "@vue/devtools-api@^6.5.0", "@vue/devtools-api@^6.6.1", "@vue/devtools-api@^6.6.3":
version "6.6.3"
@ -1943,6 +1958,13 @@
dependencies:
"@vue/shared" "3.4.31"
"@vue/reactivity@3.5.12":
version "3.5.12"
resolved "https://registry.yarnpkg.com/@vue/reactivity/-/reactivity-3.5.12.tgz#a2815d91842ed7b9e7e7936c851923caf6b6e603"
integrity sha512-UzaN3Da7xnJXdz4Okb/BGbAaomRHc3RdoWqTzlvd9+WBR5m3J39J1fGcHes7U3za0ruYn/iYy/a1euhMEHvTAg==
dependencies:
"@vue/shared" "3.5.12"
"@vue/runtime-core@3.4.31":
version "3.4.31"
resolved "https://registry.yarnpkg.com/@vue/runtime-core/-/runtime-core-3.4.31.tgz#ad3a41ad76385c0429e3e4dbefb81918494e10cf"
@ -1951,6 +1973,14 @@
"@vue/reactivity" "3.4.31"
"@vue/shared" "3.4.31"
"@vue/runtime-core@3.5.12":
version "3.5.12"
resolved "https://registry.yarnpkg.com/@vue/runtime-core/-/runtime-core-3.5.12.tgz#849207f203d0fd82971f19574d30dbe7134c78c7"
integrity sha512-hrMUYV6tpocr3TL3Ad8DqxOdpDe4zuQY4HPY3X/VRh+L2myQO8MFXPAMarIOSGNu0bFAjh1yBkMPXZBqCk62Uw==
dependencies:
"@vue/reactivity" "3.5.12"
"@vue/shared" "3.5.12"
"@vue/runtime-dom@3.4.31":
version "3.4.31"
resolved "https://registry.yarnpkg.com/@vue/runtime-dom/-/runtime-dom-3.4.31.tgz#bae7ad844f944af33699c73581bc36125bab96ce"
@ -1961,6 +1991,16 @@
"@vue/shared" "3.4.31"
csstype "^3.1.3"
"@vue/runtime-dom@3.5.12":
version "3.5.12"
resolved "https://registry.yarnpkg.com/@vue/runtime-dom/-/runtime-dom-3.5.12.tgz#6d4de3df49a90a460b311b1100baa5e2d0d1c8c9"
integrity sha512-q8VFxR9A2MRfBr6/55Q3umyoN7ya836FzRXajPB6/Vvuv0zOPL+qltd9rIMzG/DbRLAIlREmnLsplEF/kotXKA==
dependencies:
"@vue/reactivity" "3.5.12"
"@vue/runtime-core" "3.5.12"
"@vue/shared" "3.5.12"
csstype "^3.1.3"
"@vue/server-renderer@3.4.31":
version "3.4.31"
resolved "https://registry.yarnpkg.com/@vue/server-renderer/-/server-renderer-3.4.31.tgz#bbe990f793c36d62d05bdbbaf142511d53e159fd"
@ -1969,15 +2009,23 @@
"@vue/compiler-ssr" "3.4.31"
"@vue/shared" "3.4.31"
"@vue/server-renderer@3.5.12":
version "3.5.12"
resolved "https://registry.yarnpkg.com/@vue/server-renderer/-/server-renderer-3.5.12.tgz#79c6bc3860e4e4ef80d85653c5d03fd94b26574e"
integrity sha512-I3QoeDDeEPZm8yR28JtY+rk880Oqmj43hreIBVTicisFTx/Dl7JpG72g/X7YF8hnQD3IFhkky5i2bPonwrTVPg==
dependencies:
"@vue/compiler-ssr" "3.5.12"
"@vue/shared" "3.5.12"
"@vue/shared@3.4.31":
version "3.4.31"
resolved "https://registry.yarnpkg.com/@vue/shared/-/shared-3.4.31.tgz#af9981f57def2c3f080c14bf219314fc0dc808a0"
integrity sha512-Yp3wtJk//8cO4NItOPpi3QkLExAr/aLBGZMmTtW9WpdwBCJpRM6zj9WgWktXAl8IDIozwNMByT45JP3tO3ACWA==
"@vue/shared@3.4.36":
version "3.4.36"
resolved "https://registry.yarnpkg.com/@vue/shared/-/shared-3.4.36.tgz#7551f41684966acb6a307152b49a8308e7f69203"
integrity sha512-fdPLStwl1sDfYuUftBaUVn2pIrVFDASYerZSrlBvVBfylObPA1gtcWJHy5Ox8jLEJ524zBibss488Q3SZtU1uA==
"@vue/shared@3.5.12":
version "3.5.12"
resolved "https://registry.yarnpkg.com/@vue/shared/-/shared-3.5.12.tgz#f9e45b7f63f2c3f40d84237b1194b7f67de192e3"
integrity sha512-L2RPSAwUFbgZH20etwrXyVyCBu9OxRSi8T/38QsvnkJyvq2LufW2lDCOzm7t/U9C1mkhJGWYfCuFBCmIuNivrg==
"@vue/test-utils@^2.4.5":
version "2.4.6"
@ -3208,11 +3256,6 @@ entities@^4.2.0, entities@^4.4.0, entities@^4.5.0:
resolved "https://registry.yarnpkg.com/entities/-/entities-4.5.0.tgz#5d268ea5e7113ec74c4d033b79ea5a35a488fb48"
integrity sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==
entities@^5.0.0:
version "5.0.0"
resolved "https://registry.yarnpkg.com/entities/-/entities-5.0.0.tgz#b2ab51fe40d995817979ec79dd621154c3c0f62b"
integrity sha512-BeJFvFRJddxobhvEdm5GqHzRV/X+ACeuw0/BuuxsCh1EUZcAIz8+kYmBp/LrQuloy6K1f3a0M7+IhmZ7QnkISA==
env-paths@^2.2.1:
version "2.2.1"
resolved "https://registry.yarnpkg.com/env-paths/-/env-paths-2.2.1.tgz#420399d416ce1fbe9bc0a07c62fa68d67fd0f8f2"
@ -4962,6 +5005,19 @@ kolorist@^1.8.0:
resolved "https://registry.yarnpkg.com/kolorist/-/kolorist-1.8.0.tgz#edddbbbc7894bc13302cdf740af6374d4a04743c"
integrity sha512-Y+60/zizpJ3HRH8DCss+q95yr6145JXZo46OTpFvDZWLfRCE4qChOyk1b26nMaNpfHHgxagk9dXT5OP0Tfe+dQ==
leaflet-geosearch@^4.0.0:
version "4.0.0"
resolved "https://registry.yarnpkg.com/leaflet-geosearch/-/leaflet-geosearch-4.0.0.tgz#d7488830004515452368d333f7a49d06d59ea81b"
integrity sha512-a92VNY9gxyv3oyEDqIWoCNoBllajWRYejztzOSNmpLRtzpA6JtGgy/wwl9tsB8+6Eek1fe+L6+W0MDEOaidbXA==
optionalDependencies:
"@googlemaps/js-api-loader" "^1.16.6"
leaflet "^1.6.0"
leaflet@^1.6.0, leaflet@^1.9.4:
version "1.9.4"
resolved "https://registry.yarnpkg.com/leaflet/-/leaflet-1.9.4.tgz#23fae724e282fa25745aff82ca4d394748db7d8d"
integrity sha512-nxS1ynzJOmOlHp+iL3FyWqK89GtNL8U8rvlMOsQdTTssxZwCXh8N2NB3GDQOL+YR3XnWyZAxwQixURb+FA74PA==
levn@^0.4.1:
version "0.4.1"
resolved "https://registry.yarnpkg.com/levn/-/levn-0.4.1.tgz#ae4562c007473b932a6200d403268dd2fffc6ade"
@ -5700,6 +5756,11 @@ picocolors@^1.0.0, picocolors@^1.0.1:
resolved "https://registry.yarnpkg.com/picocolors/-/picocolors-1.0.1.tgz#a8ad579b571952f0e5d25892de5445bcfe25aaa1"
integrity sha512-anP1Z8qwhkbmu7MFP5iTt+wQKXgwzf7zTyGlcdzabySa9vd0Xt392U0rVmz9poOaBj0uHJKyyo9/upk0HrEQew==
picocolors@^1.1.0:
version "1.1.0"
resolved "https://registry.yarnpkg.com/picocolors/-/picocolors-1.1.0.tgz#5358b76a78cde483ba5cef6a9dc9671440b27d59"
integrity sha512-TQ92mBOW0l3LeMeyLV6mzy/kWr8lkd/hp3mTg7wYK7zJhuBStmGMBG0BdeDZS/dZx1IukaX6Bk11zcln25o1Aw==
picomatch@^2.0.4, picomatch@^2.2.1, picomatch@^2.2.2, picomatch@^2.3.1:
version "2.3.1"
resolved "https://registry.yarnpkg.com/picomatch/-/picomatch-2.3.1.tgz#3ba3833733646d9d3e4995946c1365a67fb07a42"
@ -5805,7 +5866,7 @@ postcss-value-parser@^4.2.0:
resolved "https://registry.yarnpkg.com/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz#723c09920836ba6d3e5af019f92bc0971c02e514"
integrity sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==
postcss@^8.4.0, postcss@^8.4.18, postcss@^8.4.38, postcss@^8.4.39, postcss@^8.4.40, postcss@^8.4.8:
postcss@^8.4.0, postcss@^8.4.18, postcss@^8.4.38, postcss@^8.4.39, postcss@^8.4.8:
version "8.4.41"
resolved "https://registry.yarnpkg.com/postcss/-/postcss-8.4.41.tgz#d6104d3ba272d882fe18fc07d15dc2da62fa2681"
integrity sha512-TesUflQ0WKZqAvg52PWL6kHgLKP6xB6heTOdoYM0Wt2UHyxNa4K25EZZMgKns3BH1RLVbZCREPpLY0rhnNoHVQ==
@ -5823,6 +5884,15 @@ postcss@^8.4.43:
picocolors "^1.0.1"
source-map-js "^1.2.0"
postcss@^8.4.47:
version "8.4.47"
resolved "https://registry.yarnpkg.com/postcss/-/postcss-8.4.47.tgz#5bf6c9a010f3e724c503bf03ef7947dcb0fea365"
integrity sha512-56rxCq7G/XfB4EkXq9Egn5GCqugWvDFjafDOThIdMBsI15iqPqR5r15TfSr1YPYeEI19YeaXMCbY6u88Y76GLQ==
dependencies:
nanoid "^3.3.7"
picocolors "^1.1.0"
source-map-js "^1.2.1"
prelude-ls@^1.2.1:
version "1.2.1"
resolved "https://registry.yarnpkg.com/prelude-ls/-/prelude-ls-1.2.1.tgz#debc6489d7a6e6b0e7611888cec880337d316396"
@ -6333,6 +6403,11 @@ source-map-js@^1.0.1, source-map-js@^1.0.2, source-map-js@^1.2.0:
resolved "https://registry.yarnpkg.com/source-map-js/-/source-map-js-1.2.0.tgz#16b809c162517b5b8c3e7dcd315a2a5c2612b2af"
integrity sha512-itJW8lvSA0TXEphiRoawsCksnlf8SyvmFzIhltqAHluXd88pkCd+cXJVHTDwdCr0IzwptSm035IHQktUu1QUMg==
source-map-js@^1.2.1:
version "1.2.1"
resolved "https://registry.yarnpkg.com/source-map-js/-/source-map-js-1.2.1.tgz#1ce5650fddd87abc099eda37dcff024c2667ae46"
integrity sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==
source-map-support@^0.5.16, source-map-support@~0.5.20:
version "0.5.21"
resolved "https://registry.yarnpkg.com/source-map-support/-/source-map-support-0.5.21.tgz#04fe7c7f9e1ed2d662233c28cb2b35b9f63f6e4f"
@ -6412,7 +6487,14 @@ string.prototype.trimstart@^1.0.8:
define-properties "^1.2.1"
es-object-atoms "^1.0.0"
"strip-ansi-cjs@npm:strip-ansi@^6.0.1", strip-ansi@6.0.1, strip-ansi@^6.0.0, strip-ansi@^6.0.1, strip-ansi@^7.0.1, strip-ansi@^7.1.0:
"strip-ansi-cjs@npm:strip-ansi@^6.0.1":
version "6.0.1"
resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9"
integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==
dependencies:
ansi-regex "^5.0.1"
strip-ansi@6.0.1, strip-ansi@^6.0.0, strip-ansi@^6.0.1, strip-ansi@^7.0.1, strip-ansi@^7.1.0:
version "6.0.1"
resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9"
integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==
@ -6772,6 +6854,11 @@ tslib@^2.1.0, tslib@^2.3.0, tslib@^2.6.2:
resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.6.3.tgz#0438f810ad7a9edcde7a241c3d80db693c8cbfe0"
integrity sha512-xNvxJEOUiWPGhUuUdQgAJPKOOJfGnIyKySOc09XkKsgdUV/3E2zvwZYdejjmRgPCgcym1juLH3226yA7sEFJKQ==
tua-body-scroll-lock@^1.5.1:
version "1.5.1"
resolved "https://registry.yarnpkg.com/tua-body-scroll-lock/-/tua-body-scroll-lock-1.5.1.tgz#1b8b7316dff55a821d5bec3fef045f995e7627a5"
integrity sha512-AOjusG9EjTGxqqL1xqg6JeMauJ+IQoX9ITW1qP7UugySUdH6lzi2CqJRmU+oYqOv7vCQjOs5CQrjIakGlbOenQ==
type-check@^0.4.0, type-check@~0.4.0:
version "0.4.0"
resolved "https://registry.yarnpkg.com/type-check/-/type-check-0.4.0.tgz#07b8203bfa7056c0657050e3ccd2c37730bab8f1"
@ -7194,11 +7281,6 @@ vue-apollo@^3.1.2:
serialize-javascript "^4.0.0"
throttle-debounce "^2.1.0"
vue-avatar@^2.3.3:
version "2.3.3"
resolved "https://registry.yarnpkg.com/vue-avatar/-/vue-avatar-2.3.3.tgz#e125bf4f4a6f4f9480da0c522020266a8609d2a8"
integrity sha512-Z57ILRTkFIAuCH9JiFBxX74C5zua5ub/jRDM/KZ+QKXNfscvmUOgWBs3kA2+wrpZMowIvfLHIT0gvQu1z+zpLg==
vue-component-type-helpers@^2.0.0:
version "2.1.6"
resolved "https://registry.yarnpkg.com/vue-component-type-helpers/-/vue-component-type-helpers-2.1.6.tgz#f350515b252ed9e76960ac51f135636f8baef3fe"
@ -7238,11 +7320,6 @@ vue-i18n@^9.13.1:
"@intlify/shared" "9.13.1"
"@vue/devtools-api" "^6.5.0"
vue-loading-overlay@^3.4.2:
version "3.4.3"
resolved "https://registry.yarnpkg.com/vue-loading-overlay/-/vue-loading-overlay-3.4.3.tgz#ea14a0cb0d94ac5c91e45f90c21da6233b3cf1b5"
integrity sha512-Q4+RNnI6+szylJ98Abnp9CUDagKphZMt7okznGu1m7tidZX5b9u+a+De6uktWa5WULu/as+IsrWVR8lpmbDDOA==
vue-router@^4.4.0:
version "4.4.3"
resolved "https://registry.yarnpkg.com/vue-router/-/vue-router-4.4.3.tgz#58a39dc804632bfb6d26f052aa8f6718bd130299"
@ -7255,11 +7332,6 @@ vue-timer-hook@^1.0.84:
resolved "https://registry.yarnpkg.com/vue-timer-hook/-/vue-timer-hook-1.0.84.tgz#96acd9a74297bd003ffb6983a5c660bc8260cee4"
integrity sha512-OcHWYO8WD/XcGHdEqMkBI1Vj8m+OnXONrcmZvLcL3Gc6Js5LEs3UJ0eg1akVjNB0cXHishEmrmrhK7PZ+iULww==
vue-timers@^2.0.4:
version "2.0.4"
resolved "https://registry.yarnpkg.com/vue-timers/-/vue-timers-2.0.4.tgz#7e1c443abf2109db5eeab6e62b0f5a47e94cf70b"
integrity sha512-QOEVdO4V4o9WjFG6C0Kn9tfdTeeECjqvEQozcQlfL1Tn8v0qx4uUPhTYoc1+s6qoJnSbu8f68x8+nm1ZEir0kw==
vue@3.4.31:
version "3.4.31"
resolved "https://registry.yarnpkg.com/vue/-/vue-3.4.31.tgz#83a3c4dab8302b0e974b0d4b92a2f6a6378ae797"
@ -7271,6 +7343,17 @@ vue@3.4.31:
"@vue/server-renderer" "3.4.31"
"@vue/shared" "3.4.31"
vue@^3.2.25:
version "3.5.12"
resolved "https://registry.yarnpkg.com/vue/-/vue-3.5.12.tgz#e08421c601b3617ea2c9ef0413afcc747130b36c"
integrity sha512-CLVZtXtn2ItBIi/zHZ0Sg1Xkb7+PU32bJJ8Bmy7ts3jxXTcbfsEfBivFYYWz1Hur+lalqGAh65Coin0r+HRUfg==
dependencies:
"@vue/compiler-dom" "3.5.12"
"@vue/compiler-sfc" "3.5.12"
"@vue/runtime-dom" "3.5.12"
"@vue/server-renderer" "3.5.12"
"@vue/shared" "3.5.12"
vuex-persistedstate@^4.1.0:
version "4.1.0"
resolved "https://registry.yarnpkg.com/vuex-persistedstate/-/vuex-persistedstate-4.1.0.tgz#127165f85f5b4534fb3170a5d3a8be9811bd2a53"
@ -7377,7 +7460,16 @@ word-wrap@^1.2.5:
resolved "https://registry.yarnpkg.com/word-wrap/-/word-wrap-1.2.5.tgz#d2c45c6dd4fbce621a66f136cbe328afd0410b34"
integrity sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==
"wrap-ansi-cjs@npm:wrap-ansi@^7.0.0", wrap-ansi@7.0.0, wrap-ansi@^8.1.0:
"wrap-ansi-cjs@npm:wrap-ansi@^7.0.0":
version "7.0.0"
resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43"
integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==
dependencies:
ansi-styles "^4.0.0"
string-width "^4.1.0"
strip-ansi "^6.0.0"
wrap-ansi@7.0.0, wrap-ansi@^8.1.0:
version "7.0.0"
resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43"
integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==