diff --git a/admin/src/components/ContributionLink/ContributionLinkForm.vue b/admin/src/components/ContributionLink/ContributionLinkForm.vue index 2afd8e1c5..f8e3d0f1f 100644 --- a/admin/src/components/ContributionLink/ContributionLinkForm.vue +++ b/admin/src/components/ContributionLink/ContributionLinkForm.vue @@ -113,7 +113,7 @@ {{ $t('contributionLink.clear') }} - {{ $t('contributionLink.close') }} + {{ $t('close') }} diff --git a/admin/src/components/Federation/CommunityVisualizeItem.spec.js b/admin/src/components/Federation/CommunityVisualizeItem.spec.js index 2c5065ba0..e2d10a3f9 100644 --- a/admin/src/components/Federation/CommunityVisualizeItem.spec.js +++ b/admin/src/components/Federation/CommunityVisualizeItem.spec.js @@ -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') + }) + }) }) }) }) diff --git a/admin/src/components/Federation/CommunityVisualizeItem.vue b/admin/src/components/Federation/CommunityVisualizeItem.vue index 24e6b3712..5b3d9bc8b 100644 --- a/admin/src/components/Federation/CommunityVisualizeItem.vue +++ b/admin/src/components/Federation/CommunityVisualizeItem.vue @@ -25,12 +25,34 @@ {{ $t('federation.publicKey') }} {{ item.publicKey }} - {{ $t('federation.gmsApiKey') }}  - + @save="handleUpdateHomeCommunity" + @reset="resetHomeCommunityEditable" + > + + + @@ -59,17 +81,21 @@ diff --git a/admin/src/components/input/Coordinates.spec.js b/admin/src/components/input/Coordinates.spec.js new file mode 100644 index 000000000..21d1c3263 --- /dev/null +++ b/admin/src/components/input/Coordinates.spec.js @@ -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) + }) +}) diff --git a/admin/src/components/input/Coordinates.vue b/admin/src/components/input/Coordinates.vue new file mode 100644 index 000000000..b71e5d9d4 --- /dev/null +++ b/admin/src/components/input/Coordinates.vue @@ -0,0 +1,114 @@ + + + diff --git a/admin/src/components/input/EditableGroup.spec.js b/admin/src/components/input/EditableGroup.spec.js new file mode 100644 index 000000000..9b0dc148b --- /dev/null +++ b/admin/src/components/input/EditableGroup.spec.js @@ -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: `
${viewValue}
`, + edit: `
${editValue}
`, + }, + }) + } + + 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() + }) +}) diff --git a/admin/src/components/input/EditableGroup.vue b/admin/src/components/input/EditableGroup.vue new file mode 100644 index 000000000..6a998f542 --- /dev/null +++ b/admin/src/components/input/EditableGroup.vue @@ -0,0 +1,62 @@ + + + diff --git a/admin/src/components/input/EditableGroupableLabel.spec.js b/admin/src/components/input/EditableGroupableLabel.spec.js new file mode 100644 index 000000000..71e7795f0 --- /dev/null +++ b/admin/src/components/input/EditableGroupableLabel.spec.js @@ -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() + }) +}) diff --git a/admin/src/components/input/EditableGroupableLabel.vue b/admin/src/components/input/EditableGroupableLabel.vue new file mode 100644 index 000000000..606e46363 --- /dev/null +++ b/admin/src/components/input/EditableGroupableLabel.vue @@ -0,0 +1,47 @@ + + + diff --git a/admin/src/components/input/EditableLabel.spec.js b/admin/src/components/input/EditableLabel.spec.js deleted file mode 100644 index 0b2462b30..000000000 --- a/admin/src/components/input/EditableLabel.spec.js +++ /dev/null @@ -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') - }) -}) diff --git a/admin/src/components/input/EditableLabel.vue b/admin/src/components/input/EditableLabel.vue deleted file mode 100644 index 674fcd3e7..000000000 --- a/admin/src/components/input/EditableLabel.vue +++ /dev/null @@ -1,64 +0,0 @@ - - - diff --git a/admin/src/graphql/allCommunities.js b/admin/src/graphql/allCommunities.js index 6379086c7..c2ccdd15e 100644 --- a/admin/src/graphql/allCommunities.js +++ b/admin/src/graphql/allCommunities.js @@ -11,6 +11,7 @@ export const allCommunities = gql` name description gmsApiKey + location creationDate createdAt updatedAt diff --git a/admin/src/graphql/updateHomeCommunity.js b/admin/src/graphql/updateHomeCommunity.js index a43d6edd2..a05cb7fd8 100644 --- a/admin/src/graphql/updateHomeCommunity.js +++ b/admin/src/graphql/updateHomeCommunity.js @@ -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 } } diff --git a/admin/src/locales/de.json b/admin/src/locales/de.json index 9f808f70c..22bb1d614 100644 --- a/admin/src/locales/de.json +++ b/admin/src/locales/de.json @@ -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": "|", diff --git a/admin/src/locales/en.json b/admin/src/locales/en.json index b1f020921..c8c45a7a0 100644 --- a/admin/src/locales/en.json +++ b/admin/src/locales/en.json @@ -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": "|", diff --git a/backend/package.json b/backend/package.json index 7fcd21000..ba45225a2 100644 --- a/backend/package.json +++ b/backend/package.json @@ -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", diff --git a/backend/src/config/index.ts b/backend/src/config/index.ts index 37818d4bd..d66f729db 100644 --- a/backend/src/config/index.ts +++ b/backend/src/config/index.ts @@ -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 diff --git a/backend/src/graphql/input/EditCommunityInput.ts b/backend/src/graphql/input/EditCommunityInput.ts index 8c74f874b..487221920 100644 --- a/backend/src/graphql/input/EditCommunityInput.ts +++ b/backend/src/graphql/input/EditCommunityInput.ts @@ -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 } diff --git a/backend/src/graphql/model/AdminCommunityView.ts b/backend/src/graphql/model/AdminCommunityView.ts index 95af54bc5..b4a1664a7 100644 --- a/backend/src/graphql/model/AdminCommunityView.ts +++ b/backend/src/graphql/model/AdminCommunityView.ts @@ -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 diff --git a/backend/src/graphql/resolver/CommunityResolver.ts b/backend/src/graphql/resolver/CommunityResolver.ts index 2d2323865..8a6d2992b 100644 --- a/backend/src/graphql/resolver/CommunityResolver.ts +++ b/backend/src/graphql/resolver/CommunityResolver.ts @@ -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 { + async updateHomeCommunity( + @Args() { uuid, gmsApiKey, location }: EditCommunityInput, + ): Promise { 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) diff --git a/backend/src/graphql/scalar/Point.ts b/backend/src/graphql/scalar/Point.ts deleted file mode 100644 index 06af56bfc..000000000 --- a/backend/src/graphql/scalar/Point.ts +++ /dev/null @@ -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 - }, -}) diff --git a/backend/yarn.lock b/backend/yarn.lock index 26f861772..5c33b1ce8 100644 --- a/backend/yarn.lock +++ b/backend/yarn.lock @@ -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" diff --git a/database/entity/0086-add_community_location/Community.ts b/database/entity/0086-add_community_location/Community.ts new file mode 100644 index 000000000..60410c8ce --- /dev/null +++ b/database/entity/0086-add_community_location/Community.ts @@ -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[] +} diff --git a/database/entity/Community.ts b/database/entity/Community.ts index ccc752cd8..9495cc2b6 100644 --- a/database/entity/Community.ts +++ b/database/entity/Community.ts @@ -1 +1 @@ -export { Community } from './0083-join_community_federated_communities/Community' +export { Community } from './0086-add_community_location/Community' diff --git a/database/migrations/0086-add_community_location.ts b/database/migrations/0086-add_community_location.ts new file mode 100644 index 000000000..b6f0ea917 --- /dev/null +++ b/database/migrations/0086-add_community_location.ts @@ -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>) { + 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>) { + await queryFn('ALTER TABLE `communities` DROP COLUMN IF EXISTS `location`;') +} diff --git a/dht-node/src/config/index.ts b/dht-node/src/config/index.ts index 5c6910d45..8bf59c465 100644 --- a/dht-node/src/config/index.ts +++ b/dht-node/src/config/index.ts @@ -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', diff --git a/dht-node/yarn.lock b/dht-node/yarn.lock index 5f42f71a7..4bf9fa916 100644 --- a/dht-node/yarn.lock +++ b/dht-node/yarn.lock @@ -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" diff --git a/dlt-database/migrations/0004-fix_spelling.ts b/dlt-database/migrations/0004-fix_spelling.ts index 3b2153a7d..1507ab590 100644 --- a/dlt-database/migrations/0004-fix_spelling.ts +++ b/dlt-database/migrations/0004-fix_spelling.ts @@ -1,7 +1,7 @@ export async function upgrade(queryFn: (query: string, values?: any[]) => Promise>) { 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>) { await queryFn(` ALTER TABLE \`transactions\` - RENAME COLUMN \`pairing_transaction_id\` TO \`paring_transaction_id\`, + RENAME COLUMN \`pairing_transaction_id\` TO \`paring_transaction_id\` ; `) } diff --git a/federation/src/config/index.ts b/federation/src/config/index.ts index b66ed3974..0cbebc733 100644 --- a/federation/src/config/index.ts +++ b/federation/src/config/index.ts @@ -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