Merge pull request #3323 from gradido/admin_add_location_alongside_gms_api_key

feat(admin):  geo-coordinates for community
This commit is contained in:
einhornimmond 2024-06-28 08:15:22 +02:00 committed by GitHub
commit 54c1bd4cdc
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
29 changed files with 872 additions and 216 deletions

View File

@ -113,7 +113,7 @@
{{ $t('contributionLink.clear') }}
</b-button>
<b-button @click.prevent="$emit('closeContributionForm')">
{{ $t('contributionLink.close') }}
{{ $t('close') }}
</b-button>
</div>
</b-form>

View File

@ -1,9 +1,19 @@
import { createMockClient } from 'mock-apollo-client'
import { mount } from '@vue/test-utils'
import VueApollo from 'vue-apollo'
import Vuex from 'vuex'
import CommunityVisualizeItem from './CommunityVisualizeItem.vue'
import { updateHomeCommunity } from '../../graphql/updateHomeCommunity'
import { toastSuccessSpy } from '../../../test/testSetup'
const mockClient = createMockClient()
const apolloProvider = new VueApollo({
defaultClient: mockClient,
})
const localVue = global.localVue
localVue.use(Vuex)
localVue.use(VueApollo)
const today = new Date()
const createdDate = new Date()
createdDate.setDate(createdDate.getDate() - 3)
@ -19,7 +29,7 @@ const store = new Vuex.Store({
let propsData = {
item: {
id: 1,
uuid: 1,
foreign: false,
url: 'http://localhost/api/',
publicKey: '4007170edd8d33fb009cd99ee4e87f214e7cd21b668d45540a064deb42e243c2',
@ -76,8 +86,18 @@ const mocks = {
describe('CommunityVisualizeItem', () => {
let wrapper
const updateHomeCommunityMock = jest.fn()
mockClient.setRequestHandler(
updateHomeCommunity,
updateHomeCommunityMock.mockResolvedValue({
data: {
updateHomeCommunity: { id: 1 },
},
}),
)
const Wrapper = () => {
return mount(CommunityVisualizeItem, { localVue, mocks, propsData, store })
return mount(CommunityVisualizeItem, { localVue, mocks, propsData, store, apolloProvider })
}
describe('mount', () => {
@ -152,7 +172,7 @@ describe('CommunityVisualizeItem', () => {
beforeEach(() => {
propsData = {
item: {
id: 7590,
uuid: 7590,
foreign: false,
publicKey: 'eaf6a426b24fd54f8fbae11c17700fc595080ca25159579c63d38dbc64284ba7',
url: 'http://localhost/api/2_0',
@ -195,7 +215,7 @@ describe('CommunityVisualizeItem', () => {
beforeEach(() => {
propsData = {
item: {
id: 7590,
uuid: 7590,
foreign: false,
publicKey: 'eaf6a426b24fd54f8fbae11c17700fc595080ca25159579c63d38dbc64284ba7',
url: 'http://localhost/api/',
@ -219,7 +239,7 @@ describe('CommunityVisualizeItem', () => {
beforeEach(() => {
propsData = {
item: {
id: 7590,
uuid: 7590,
foreign: false,
publicKey: 'eaf6a426b24fd54f8fbae11c17700fc595080ca25159579c63d38dbc64284ba7',
url: 'http://localhost/api/2_0',
@ -237,6 +257,100 @@ describe('CommunityVisualizeItem', () => {
expect(wrapper.vm.createdAt).toBe('')
})
})
describe('test handleUpdateHomeCommunity', () => {
describe('gms api key', () => {
beforeEach(async () => {
wrapper = Wrapper()
wrapper.vm.originalGmsApiKey = 'original'
wrapper.vm.gmsApiKey = 'changed key'
await wrapper.vm.handleUpdateHomeCommunity()
// Wait for the next tick to allow async operations to complete
await wrapper.vm.$nextTick()
})
it('expect changed gms api key', () => {
expect(updateHomeCommunityMock).toBeCalledWith({
uuid: propsData.item.uuid,
gmsApiKey: 'changed key',
location: undefined,
})
expect(wrapper.vm.originalGmsApiKey).toBe('changed key')
expect(toastSuccessSpy).toBeCalledWith('federation.toast_gmsApiKeyUpdated')
})
})
describe('location', () => {
beforeEach(async () => {
wrapper = Wrapper()
wrapper.vm.originalLocation = { latitude: 15.121, longitude: 1.212 }
wrapper.vm.location = { latitude: 1.121, longitude: 17.212 }
await wrapper.vm.handleUpdateHomeCommunity()
// Wait for the next tick to allow async operations to complete
await wrapper.vm.$nextTick()
})
it('expect changed location', () => {
expect(updateHomeCommunityMock).toBeCalledWith({
uuid: propsData.item.uuid,
location: { latitude: 1.121, longitude: 17.212 },
gmsApiKey: undefined,
})
expect(wrapper.vm.originalLocation).toStrictEqual({
latitude: 1.121,
longitude: 17.212,
})
expect(toastSuccessSpy).toBeCalledWith('federation.toast_gmsLocationUpdated')
})
})
describe('gms api key and location', () => {
beforeEach(async () => {
wrapper = Wrapper()
wrapper.vm.originalGmsApiKey = 'original'
wrapper.vm.gmsApiKey = 'changed key'
wrapper.vm.originalLocation = { latitude: 15.121, longitude: 1.212 }
wrapper.vm.location = { latitude: 1.121, longitude: 17.212 }
await wrapper.vm.handleUpdateHomeCommunity()
// Wait for the next tick to allow async operations to complete
await wrapper.vm.$nextTick()
})
it('expect changed gms api key and changed location', () => {
expect(updateHomeCommunityMock).toBeCalledWith({
uuid: propsData.item.uuid,
gmsApiKey: 'changed key',
location: undefined,
})
expect(wrapper.vm.originalGmsApiKey).toBe('changed key')
expect(wrapper.vm.originalLocation).toStrictEqual({
latitude: 1.121,
longitude: 17.212,
})
expect(toastSuccessSpy).toBeCalledWith('federation.toast_gmsApiKeyAndLocationUpdated')
})
})
})
describe('test resetHomeCommunityEditable', () => {
beforeEach(async () => {
wrapper = Wrapper()
})
it('test', () => {
wrapper.vm.originalGmsApiKey = 'original'
wrapper.vm.gmsApiKey = 'changed key'
wrapper.vm.originalLocation = { latitude: 15.121, longitude: 1.212 }
wrapper.vm.location = { latitude: 1.121, longitude: 17.212 }
wrapper.vm.resetHomeCommunityEditable()
expect(wrapper.vm.location).toStrictEqual({ latitude: 15.121, longitude: 1.212 })
expect(wrapper.vm.gmsApiKey).toBe('original')
})
})
})
})
})

View File

@ -25,12 +25,34 @@
{{ $t('federation.publicKey') }}&nbsp;{{ item.publicKey }}
</b-list-group-item>
<b-list-group-item v-if="!item.foreign">
{{ $t('federation.gmsApiKey') }}&nbsp;
<editable-label
:value="gmsApiKey"
<editable-group
:allowEdit="$store.state.moderator.roles.includes('ADMIN')"
@save="handleSaveGsmApiKey"
/>
@save="handleUpdateHomeCommunity"
@reset="resetHomeCommunityEditable"
>
<template #view>
<label>{{ $t('federation.gmsApiKey') }}&nbsp;{{ gmsApiKey }}</label>
<b-form-group>
{{ $t('federation.coordinates') }}
<span v-if="isValidLocation">
{{
$t('geo-coordinates.format', {
latitude: location.latitude,
longitude: location.longitude,
})
}}
</span>
</b-form-group>
</template>
<template #edit>
<editable-groupable-label
v-model="gmsApiKey"
:label="$t('federation.gmsApiKey')"
idName="home-community-api-key"
/>
<coordinates v-model="location" />
</template>
</editable-group>
</b-list-group-item>
<b-list-group-item>
<b-list-group>
@ -59,17 +81,21 @@
<script>
import { formatDistanceToNow } from 'date-fns'
import { de, enUS as en, fr, es, nl } from 'date-fns/locale'
import EditableLabel from '@/components/input/EditableLabel'
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: {
EditableLabel,
Coordinates,
EditableGroup,
FederationVisualizeItem,
EditableGroupableLabel,
},
props: {
item: { type: Object },
@ -79,12 +105,12 @@ export default {
formatDistanceToNow,
locale: this.$i18n.locale,
details: false,
gmsApiKey: '',
gmsApiKey: this.item.gmsApiKey,
location: this.item.location,
originalGmsApiKey: this.item.gmsApiKey,
originalLocation: this.item.location,
}
},
created() {
this.gmsApiKey = this.item.gmsApiKey
},
computed: {
verified() {
if (!this.item.federatedCommunities || this.item.federatedCommunities.length === 0) {
@ -133,28 +159,49 @@ export default {
}
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
},
handleSaveGsmApiKey(gmsApiKey) {
this.gmsApiKey = gmsApiKey
handleUpdateHomeCommunity() {
this.$apollo
.mutate({
mutation: updateHomeCommunity,
variables: {
uuid: this.item.uuid,
gmsApiKey: gmsApiKey,
gmsApiKey: this.gmsApiKey,
location: this.location,
},
})
.then(() => {
this.toastSuccess(this.$t('federation.toast_gmsApiKeyUpdated'))
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

@ -0,0 +1,103 @@
import { mount } from '@vue/test-utils'
import Coordinates from './Coordinates.vue'
import Vue from 'vue'
import VueI18n from 'vue-i18n'
Vue.use(VueI18n)
const localVue = global.localVue
const mocks = {
$t: jest.fn((t, v) => {
if (t === 'geo-coordinates.format') {
return `${v.latitude}, ${v.longitude}`
}
return t
}),
}
describe('Coordinates', () => {
let wrapper
const value = {
latitude: 56.78,
longitude: 12.34,
}
const createWrapper = (propsData) => {
return mount(Coordinates, {
localVue,
mocks,
propsData,
})
}
beforeEach(() => {
wrapper = createWrapper({ value })
})
it('renders the component with initial values', () => {
expect(wrapper.find('#home-community-latitude').element.value).toBe('56.78')
expect(wrapper.find('#home-community-longitude').element.value).toBe('12.34')
expect(wrapper.find('#home-community-latitude-longitude-smart').element.value).toBe(
'56.78, 12.34',
)
})
it('updates latitude and longitude when input changes', async () => {
const latitudeInput = wrapper.find('#home-community-latitude')
const longitudeInput = wrapper.find('#home-community-longitude')
await latitudeInput.setValue('34.56')
await longitudeInput.setValue('78.90')
expect(wrapper.vm.inputValue).toStrictEqual({
latitude: 34.56,
longitude: 78.9,
})
})
it('emits input event with updated values', async () => {
const latitudeInput = wrapper.find('#home-community-latitude')
const longitudeInput = wrapper.find('#home-community-longitude')
await latitudeInput.setValue('34.56')
expect(wrapper.emitted().input).toBeTruthy()
expect(wrapper.emitted().input[0][0]).toEqual({
latitude: 34.56,
longitude: 12.34,
})
await longitudeInput.setValue('78.90')
expect(wrapper.emitted().input).toBeTruthy()
expect(wrapper.emitted().input[1][0]).toEqual({
latitude: 34.56,
longitude: 78.9,
})
})
it('splits coordinates correctly when entering in latitudeLongitude input', async () => {
const latitudeLongitudeInput = wrapper.find('#home-community-latitude-longitude-smart')
await latitudeLongitudeInput.setValue('34.56, 78.90')
await latitudeLongitudeInput.trigger('input')
expect(wrapper.vm.inputValue).toStrictEqual({
latitude: 34.56,
longitude: 78.9,
})
})
it('validates coordinates correctly', async () => {
const latitudeInput = wrapper.find('#home-community-latitude')
const longitudeInput = wrapper.find('#home-community-longitude')
await latitudeInput.setValue('invalid')
await longitudeInput.setValue('78.90')
expect(wrapper.vm.isValid).toBe(false)
await latitudeInput.setValue('34.56')
await longitudeInput.setValue('78.90')
expect(wrapper.vm.isValid).toBe(true)
})
})

View File

@ -0,0 +1,114 @@
<template>
<div>
<b-form-group
:label="$t('geo-coordinates.label')"
:invalid-feedback="$t('geo-coordinates.both-or-none')"
:state="isValid"
>
<b-form-group
:label="$t('latitude-longitude-smart')"
label-for="home-community-latitude-longitude-smart"
:description="$t('geo-coordinates.latitude-longitude-smart.describe')"
>
<b-form-input
v-model="locationString"
id="home-community-latitude-longitude-smart"
type="text"
@input="splitCoordinates"
/>
</b-form-group>
<b-form-group :label="$t('latitude')" label-for="home-community-latitude">
<b-form-input
v-model="inputValue.latitude"
id="home-community-latitude"
type="text"
@input="valueUpdated"
/>
</b-form-group>
<b-form-group :label="$t('longitude')" label-for="home-community-longitude">
<b-form-input
v-model="inputValue.longitude"
id="home-community-longitude"
type="text"
@input="valueUpdated"
/>
</b-form-group>
</b-form-group>
</div>
</template>
<script>
export default {
name: 'Coordinates',
props: {
value: Object,
default: null,
},
data() {
return {
inputValue: this.value,
originalValue: this.value,
locationString: this.getLatitudeLongitudeString(this.value),
}
},
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 = value.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(value) {
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)
},
},
}
</script>

View File

@ -0,0 +1,92 @@
import { mount } from '@vue/test-utils'
import EditableGroup from './EditableGroup.vue'
const localVue = global.localVue
const viewValue = 'test label value'
const editValue = 'test edit value'
const mocks = {
$t: jest.fn((t) => t),
}
describe('EditableGroup', () => {
let wrapper
const createWrapper = (propsData) => {
return mount(EditableGroup, {
localVue,
propsData,
mocks,
slots: {
view: `<div>${viewValue}</div>`,
edit: `<div class='test-edit'>${editValue}</div>`,
},
})
}
it('renders the view slot when not editing', () => {
wrapper = createWrapper({ allowEdit: true })
expect(wrapper.find('div').text()).toBe(viewValue)
})
it('renders the edit slot when editing', async () => {
wrapper = createWrapper({ allowEdit: true })
await wrapper.find('button').trigger('click')
expect(wrapper.find('.test-edit').text()).toBe(editValue)
})
it('emits save event when clicking save button', async () => {
wrapper = createWrapper({ allowEdit: true })
await wrapper.find('button').trigger('click') // Click to enable editing
await wrapper.vm.$emit('input', 'New Value') // Simulate input change
await wrapper.setData({ isValueChanged: true }) // Set valueChanged to true
await wrapper.find('button').trigger('click') // Click to save
expect(wrapper.emitted().save).toBeTruthy()
})
it('disables save button when value is not changed', async () => {
wrapper = createWrapper({ allowEdit: true })
await wrapper.find('button').trigger('click') // Click to enable editing
expect(wrapper.find('button').attributes('disabled')).toBe('disabled')
})
it('enables save button when value is changed', async () => {
wrapper = createWrapper({ allowEdit: true })
await wrapper.find('button').trigger('click') // Click to enable editing
await wrapper.vm.$emit('input', 'New Value') // Simulate input change
await wrapper.setData({ isValueChanged: true }) // Set valueChanged to true
expect(wrapper.find('button').attributes('disabled')).toBeFalsy()
})
it('updates variant to success when editing', async () => {
wrapper = createWrapper({ allowEdit: true })
await wrapper.find('button').trigger('click') // Click to enable editing
expect(wrapper.vm.variant).toBe('success')
})
it('updates variant to prime when not editing', async () => {
wrapper = createWrapper({ allowEdit: true })
expect(wrapper.vm.variant).toBe('prime')
})
it('emits reset event when clicking close button', async () => {
wrapper = createWrapper({ allowEdit: true })
await wrapper.find('button').trigger('click') // Click to enable editing
await wrapper.find('button.close-button').trigger('click') // Click close button
expect(wrapper.emitted().reset).toBeTruthy()
})
})

View File

@ -0,0 +1,62 @@
<template>
<div>
<slot v-if="!isEditing" v-bind:isEditing="isEditing" name="view"></slot>
<slot v-else v-bind:isEditing="isEditing" name="edit" @input="valueChanged"></slot>
<b-form-group v-if="allowEdit && !isEditing">
<b-button @click="enableEdit" :variant="variant">
<b-icon icon="pencil-fill">{{ $t('edit') }}</b-icon>
</b-button>
</b-form-group>
<b-form-group v-else-if="allowEdit && isEditing">
<b-button @click="save" :variant="variant" :disabled="!isValueChanged" class="save-button">
{{ $t('save') }}
</b-button>
<b-button @click="close" variant="secondary" class="close-button">
{{ $t('close') }}
</b-button>
</b-form-group>
</div>
</template>
<script>
export default {
name: 'EditableGroup',
props: {
allowEdit: {
type: Boolean,
default: false,
},
},
data() {
return {
isEditing: false,
isValueChanged: false,
}
},
computed: {
variant() {
return this.isEditing ? 'success' : 'prime'
},
},
methods: {
enableEdit() {
this.isEditing = true
},
valueChanged() {
this.isValueChanged = true
},
invalidValues() {
this.isValueChanged = false
},
save() {
this.$emit('save')
this.isEditing = false
this.isValueChanged = false
},
close() {
this.$emit('reset')
this.isEditing = false
},
},
}
</script>

View File

@ -0,0 +1,79 @@
import { mount } from '@vue/test-utils'
import EditableGroupableLabel from './EditableGroupableLabel.vue'
const localVue = global.localVue
const value = 'test label value'
const label = 'Test Label'
const idName = 'test-id-name'
describe('EditableGroupableLabel', () => {
let wrapper
const createWrapper = (propsData) => {
return mount(EditableGroupableLabel, {
localVue,
propsData,
})
}
beforeEach(() => {
wrapper = createWrapper({ value, label, idName })
})
it('renders the label correctly', () => {
expect(wrapper.find('label').text()).toBe(label)
})
it('renders the input with the correct id and value', () => {
const input = wrapper.find('input')
expect(input.attributes('id')).toBe(idName)
expect(input.element.value).toBe(value)
})
it('emits input event with the correct value when input changes', async () => {
const newValue = 'new label value'
const input = wrapper.find('input')
input.element.value = newValue
await input.trigger('input')
expect(wrapper.emitted().input).toBeTruthy()
expect(wrapper.emitted().input[0][0]).toBe(newValue)
})
it('calls valueChanged method on parent when value changes', async () => {
const valueChangedMock = jest.fn()
wrapper.vm.$parent = { valueChanged: valueChangedMock }
const newValue = 'new label value'
const input = wrapper.find('input')
input.element.value = newValue
await input.trigger('input')
expect(valueChangedMock).toHaveBeenCalled()
})
it('calls invalidValues method on parent when value is reverted to original', async () => {
const invalidValuesMock = jest.fn()
wrapper.vm.$parent = { invalidValues: invalidValuesMock }
const input = wrapper.find('input')
input.element.value = 'new label value'
await input.trigger('input')
input.element.value = value
await input.trigger('input')
expect(invalidValuesMock).toHaveBeenCalled()
})
it('does not call valueChanged method on parent when value is reverted to original', async () => {
const valueChangedMock = jest.fn()
wrapper.vm.$parent = { valueChanged: valueChangedMock }
const input = wrapper.find('input')
input.element.value = value
await input.trigger('input')
expect(valueChangedMock).not.toHaveBeenCalled()
})
})

View File

@ -0,0 +1,47 @@
<template>
<b-form-group :label="label" :label-for="idName">
<b-form-input :id="idName" v-model="inputValue" @input="updateValue" />
</b-form-group>
</template>
<script>
export default {
name: 'EditableGroupableLabel',
props: {
value: {
type: String,
required: false,
default: null,
},
label: {
type: String,
required: true,
},
idName: {
type: String,
required: true,
},
},
data() {
return {
inputValue: this.value,
originalValue: this.value,
}
},
methods: {
updateValue(value) {
this.inputValue = value
if (this.inputValue !== this.originalValue) {
if (this.$parent.valueChanged) {
this.$parent.valueChanged()
}
} else {
if (this.$parent.invalidValues) {
this.$parent.invalidValues()
}
}
this.$emit('input', this.inputValue)
},
},
}
</script>

View File

@ -1,83 +0,0 @@
// Test written by ChatGPT 3.5
import { mount } from '@vue/test-utils'
import EditableLabel from './EditableLabel.vue'
const localVue = global.localVue
const value = 'test label value'
describe('EditableLabel', () => {
let wrapper
const createWrapper = (propsData) => {
return mount(EditableLabel, {
localVue,
propsData,
})
}
it('renders the label when not editing', () => {
wrapper = createWrapper({ value, allowEdit: true })
expect(wrapper.find('label').text()).toBe(value)
})
it('renders the input when editing', async () => {
wrapper = createWrapper({ value, allowEdit: true })
await wrapper.find('button').trigger('click')
expect(wrapper.find('input').exists()).toBe(true)
})
it('emits save event when clicking save button', async () => {
wrapper = createWrapper({ value, allowEdit: true })
await wrapper.find('button').trigger('click')
await wrapper.setData({ inputValue: 'New Value' })
await wrapper.find('button').trigger('click')
expect(wrapper.emitted().save).toBeTruthy()
expect(wrapper.emitted().save[0][0]).toBe('New Value')
})
it('disables save button when value is not changed', async () => {
wrapper = createWrapper({ value, allowEdit: true })
await wrapper.find('button').trigger('click')
expect(wrapper.find('button').attributes('disabled')).toBe('disabled')
})
it('enables save button when value is changed', async () => {
wrapper = createWrapper({ value, allowEdit: true })
await wrapper.find('button').trigger('click')
await wrapper.setData({ inputValue: 'New Value' })
expect(wrapper.find('button').attributes('disabled')).toBeFalsy()
})
it('updates originalValue when saving changes', async () => {
wrapper = createWrapper({ value, allowEdit: true })
await wrapper.find('button').trigger('click')
await wrapper.setData({ inputValue: 'New Value' })
await wrapper.find('button').trigger('click')
expect(wrapper.vm.originalValue).toBe('New Value')
})
it('changes variant to success when editing', async () => {
wrapper = createWrapper({ value, allowEdit: true })
await wrapper.find('button').trigger('click')
expect(wrapper.vm.variant).toBe('success')
})
it('changes variant to prime when not editing', async () => {
wrapper = createWrapper({ value, allowEdit: true })
expect(wrapper.vm.variant).toBe('prime')
})
})

View File

@ -1,64 +0,0 @@
<template>
<div>
<b-form-group>
<label v-if="!editing">{{ value }}</label>
<b-form-input v-else v-model="inputValue" :placeholder="placeholder" />
</b-form-group>
<b-button
v-if="allowEdit"
@click="toggleEdit"
:disabled="!isValueChanged && editing"
:variant="variant"
>
<b-icon v-if="!editing" icon="pencil-fill" tooltip="$t('edit')"></b-icon>
<b-icon v-else icon="check" tooltip="$t('save')"></b-icon>
</b-button>
</div>
</template>
<script>
export default {
// Code written from chatGPT 3.5
name: 'EditableLabel',
props: {
value: {
type: String,
required: false,
default: '',
},
allowEdit: {
type: Boolean,
default: false,
},
placeholder: {
type: String,
required: false,
default: '',
},
},
data() {
return {
editing: false,
inputValue: this.value,
originalValue: this.value,
}
},
computed: {
variant() {
return this.editing ? 'success' : 'prime'
},
isValueChanged() {
return this.inputValue !== this.originalValue
},
},
methods: {
toggleEdit() {
if (this.editing) {
this.$emit('save', this.inputValue)
this.originalValue = this.inputValue
}
this.editing = !this.editing
},
},
}
</script>

View File

@ -11,6 +11,7 @@ export const allCommunities = gql`
name
description
gmsApiKey
location
creationDate
createdAt
updatedAt

View File

@ -1,8 +1,8 @@
import gql from 'graphql-tag'
export const updateHomeCommunity = gql`
mutation ($uuid: String!, $gmsApiKey: String!) {
updateHomeCommunity(uuid: $uuid, gmsApiKey: $gmsApiKey) {
mutation ($uuid: String!, $gmsApiKey: String, $location: Location) {
updateHomeCommunity(uuid: $uuid, gmsApiKey: $gmsApiKey, location: $location) {
id
}
}

View File

@ -4,11 +4,11 @@
"back": "zurück",
"change_user_role": "Nutzerrolle ändern",
"chat": "Chat",
"close": "Schließen",
"contributionLink": {
"amount": "Betrag",
"changeSaved": "Änderungen gespeichert",
"clear": "Löschen",
"close": "Schließen",
"contributionLinks": "Beitragslinks",
"create": "Anlegen",
"cycle": "Zyklus",
@ -69,6 +69,7 @@
"deleted_user": "Alle gelöschten Nutzer",
"deny": "Ablehnen",
"e_mail": "E-Mail",
"edit": "bearbeiten",
"enabled": "aktiviert",
"error": "Fehler",
"expired": "abgelaufen",
@ -76,9 +77,12 @@
"apiVersion": "API Version",
"authenticatedAt": "Verifiziert am:",
"communityUuid": "Community UUID:",
"coordinates": "Koordinaten:",
"createdAt": "Erstellt am",
"gmsApiKey": "GMS API Key:",
"toast_gmsApiKeyAndLocationUpdated": "Der GMS Api Key und die Location wurden erfolgreich aktualisiert!",
"toast_gmsApiKeyUpdated": "Der GMS Api Key wurde erfolgreich aktualisiert!",
"toast_gmsLocationUpdated": "Die GMS Location wurde erfolgreich aktualisiert!",
"gradidoInstances": "Gradido Instanzen",
"lastAnnouncedAt": "letzte Bekanntgabe",
"lastErrorAt": "Letzer Fehler am",
@ -100,6 +104,14 @@
"form": {
"cancel": "Abbrechen"
},
"geo-coordinates": {
"both-or-none": "Bitte beide oder keine eingeben!",
"format": "{latitude}, {longitude}",
"label": "Geo-Koordinaten",
"latitude-longitude-smart": {
"describe": "Teilt Koordinaten im Format 'Breitengrad, Längengrad' automatisch auf. Fügen sie hier einfach z.B. ihre Koordinaten von Google Maps, zum Beispiel: 49.28187664243721, 9.740672183943639, ein."
}
},
"help": {
"help": "Hilfe",
"transactionlist": {
@ -113,6 +125,9 @@
"hide_resubmission": "Wiedervorlage verbergen",
"hide_resubmission_tooltip": "Verbirgt alle Schöpfungen für die ein Moderator ein Erinnerungsdatum festgelegt hat.",
"lastname": "Nachname",
"latitude": "Breitengrad:",
"latitude-longitude-smart": "Breitengrad, Längengrad",
"longitude": "Längengrad:",
"math": {
"equals": "=",
"pipe": "|",

View File

@ -4,11 +4,11 @@
"back": "back",
"change_user_role": "Change user role",
"chat": "Chat",
"close": "Close",
"contributionLink": {
"amount": "Amount",
"changeSaved": "Changes saved",
"clear": "Clear",
"close": "Close",
"contributionLinks": "Contribution Links",
"create": "Create",
"cycle": "Cycle",
@ -69,6 +69,7 @@
"deleted_user": "All deleted user",
"deny": "Reject",
"e_mail": "E-mail",
"edit": "edit",
"enabled": "enabled",
"error": "Error",
"expired": "expired",
@ -76,9 +77,12 @@
"apiVersion": "API Version",
"authenticatedAt": "verified at:",
"communityUuid": "Community UUID:",
"coordinates": "Coordinates:",
"createdAt": "Created At ",
"gmsApiKey": "GMS API Key:",
"toast_gmsApiKeyAndLocationUpdated": "The GMS Api Key and the location have been successfully updated!",
"toast_gmsApiKeyUpdated": "The GMS Api Key has been successfully updated!",
"toast_gmsLocationUpdated": "The GMS location has been successfully updated!",
"gradidoInstances": "Gradido Instances",
"lastAnnouncedAt": "Last Announced",
"lastErrorAt": "last error at",
@ -100,6 +104,14 @@
"form": {
"cancel": "Cancel"
},
"geo-coordinates": {
"both-or-none": "Please enter both or none!",
"label": "geo-coordinates",
"format": "{latitude}, {longitude}",
"latitude-longitude-smart": {
"describe": "Automatically splits coordinates in the format 'latitude, longitude'. Simply enter your coordinates from Google Maps here, for example: 49.28187664243721, 9.740672183943639."
}
},
"help": {
"help": "Help",
"transactionlist": {
@ -113,6 +125,9 @@
"hide_resubmission": "Hide resubmission",
"hide_resubmission_tooltip": "Hides all creations for which a moderator has set a reminder date.",
"lastname": "Lastname",
"latitude": "Latitude:",
"latitude-longitude-smart": "Latitude, Longitude",
"longitude": "Longitude:",
"math": {
"equals": "=",
"pipe": "|",

View File

@ -36,6 +36,7 @@
"gradido-database": "file:../database",
"graphql": "^15.5.1",
"graphql-request": "5.0.0",
"graphql-type-json": "0.3.2",
"helmet": "^5.1.1",
"i18n": "^0.15.1",
"jose": "^4.14.4",

View File

@ -12,7 +12,7 @@ Decimal.set({
})
const constants = {
DB_VERSION: '0085-add_index_transactions_user_id',
DB_VERSION: '0086-add_community_location',
DECAY_START_TIME: new Date('2021-05-13 17:46:31-0000'), // GMT+0
LOG4JS_CONFIG: 'log4js-config.json',
// default log level on production should be info

View File

@ -1,6 +1,9 @@
import { IsString, IsUUID } from 'class-validator'
import { ArgsType, Field, InputType } from 'type-graphql'
import { Location } from '@/graphql/model/Location'
import { isValidLocation } from '@/graphql/validator/Location'
@ArgsType()
@InputType()
export class EditCommunityInput {
@ -8,7 +11,11 @@ export class EditCommunityInput {
@IsUUID('4')
uuid: string
@Field(() => String)
@Field(() => String, { nullable: true })
@IsString()
gmsApiKey: string
gmsApiKey?: string | null
@Field(() => Location, { nullable: true })
@isValidLocation()
location?: Location | null
}

View File

@ -1,8 +1,12 @@
import { Point } from '@dbTools/typeorm'
import { Community as DbCommunity } from '@entity/Community'
import { FederatedCommunity as DbFederatedCommunity } from '@entity/FederatedCommunity'
import { ObjectType, Field } from 'type-graphql'
import { Point2Location } from '@/graphql/resolver/util/Location2Point'
import { FederatedCommunity } from './FederatedCommunity'
import { Location } from './Location'
@ObjectType()
export class AdminCommunityView {
@ -36,6 +40,9 @@ export class AdminCommunityView {
this.uuid = dbCom.communityUuid
this.authenticatedAt = dbCom.authenticatedAt
this.gmsApiKey = dbCom.gmsApiKey
if (dbCom.location) {
this.location = Point2Location(dbCom.location as Point)
}
}
@Field(() => Boolean)
@ -62,6 +69,9 @@ export class AdminCommunityView {
@Field(() => String, { nullable: true })
gmsApiKey: string | null
@Field(() => Location, { nullable: true })
location: Location | null
@Field(() => Date, { nullable: true })
creationDate: Date | null

View File

@ -18,6 +18,7 @@ import {
getCommunityByUuid,
getHomeCommunity,
} from './util/communities'
import { Location2Point } from './util/Location2Point'
@Resolver()
export class CommunityResolver {
@ -78,7 +79,9 @@ export class CommunityResolver {
@Authorized([RIGHTS.COMMUNITY_UPDATE])
@Mutation(() => Community)
async updateHomeCommunity(@Args() { uuid, gmsApiKey }: EditCommunityInput): Promise<Community> {
async updateHomeCommunity(
@Args() { uuid, gmsApiKey, location }: EditCommunityInput,
): Promise<Community> {
const homeCom = await getCommunityByUuid(uuid)
if (!homeCom) {
throw new LogError('HomeCommunity with uuid not found: ', uuid)
@ -86,8 +89,11 @@ export class CommunityResolver {
if (homeCom.foreign) {
throw new LogError('Error: Only the HomeCommunity could be modified!')
}
if (homeCom.gmsApiKey !== gmsApiKey) {
homeCom.gmsApiKey = gmsApiKey
if (homeCom.gmsApiKey !== gmsApiKey || homeCom.location !== location) {
homeCom.gmsApiKey = gmsApiKey ?? null
if (location) {
homeCom.location = Location2Point(location)
}
await DbCommunity.save(homeCom)
}
return new Community(homeCom)

View File

@ -1,31 +0,0 @@
/* eslint-disable @typescript-eslint/no-unsafe-argument */
import { Point as DbPoint } from '@dbTools/typeorm'
import { GraphQLScalarType, Kind } from 'graphql'
export const PointScalar = new GraphQLScalarType({
name: 'Point',
description:
'The `Point` scalar type to represent longitude and latitude values of a geo location',
serialize(value: DbPoint) {
// Check type of value
if (value.type !== 'Point') {
throw new Error(`PointScalar can only serialize Geometry type 'Point' values`)
}
return value
},
parseValue(value): DbPoint {
const point = JSON.parse(value) as DbPoint
return point
},
parseLiteral(ast) {
if (ast.kind !== Kind.STRING) {
throw new TypeError(`${String(ast)} is not a valid Geometry value.`)
}
const point = JSON.parse(ast.value) as DbPoint
return point
},
})

View File

@ -3696,7 +3696,7 @@ graceful-fs@^4.1.6, graceful-fs@^4.2.0:
integrity sha512-9ByhssR2fPVsNZj478qUUbKfmL0+t5BDVyjShtyZZLiK7ZDAArFFfopyOTj0M05wE2tJPisA4iTnnXl2YoPvOA==
"gradido-database@file:../database":
version "2.2.1"
version "2.3.1"
dependencies:
"@types/uuid" "^8.3.4"
cross-env "^7.0.3"
@ -3774,6 +3774,11 @@ graphql-tools@^4.0.8:
iterall "^1.1.3"
uuid "^3.1.0"
graphql-type-json@0.3.2:
version "0.3.2"
resolved "https://registry.yarnpkg.com/graphql-type-json/-/graphql-type-json-0.3.2.tgz#f53a851dbfe07bd1c8157d24150064baab41e115"
integrity sha512-J+vjof74oMlCWXSvt0DOf2APEdZOCdubEvGDUAlqH//VBYcOYsGgRW7Xzorr44LvkjiuvecWc8fChxuZZbChtg==
graphql@^15.5.1:
version "15.6.1"
resolved "https://registry.yarnpkg.com/graphql/-/graphql-15.6.1.tgz#9125bdf057553525da251e19e96dab3d3855ddfc"

View File

@ -0,0 +1,89 @@
import {
BaseEntity,
Entity,
PrimaryGeneratedColumn,
Column,
CreateDateColumn,
UpdateDateColumn,
OneToMany,
JoinColumn,
Geometry,
} from 'typeorm'
import { FederatedCommunity } from '../FederatedCommunity'
import { GeometryTransformer } from '../../src/typeorm/GeometryTransformer'
import { User } from '../User'
@Entity('communities')
export class Community extends BaseEntity {
@PrimaryGeneratedColumn('increment', { unsigned: true })
id: number
@Column({ name: 'foreign', type: 'bool', nullable: false, default: true })
foreign: boolean
@Column({ name: 'url', length: 255, nullable: false })
url: string
@Column({ name: 'public_key', type: 'binary', length: 32, nullable: false })
publicKey: Buffer
@Column({ name: 'private_key', type: 'binary', length: 64, nullable: true })
privateKey: Buffer | null
@Column({
name: 'community_uuid',
type: 'char',
length: 36,
nullable: true,
collation: 'utf8mb4_unicode_ci',
})
communityUuid: string | null
@Column({ name: 'authenticated_at', type: 'datetime', nullable: true })
authenticatedAt: Date | null
@Column({ name: 'name', type: 'varchar', length: 40, nullable: true })
name: string | null
@Column({ name: 'description', type: 'varchar', length: 255, nullable: true })
description: string | null
@CreateDateColumn({ name: 'creation_date', type: 'datetime', nullable: true })
creationDate: Date | null
@Column({ name: 'gms_api_key', type: 'varchar', length: 512, nullable: true, default: null })
gmsApiKey: string | null
@Column({
name: 'location',
type: 'geometry',
default: null,
nullable: true,
transformer: GeometryTransformer,
})
location: Geometry | null
@CreateDateColumn({
name: 'created_at',
type: 'datetime',
default: () => 'CURRENT_TIMESTAMP(3)',
nullable: false,
})
createdAt: Date
@UpdateDateColumn({
name: 'updated_at',
type: 'datetime',
onUpdate: 'CURRENT_TIMESTAMP(3)',
nullable: true,
})
updatedAt: Date | null
@OneToMany(() => User, (user) => user.community)
@JoinColumn({ name: 'community_uuid', referencedColumnName: 'communityUuid' })
users: User[]
@OneToMany(() => FederatedCommunity, (federatedCommunity) => federatedCommunity.community)
@JoinColumn({ name: 'public_key', referencedColumnName: 'publicKey' })
federatedCommunities?: FederatedCommunity[]
}

View File

@ -1 +1 @@
export { Community } from './0083-join_community_federated_communities/Community'
export { Community } from './0086-add_community_location/Community'

View File

@ -0,0 +1,13 @@
/* eslint-disable @typescript-eslint/explicit-module-boundary-types */
/* eslint-disable @typescript-eslint/no-explicit-any */
export async function upgrade(queryFn: (query: string, values?: any[]) => Promise<Array<any>>) {
await queryFn(
'ALTER TABLE `communities` ADD COLUMN IF NOT EXISTS `location` geometry DEFAULT NULL NULL AFTER `gms_api_key`;',
)
}
export async function downgrade(queryFn: (query: string, values?: any[]) => Promise<Array<any>>) {
await queryFn('ALTER TABLE `communities` DROP COLUMN IF EXISTS `location`;')
}

View File

@ -4,7 +4,7 @@ import dotenv from 'dotenv'
dotenv.config()
const constants = {
DB_VERSION: '0085-add_index_transactions_user_id',
DB_VERSION: '0086-add_community_location',
LOG4JS_CONFIG: 'log4js-config.json',
// default log level on production should be info
LOG_LEVEL: process.env.LOG_LEVEL ?? 'info',

View File

@ -2260,6 +2260,11 @@ gensync@^1.0.0-beta.2:
resolved "https://registry.yarnpkg.com/gensync/-/gensync-1.0.0-beta.2.tgz#32a6ee76c3d7f52d46b2b1ae5d93fea8580a25e0"
integrity sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==
geojson@^0.5.0:
version "0.5.0"
resolved "https://registry.yarnpkg.com/geojson/-/geojson-0.5.0.tgz#3cd6c96399be65b56ee55596116fe9191ce701c0"
integrity sha512-/Bx5lEn+qRF4TfQ5aLu6NH+UKtvIv7Lhc487y/c8BdludrCTpiWf9wyI0RTyqg49MFefIAvFDuEi5Dfd/zgNxQ==
get-caller-file@^2.0.5:
version "2.0.5"
resolved "https://registry.yarnpkg.com/get-caller-file/-/get-caller-file-2.0.5.tgz#4f94412a82db32f36e3b0b9741f8a97feb031f7e"
@ -2389,18 +2394,20 @@ graceful-fs@^4.2.4:
integrity sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==
"gradido-database@file:../database":
version "1.22.3"
version "2.2.1"
dependencies:
"@types/uuid" "^8.3.4"
cross-env "^7.0.3"
crypto "^1.0.1"
decimal.js-light "^2.5.1"
dotenv "^10.0.0"
geojson "^0.5.0"
mysql2 "^2.3.0"
reflect-metadata "^0.1.13"
ts-mysql-migrate "^1.0.2"
typeorm "^0.3.16"
uuid "^8.3.2"
wkx "^0.5.0"
grapheme-splitter@^1.0.4:
version "1.0.4"
@ -4888,6 +4895,13 @@ which@^2.0.1:
dependencies:
isexe "^2.0.0"
wkx@^0.5.0:
version "0.5.0"
resolved "https://registry.yarnpkg.com/wkx/-/wkx-0.5.0.tgz#c6c37019acf40e517cc6b94657a25a3d4aa33e8c"
integrity sha512-Xng/d4Ichh8uN4l0FToV/258EjMGU9MGcA0HV2d9B/ZpZB3lqQm7nkOdZdm5GhKtLLhAE7PiVQwN4eN+2YJJUg==
dependencies:
"@types/node" "*"
word-wrap@^1.2.3, word-wrap@~1.2.3:
version "1.2.3"
resolved "https://registry.yarnpkg.com/word-wrap/-/word-wrap-1.2.3.tgz#610636f6b1f703891bd34771ccb17fb93b47079c"

View File

@ -1,7 +1,7 @@
export async function upgrade(queryFn: (query: string, values?: any[]) => Promise<Array<any>>) {
await queryFn(`
ALTER TABLE \`transactions\`
RENAME COLUMN \`paring_transaction_id\` TO \`pairing_transaction_id\`,
RENAME COLUMN \`paring_transaction_id\` TO \`pairing_transaction_id\`
;
`)
}
@ -9,7 +9,7 @@ export async function upgrade(queryFn: (query: string, values?: any[]) => Promis
export async function downgrade(queryFn: (query: string, values?: any[]) => Promise<Array<any>>) {
await queryFn(`
ALTER TABLE \`transactions\`
RENAME COLUMN \`pairing_transaction_id\` TO \`paring_transaction_id\`,
RENAME COLUMN \`pairing_transaction_id\` TO \`paring_transaction_id\`
;
`)
}

View File

@ -10,7 +10,7 @@ Decimal.set({
})
const constants = {
DB_VERSION: '0085-add_index_transactions_user_id',
DB_VERSION: '0086-add_community_location',
DECAY_START_TIME: new Date('2021-05-13 17:46:31-0000'), // GMT+0
LOG4JS_CONFIG: 'log4js-config.json',
// default log level on production should be info