diff --git a/service/src/adapters/settings/adapters.settings.controllers.web.ts b/service/src/adapters/settings/adapters.settings.controllers.web.ts new file mode 100644 index 000000000..b8f049d69 --- /dev/null +++ b/service/src/adapters/settings/adapters.settings.controllers.web.ts @@ -0,0 +1,83 @@ +import express from 'express' +import { WebAppRequestFactory } from '../adapters.controllers.web' +import { GetSettingsServices, UpdateMapSettingsRequest, UpdateSettingsServices } from '../../app.api/settings/app.api.settings' +import { MobileSearchType, WebSearchType } from '../../entities/settings/entities.settings' +import { URL } from 'url' + +export interface SettingsAppLayer { + getMapSettings: GetSettingsServices, + updateMapSettings: UpdateSettingsServices +} + +function validateUrl(url: string): boolean { + try { + new URL(url) + return true + } catch (e) { return false } +} + +export function SettingsRoutes(app: SettingsAppLayer, createAppRequest: WebAppRequestFactory) { + const routes = express.Router() + routes.use(express.json()) + + routes.route('/map') + .get(async (req, res) => { + const appReq = createAppRequest(req) + const appRes = await app.getMapSettings(appReq) + if (appRes.success) { + return res.json(appRes.success) + } else { + return res.sendStatus(404) + } + }) + + routes.route('/map') + .post(async (req, res) => { + const { + webSearchType: webSearchTypeParameter, + mobileSearchType: mobileSearchTypeParameter, + webNominatimUrl, + mobileNominatimUrl + } = req.body + + const webSearchType = WebSearchType[webSearchTypeParameter as keyof typeof WebSearchType] + if (!webSearchType) { + return res.status(400).json({ message: 'Web search option is required.' }) + } + + const mobileSearchType = MobileSearchType[mobileSearchTypeParameter as keyof typeof MobileSearchType] + if (!mobileSearchType) { + return res.status(400).json({ message: 'Mobile search option is required.' }) + } + + if (webSearchType === WebSearchType.NOMINATIM) { + if (!validateUrl(webNominatimUrl)) { + return res.status(400).json({ message: 'Web Nominatim URL is required and must be a valid URL.' }) + } + } + + if (mobileSearchType === MobileSearchType.NOMINATIM) { + if (!validateUrl(mobileNominatimUrl)) { + return res.status(400).json({ message: 'Mobile Nominatim URL is required and must be a vaild URL.' }) + } + } + + const settings: UpdateMapSettingsRequest['settings'] = { + webSearchType: webSearchType, + webNominatimUrl: req.body.webNominatimUrl, + mobileSearchType: mobileSearchType, + mobileNominatimUrl: req.body.mobileNominatimUrl + } + + const appReq: UpdateMapSettingsRequest = createAppRequest(req, { settings }) + + const appRes = await app.updateMapSettings(appReq) + if (appRes.success) { + return res.json(appRes.success) + } else { + return res.sendStatus(400) + } + }) + + return routes +} \ No newline at end of file diff --git a/service/src/adapters/settings/adapters.settings.db.mongoose.ts b/service/src/adapters/settings/adapters.settings.db.mongoose.ts new file mode 100644 index 000000000..a7cbd7b19 --- /dev/null +++ b/service/src/adapters/settings/adapters.settings.db.mongoose.ts @@ -0,0 +1,33 @@ +import { BaseMongooseRepository } from '../base/adapters.base.db.mongoose' +import * as legacy from '../../models/setting' +import _ from 'lodash' +import mongoose from 'mongoose' +import { MapSettings, SettingRepository } from '../../entities/settings/entities.settings' + +export type SettingsDocument = legacy.SettingsDocument +export type SettingsModel = mongoose.Model +export const SettingsSchema = legacy.Model.schema + +export class MongooseSettingsRepository extends BaseMongooseRepository implements SettingRepository { + + constructor(model: mongoose.Model) { + super(model, { + docToEntity: doc => { + const json = doc.toJSON() + return { + ...json + } + } + }) + } + + async getMapSettings(): Promise { + const document = await this.model.findOne({ type: 'map' }) + return document?.settings + } + + async updateMapSettings(settings: MapSettings): Promise { + const document = await this.model.findOneAndUpdate({ type: 'map' }, { settings }, { new: true, upsert: true }) + return document?.settings + } +} \ No newline at end of file diff --git a/service/src/app.api/settings/app.api.settings.ts b/service/src/app.api/settings/app.api.settings.ts new file mode 100644 index 000000000..4f861a9f3 --- /dev/null +++ b/service/src/app.api/settings/app.api.settings.ts @@ -0,0 +1,20 @@ +import { MapSettings } from '../../entities/settings/entities.settings' +import { PermissionDeniedError } from '../app.api.errors' +import { AppRequest, AppRequestContext, AppResponse } from '../app.api.global' + +export interface UpdateMapSettingsRequest extends AppRequest { + settings: MapSettings +} + +export interface GetSettingsServices { + (req: AppRequest): Promise> +} + +export interface UpdateSettingsServices { + (req: UpdateMapSettingsRequest): Promise> +} + +export interface SettingsPermissionService { + ensureFetchMapSettingsPermissionFor(context: AppRequestContext): Promise + ensureUpdateMapSettingsPermissionFor(context: AppRequestContext): Promise +} diff --git a/service/src/app.impl/settings/app.impl.settings.ts b/service/src/app.impl/settings/app.impl.settings.ts new file mode 100644 index 000000000..646239ab4 --- /dev/null +++ b/service/src/app.impl/settings/app.impl.settings.ts @@ -0,0 +1,26 @@ +import * as api from '../../app.api/settings/app.api.settings' +import { AppRequest, KnownErrorsOf, withPermission } from '../../app.api/app.api.global' +import { MapSettings, SettingRepository } from '../../entities/settings/entities.settings' +import { UpdateMapSettingsRequest } from '../../app.api/settings/app.api.settings' + +export function FetchMapSettings(settingRepo: SettingRepository, permissionService: api.SettingsPermissionService): api.GetSettingsServices { + return async function getMapSettings(req: AppRequest): ReturnType { + return await withPermission>( + permissionService.ensureFetchMapSettingsPermissionFor(req.context), + async (): Promise => { + return await settingRepo.getMapSettings() + } + ) + } +} + +export function UpdateMapSettings(settingRepo: SettingRepository, permissionService: api.SettingsPermissionService): api.UpdateSettingsServices { + return async function updateMapSettings(req: UpdateMapSettingsRequest): ReturnType { + return await withPermission>( + permissionService.ensureUpdateMapSettingsPermissionFor(req.context), + async (): Promise => { + return await settingRepo.updateMapSettings(req.settings) + } + ) + } +} \ No newline at end of file diff --git a/service/src/app.ts b/service/src/app.ts index ca0c3b710..77f4adb0f 100644 --- a/service/src/app.ts +++ b/service/src/app.ts @@ -56,6 +56,11 @@ import { FileSystemAttachmentStoreInitError, intializeAttachmentStore } from './ import { AttachmentStoreToken, ObservationRepositoryToken } from './plugins.api/plugins.api.observations' import { GetDbConnection, MongooseDbConnectionToken } from './plugins.api/plugins.api.db' import { EventEmitter } from 'events' +import { SettingsAppLayer, SettingsRoutes } from './adapters/settings/adapters.settings.controllers.web' +import { MongooseSettingsRepository, SettingsModel } from './adapters/settings/adapters.settings.db.mongoose' +import { FetchMapSettings, UpdateMapSettings } from './app.impl/settings/app.impl.settings' +import { RoleBasedMapPermissionService } from './permissions/permissions.settings' +import { SettingRepository } from './entities/settings/entities.settings' export interface MageService { @@ -217,6 +222,9 @@ type DatabaseLayer = { }, users: { user: UserModel + }, + settings: { + setting: SettingsModel } } @@ -252,7 +260,8 @@ type AppLayer = { deleteFeed: feedsApi.DeleteFeed }, icons: StaticIconsAppLayer, - users: UsersAppLayer + users: UsersAppLayer, + settings: SettingsAppLayer } async function initDatabase(): Promise { @@ -296,6 +305,9 @@ async function initDatabase(): Promise { }, users: { user: require('./models/user').Model + }, + settings: { + setting: require('./models/setting').Model } } } @@ -318,6 +330,9 @@ type Repositories = { }, users: { userRepo: UserRepository + }, + settings: { + settingRepo: SettingRepository } } @@ -346,6 +361,7 @@ async function initRepositories(models: DatabaseLayer, config: BootConfig): Prom new FileSystemIconContentStore(), [ new PluginUrlScheme(config.plugins?.servicePlugins || []) ]) const userRepo = new MongooseUserRepository(models.users.user) + const settingRepo = new MongooseSettingsRepository(models.settings.setting) const attachmentStore = await intializeAttachmentStore(environment.attachmentBaseDirectory) if (attachmentStore instanceof FileSystemAttachmentStoreInitError) { throw attachmentStore @@ -366,6 +382,9 @@ async function initRepositories(models: DatabaseLayer, config: BootConfig): Prom }, users: { userRepo + }, + settings: { + settingRepo } } } @@ -376,12 +395,14 @@ async function initAppLayer(repos: Repositories): Promise { const icons = await initIconsAppLayer(repos) const feeds = await initFeedsAppLayer(repos) const users = await initUsersAppLayer(repos) + const settings = await initSettingsAppLayer(repos) return { events, observations, feeds, icons, users, + settings } } @@ -463,6 +484,16 @@ function initFeedsAppLayer(repos: Repositories): AppLayer['feeds'] { } } +async function initSettingsAppLayer(repos: Repositories): Promise { + const mapPermissions = new RoleBasedMapPermissionService() + const getMapSettings = FetchMapSettings(repos.settings.settingRepo, mapPermissions) + const updateMapSettings = UpdateMapSettings(repos.settings.settingRepo, mapPermissions) + return { + getMapSettings, + updateMapSettings + } +} + interface MageEventRequestContext extends AppRequestContext { event: MageEventDocument | MageEvent | undefined } @@ -483,6 +514,13 @@ async function initWebLayer(repos: Repositories, app: AppLayer, webUIPlugins: st } } const bearerAuth = webAuth.passport.authenticate('bearer') + + const settingsRoutes = SettingsRoutes(app.settings, appRequestFactory) + webController.use('/api/settings', [ + bearerAuth, + settingsRoutes + ]) + const usersRoutes = UsersRoutes(app.users, appRequestFactory) /* TODO: cannot mount at /api/users/search because the /api/users/:userId route diff --git a/service/src/entities/authorization/entities.permissions.ts b/service/src/entities/authorization/entities.permissions.ts index a1fea67c4..f0f73a1bf 100644 --- a/service/src/entities/authorization/entities.permissions.ts +++ b/service/src/entities/authorization/entities.permissions.ts @@ -72,7 +72,9 @@ export enum TeamPermission { export enum SettingPermission { READ_SETTINGS = 'READ_SETTINGS', - UPDATE_SETTINGS = 'UPDATE_SETTINGS' + UPDATE_SETTINGS = 'UPDATE_SETTINGS', + MAP_SETTINGS_READ = 'MAP_SETTINGS_READ', + MAP_SETTINGS_UPDATE = 'MAP_SETTINGS_UPDATE', } export enum FeedsPermission { diff --git a/service/src/entities/settings/entities.settings.ts b/service/src/entities/settings/entities.settings.ts new file mode 100644 index 000000000..295621982 --- /dev/null +++ b/service/src/entities/settings/entities.settings.ts @@ -0,0 +1,22 @@ +export enum WebSearchType { + NONE = "NONE", + NOMINATIM = "NOMINATIM" +} + +export enum MobileSearchType { + NONE = "NONE", + NATIVE = "NATIVE", + NOMINATIM = "NOMINATIM" +} + +export interface MapSettings { + webSearchType: WebSearchType + webNominatimUrl: string | null + mobileSearchType: MobileSearchType + mobileNominatimUrl: string | null +} + +export interface SettingRepository { + getMapSettings(): Promise + updateMapSettings(settings: MapSettings): Promise +} \ No newline at end of file diff --git a/service/src/migrations/030-map-search-settings.js b/service/src/migrations/030-map-search-settings.js new file mode 100644 index 000000000..1734320ae --- /dev/null +++ b/service/src/migrations/030-map-search-settings.js @@ -0,0 +1,40 @@ +exports.id = 'map-search-settings'; + +exports.up = async function (done) { + + const roles = this.db.collection('roles'); + try { + await roles.update({name: 'USER_ROLE'}, { $push: { permissions: 'MAP_SETTINGS_READ' } }); + } catch (e) { done(e) } + + try { + await roles.update({ name: 'USER_NO_EDIT_ROLE' }, { $push: { permissions: 'MAP_SETTINGS_READ' } }); + } catch (e) { done(e) } + + + try { + await roles.update({ name: 'EVENT_MANAGER_ROLE' }, { $push: { permissions: 'MAP_SETTINGS_READ' } }); + } catch (e) { done(e) } + + try { + await roles.update({ name: 'ADMIN_ROLE' }, { $push: { permissions: { $each: ['MAP_SETTINGS_READ', 'MAP_SETTINGS_UPDATE'] } } }); + } catch (e) { done(e) } + + const settings = this.db.collection('settings'); + try { + await settings.insertOne({ + type: 'map', + settings: { + webSearchType: "NOMINATIM", + webNominatimUrl: "https://nominatim.openstreetmap.org", + mobileSearchType: "NATIVE", + } + }); + } catch (e) { done(e) } + + done(); +}; + +exports.down = function (done) { + done(); +}; \ No newline at end of file diff --git a/service/src/models/setting.d.ts b/service/src/models/setting.d.ts new file mode 100644 index 000000000..64a2d6f26 --- /dev/null +++ b/service/src/models/setting.d.ts @@ -0,0 +1,9 @@ +import mongoose from 'mongoose' + +export interface SettingsDocument extends mongoose.Document { + _id: mongoose.Types.ObjectId + type: string + settings: any +} + +export declare const Model: mongoose.Model diff --git a/service/src/models/setting.js b/service/src/models/setting.js index 27e7d9eb8..c5d8dcc62 100644 --- a/service/src/models/setting.js +++ b/service/src/models/setting.js @@ -25,6 +25,7 @@ SettingSchema.set("toJSON", { // Creates the Model for the Setting Schema const Setting = mongoose.model('Setting', SettingSchema); +exports.Model = Setting; exports.getSettings = function () { return Setting.find({}).exec(); diff --git a/service/src/permissions/permissions.role-based.base.ts b/service/src/permissions/permissions.role-based.base.ts index 5a8cfa3d0..57c545ef1 100644 --- a/service/src/permissions/permissions.role-based.base.ts +++ b/service/src/permissions/permissions.role-based.base.ts @@ -19,5 +19,6 @@ export function ensureContextUserHasPermission(context: AppRequestContext): Promise { + return ensureContextUserHasPermission(context, SettingPermission.MAP_SETTINGS_READ) + } + + async ensureUpdateMapSettingsPermissionFor(context: AppRequestContext): Promise { + return ensureContextUserHasPermission(context, SettingPermission.MAP_SETTINGS_UPDATE) + } +} \ No newline at end of file diff --git a/service/src/routes/index.js b/service/src/routes/index.js index 82e8e10d5..f79eb59f6 100644 --- a/service/src/routes/index.js +++ b/service/src/routes/index.js @@ -15,9 +15,12 @@ module.exports = function(app, security) { const { modulesPathsInDir } = require('../utilities/loader'); app.get('/api', function (req, res, next) { + console.log('*********************** get api ') + async.parallel({ initial: function (done) { User.count(function (err, count) { + console.log('*********************** user count is ', count) done(err, count === 0); }); }, diff --git a/service/test/adapters/settings/adapters.settings.controllers.web.test.ts b/service/test/adapters/settings/adapters.settings.controllers.web.test.ts new file mode 100644 index 000000000..03064db9d --- /dev/null +++ b/service/test/adapters/settings/adapters.settings.controllers.web.test.ts @@ -0,0 +1,180 @@ +import { beforeEach } from 'mocha' +import express from 'express' +import { expect } from 'chai' +import supertest from 'supertest' +import { Substitute as Sub, SubstituteOf, Arg } from '@fluffy-spoon/substitute' +import _ from 'lodash' +import { AppResponse, AppRequest } from '../../../lib/app.api/app.api.global' +import { WebAppRequestFactory } from '../../../lib/adapters/adapters.controllers.web' +import { SettingsAppLayer, SettingsRoutes } from '../../../lib/adapters/settings/adapters.settings.controllers.web' +import { MobileSearchType, WebSearchType } from '../../../lib/entities/settings/entities.settings' +import { UpdateMapSettingsRequest } from '../../../src/app.api/settings/app.api.settings' + +const rootPath = '/test/settings' +const jsonMimeType = /^application\/json/ +const testUser = 'lummytin' + +describe('settings web controller', function () { + + let createAppRequest: WebAppRequestFactory =

(_webReq: express.Request, params?: P): AppRequest & P => { + return { + context: { + requestToken: Symbol(), + requestingPrincipal(): typeof testUser { + return testUser + } + }, + ...(params || {}) + } as AppRequest & P + } + let settingsRoutes: express.Router + let app: express.Application + let settingsApp: SubstituteOf + let client: supertest.SuperTest + + beforeEach(function () { + settingsApp = Sub.for() + settingsRoutes = SettingsRoutes(settingsApp, createAppRequest) + app = express() + app.use(rootPath, settingsRoutes) + client = supertest(app) + }) + + + describe('POST /settings/map', function () { + + it('creates new map settings', async function () { + const mapSettings = { + webSearchType: WebSearchType.NOMINATIM, + webNominatimUrl: "https://test.com", + mobileSearchType: MobileSearchType.NATIVE, + mobileNominatimUrl: "https://test.com" + } + + const settings = { settings: mapSettings } + + settingsApp.updateMapSettings(Arg.is(x => _.isMatch(x, settings))) + .resolves(AppResponse.success(mapSettings)) + + const res = await client + .post(`${rootPath}/map`) + .type('json') + .send({ + webSearchType: WebSearchType.NOMINATIM, + webNominatimUrl: "https://test.com", + mobileSearchType: MobileSearchType.NATIVE, + mobileNominatimUrl: "https://test.com" + }) + + expect(res.status).to.equal(200) + expect(res.type).to.match(jsonMimeType) + expect(res.body).to.deep.equal(mapSettings) + }) + + it('fails to create new map settings with missing webNominatimUrl', async function () { + const res = await client + .post(`${rootPath}/map`) + .type('json') + .send({ + webSearchType: WebSearchType.NOMINATIM, + mobileSearchType: MobileSearchType.NOMINATIM, + mobileNominatimUrl: "https://test.com" + }) + + expect(res.status).to.equal(400) + }) + + it('fails to create new map settings with missing mobileNominatimUrl', async function () { + const res = await client + .post(`${rootPath}/map`) + .type('json') + .send({ + webSearchType: WebSearchType.NOMINATIM, + webNominatimUrl: "https://test.com", + mobileSearchType: MobileSearchType.NOMINATIM, + }) + + expect(res.status).to.equal(400) + }) + + it('fails to create new map settings with invalid webNominatimUrl', async function () { + const res = await client + .post(`${rootPath}/map`) + .type('json') + .send({ + webSearchType: WebSearchType.NOMINATIM, + webNominatimUrl: "invalid", + mobileSearchType: MobileSearchType.NOMINATIM, + mobileNominatimUrl: "https://test.com" + }) + + expect(res.status).to.equal(400) + expect(res.body.message).to.deep.equal('Web Nominatim URL is required and must be a valid URL.') + }) + + it('fails to create new map settings with invalid mobileNominatimUrl', async function () { + const res = await client + .post(`${rootPath}/map`) + .type('json') + .send({ + webSearchType: WebSearchType.NOMINATIM, + webNominatimUrl: "https://test.com", + mobileSearchType: MobileSearchType.NOMINATIM, + mobileNominatimUrl: "invalid" + }) + + expect(res.status).to.equal(400) + expect(res.body.message).to.deep.equal('Mobile Nominatim URL is required and must be a vaild URL.') + }) + + it('fails to create new map settings on missing webSearchType parameter', async function () { + const res = await client + .post(`${rootPath}/map`) + .type('json') + .send({ + mobileSearchType: MobileSearchType.NONE + }) + + expect(res.status).to.equal(400) + expect(res.body.message).to.deep.equal('Web search option is required.') + }) + + it('fails to create new map settings on missing mobileSearchType parameter', async function () { + const res = await client + .post(`${rootPath}/map`) + .type('json') + .send({ + webSearchType: WebSearchType.NONE + }) + + expect(res.status).to.equal(400) + expect(res.body.message).to.deep.equal('Mobile search option is required.') + }) + + it('fails to create new map settings on invalid webSearchType parameter', async function () { + const res = await client + .post(`${rootPath}/map`) + .type('json') + .send({ + webSearchType: "invalid", + mobileSearchType: MobileSearchType.NONE + }) + + expect(res.status).to.equal(400) + expect(res.body.message).to.deep.equal('Web search option is required.') + }) + + it('fails to create new map settings on invalid mobileSearchType parameter', async function () { + const res = await client + .post(`${rootPath}/map`) + .type('json') + .send({ + webSearchType: WebSearchType.NONE, + mobileSearchType: "invalid" + }) + + expect(res.status).to.equal(400) + expect(res.body.message).to.deep.equal('Mobile search option is required.') + }) + }) +}) \ No newline at end of file diff --git a/service/test/adapters/settings/adapters.settings.db.mongoose.test.ts b/service/test/adapters/settings/adapters.settings.db.mongoose.test.ts new file mode 100644 index 000000000..8c0254166 --- /dev/null +++ b/service/test/adapters/settings/adapters.settings.db.mongoose.test.ts @@ -0,0 +1,58 @@ +import { describe, it } from 'mocha' +import { expect } from 'chai' +import mongoose from 'mongoose' +import _ from 'lodash' +import * as legacy from '../../../lib/models/setting' +import { MongooseSettingsRepository, SettingsDocument, SettingsModel } from '../../../lib/adapters/settings/adapters.settings.db.mongoose' +import { MapSettings, MobileSearchType, WebSearchType } from '../../../lib/entities/settings/entities.settings' + +describe('settings mongoose repository', function() { + + let model: SettingsModel + let repo: MongooseSettingsRepository + + before(async function () { + model = legacy.Model as mongoose.Model + repo = new MongooseSettingsRepository(model) + }) + + afterEach(async function() { + await model.remove({}) + }) + + describe('finding map settings', function() { + + const mapSettings = { + webSearchType: WebSearchType.NONE, + webNominatimUrl: "web url", + mobileSearchType: MobileSearchType.NOMINATIM, + mobileNominatimUrl: "mobile url" + } + + beforeEach('create map settings', async function () { + await model.update({ type: 'map' }, {settings: mapSettings }, { upsert: true}) + }) + + it('looks up map settings by type', async function() { + const fetched = await repo.getMapSettings() + expect(fetched).to.deep.equal(mapSettings) + }) + + }) + + describe('updating map settings', function () { + + const mapSettings: MapSettings = { + webSearchType: WebSearchType.NOMINATIM, + webNominatimUrl: "web url", + mobileSearchType: MobileSearchType.NOMINATIM, + mobileNominatimUrl: "mobile url" + } + + it('updates map settings by type', async function () { + const fetched = await repo.updateMapSettings(mapSettings) + expect(fetched).to.deep.equal(mapSettings) + }) + + }) +}) \ No newline at end of file diff --git a/web-app/package-lock.json b/web-app/package-lock.json index 009bfa7c6..57f632ee7 100644 --- a/web-app/package-lock.json +++ b/web-app/package-lock.json @@ -3164,8 +3164,7 @@ "@types/geojson": { "version": "7946.0.7", "resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.7.tgz", - "integrity": "sha512-wE2v81i4C4Ol09RtsWFAqg3BUitWbHSpSlIo+bNdsCJijO9sjme+zm+73ZMCa/qMC8UEERxzGbvmr1cffo2SiQ==", - "dev": true + "integrity": "sha512-wE2v81i4C4Ol09RtsWFAqg3BUitWbHSpSlIo+bNdsCJijO9sjme+zm+73ZMCa/qMC8UEERxzGbvmr1cffo2SiQ==" }, "@types/glob": { "version": "7.2.0", diff --git a/web-app/package.json b/web-app/package.json index 193061972..f68d0c06f 100644 --- a/web-app/package.json +++ b/web-app/package.json @@ -68,6 +68,7 @@ "@turf/center": "4.3.0", "@turf/helpers": "4.3.0", "@turf/kinks": "4.3.0", + "@types/geojson": "^7946.0.7", "@uirouter/angular": "6.0.1", "@uirouter/angular-hybrid": "^10.0.1", "@uirouter/angularjs": "1.0.24", diff --git a/web-app/src/app/admin/admin-map/admin-map.component.html b/web-app/src/app/admin/admin-map/admin-map.component.html new file mode 100644 index 000000000..5d91a17bc --- /dev/null +++ b/web-app/src/app/admin/admin-map/admin-map.component.html @@ -0,0 +1,57 @@ +

+ + +
+
+ + + search + Location Search + + + +
+
Mobile
+
Configure how users search for places on MAGE mobile.
+ + + + {{mobileSearchOption}} + + + +
+ + Nominatim URL + + +
+
+ +
+
Web
+
Configure how users search for places on MAGE web.
+ + + + {{webSearchOption}} + + + +
+ + Nominatim URL + + +
+
+ + +
+
+
+
+
\ No newline at end of file diff --git a/web-app/src/app/admin/admin-map/admin-map.component.scss b/web-app/src/app/admin/admin-map/admin-map.component.scss new file mode 100644 index 000000000..0a54a7366 --- /dev/null +++ b/web-app/src/app/admin/admin-map/admin-map.component.scss @@ -0,0 +1,65 @@ +@import '~@angular/material/theming'; +@import "variables.scss"; + +:host ::ng-deep label { + font-weight: unset; +} + +.page { + position: absolute; + top: 0; + bottom: 0; + right: 0; + left: 0; + overflow-y: auto; + background-color: #F0F0F0; +} + +.content { + display: block; + margin: 16px; +} + +.platform {} + +.platform__label { + font: 500 20px/32px Roboto, "Helvetica Neue", sans-serif; + letter-spacing: normal; + margin: 0 0 16px; +} + +.platform__description { + color: rgba(0, 0, 0, .6); +} + +.radio-group { + display: flex; + flex-direction: column; + margin: 15px 0; +} + +.radio-button { + margin: 5px; +} + +mat-card-header { + color: white; + background-color: mat-color($app-primary); + position: relative; + padding: 16px 16px 0; + margin-bottom: 8px; +} + +mat-card { + padding: 0; + overflow: hidden; +} + +mat-card-content { + padding: 16px; +} + +mat-form-field { + width: 600px; +} + diff --git a/web-app/src/app/admin/admin-map/admin-map.component.spec.ts b/web-app/src/app/admin/admin-map/admin-map.component.spec.ts new file mode 100644 index 000000000..5c0598dff --- /dev/null +++ b/web-app/src/app/admin/admin-map/admin-map.component.spec.ts @@ -0,0 +1,54 @@ +import { async, ComponentFixture, TestBed } from '@angular/core/testing'; + +import { AdminMapComponent } from './admin-map.component'; +import { Component, ViewChild } from '@angular/core'; +import { MatInput, MatInputModule } from '@angular/material/input'; +import { By } from '@angular/platform-browser'; +import { HttpClientTestingModule } from '@angular/common/http/testing'; +import { MatSnackBarModule } from '@angular/material/snack-bar'; +import { NoopAnimationsModule } from '@angular/platform-browser/animations'; + +@Component({ + selector: `host-component`, + template: `` +}) +class TestHostComponent { + @ViewChild(AdminMapComponent) component: AdminMapComponent; +} + +describe('AdminMapComponent', () => { + let component: AdminMapComponent; + let hostComponent: TestHostComponent + let fixture: ComponentFixture; + + beforeEach(async(() => { + TestBed.configureTestingModule({ + imports: [MatInputModule, MatSnackBarModule, HttpClientTestingModule, NoopAnimationsModule], + declarations: [AdminMapComponent, TestHostComponent], + schemas: [] + }) + .compileComponents() + })); + + beforeEach(() => { + fixture = TestBed.createComponent(TestHostComponent) + hostComponent = fixture.componentInstance + fixture.detectChanges(); + component = hostComponent.component + }) + + + it('should create', () => { + expect(component).toBeTruthy(); + }); + + it('should show error on invalid and touched', async () => { + component.webSearchType = 'NOMINATIM' + + fixture.detectChanges() + await fixture.whenStable() + + const webNominatimUrlInput = fixture.debugElement.query(By.directive(MatInput)) + expect(webNominatimUrlInput).not.toBeNull() + }) +}); diff --git a/web-app/src/app/admin/admin-map/admin-map.component.ts b/web-app/src/app/admin/admin-map/admin-map.component.ts new file mode 100644 index 000000000..687a6d9a1 --- /dev/null +++ b/web-app/src/app/admin/admin-map/admin-map.component.ts @@ -0,0 +1,67 @@ +import { Component, OnInit } from '@angular/core'; +import { AdminBreadcrumb } from '../admin-breadcrumb/admin-breadcrumb.model' +import { MapSettingsService } from 'src/app/map/settings/map.settings.service'; +import { MatSnackBar } from '@angular/material/snack-bar'; +import { MapSettings } from 'src/app/entities/map/entities.map'; + +@Component({ + selector: 'mage-admin-map', + templateUrl: './admin-map.component.html', + styleUrls: ['./admin-map.component.scss'] +}) +export class AdminMapComponent implements OnInit { + + readonly breadcrumbs: AdminBreadcrumb[] = [{ + title: 'Map', + icon: 'public' + }] + + mobileSearchType: "NONE" | "NATIVE" | "NOMINATIM" + mobileSearchOptions: string[] = ['NONE', 'NATIVE', 'NOMINATIM'] + + webSearchType: "NONE" | "NOMINATIM" + webSearchOptions: string[] = ['NONE', 'NOMINATIM'] + + webNominatimUrl = '' + mobileNominatimUrl = '' + + constructor( + private mapSettingsService: MapSettingsService, + private snackBar: MatSnackBar + ) { + this.mapSettingsService.getMapSettings().subscribe((settings: MapSettings) => { + this.webSearchType = settings.webSearchType + this.webNominatimUrl = settings.webNominatimUrl + this.mobileSearchType = settings.mobileSearchType + this.mobileNominatimUrl = settings.mobileNominatimUrl + }); + } + + ngOnInit(): void {} + + save(): void { + const settings: any = { + webSearchType: this.webSearchType, + mobileSearchType: this.mobileSearchType + } + + if (this.webSearchType == "NOMINATIM") { + settings.webNominatimUrl = this.webNominatimUrl + } + + if (this.mobileSearchType == "NOMINATIM") { + settings.mobileNominatimUrl = this.mobileNominatimUrl + } + + this.mapSettingsService.updateMapSettings(settings).subscribe(() => { + this.snackBar.open("Map settings saved", null, { + duration: 2000, + }) + }, (response) => { + const message = response?.error?.message || "Error saving map settings" + this.snackBar.open(message, null, { + duration: 2000, + }) + }) + } +} diff --git a/web-app/src/app/app.module.ts b/web-app/src/app/app.module.ts index d4bf899de..b23de4bed 100644 --- a/web-app/src/app/app.module.ts +++ b/web-app/src/app/app.module.ts @@ -176,6 +176,8 @@ import { ExportDataComponent } from './export/export-data/export-data.component' import { NoExportsComponent } from './export/empty-state/no-exports.component'; import { AdminEventFormPreviewComponent } from './admin/admin-event/admin-event-form/admin-event-form-preview/admin-event-form-preview.component'; import { AdminEventFormPreviewDialogComponent } from './admin/admin-event/admin-event-form/admin-event-form-preview/admin-event-form-preview-dialog.component'; +import { AdminMapComponent } from './admin/admin-map/admin-map.component'; + @NgModule({ declarations: [ @@ -269,7 +271,8 @@ import { AdminEventFormPreviewDialogComponent } from './admin/admin-event/admin- AdminAuthenticationSettingsComponent, AdminSettingsUnsavedComponent, AdminEventFormPreviewComponent, - AdminEventFormPreviewDialogComponent + AdminEventFormPreviewDialogComponent, + AdminMapComponent ], imports: [ CommonModule, diff --git a/web-app/src/app/color-picker/color-picker.component.spec.ts b/web-app/src/app/color-picker/color-picker.component.spec.ts index 7efc45da6..4cd1c5b7c 100644 --- a/web-app/src/app/color-picker/color-picker.component.spec.ts +++ b/web-app/src/app/color-picker/color-picker.component.spec.ts @@ -43,6 +43,7 @@ describe('ColorPickerComponent', () => { expect(component).toBeTruthy(); }); + it('should show color picker on open', () => { component.open(); expect(component.showColorPicker).toEqual(true); diff --git a/web-app/src/app/entities/map/entities.map.ts b/web-app/src/app/entities/map/entities.map.ts new file mode 100644 index 000000000..507cba261 --- /dev/null +++ b/web-app/src/app/entities/map/entities.map.ts @@ -0,0 +1,17 @@ +export enum WebSearchType { + NONE = "NONE", + NOMINATIM = "NOMINATIM" +} + +export enum MobileSearchType { + NONE = "NONE", + NATIVE = "NATIVE", + NOMINATIM = "NOMINATIM" +} + +export interface MapSettings { + webSearchType: WebSearchType + webNominatimUrl: string | null + mobileSearchType: MobileSearchType + mobileNominatimUrl: string | null +} \ No newline at end of file diff --git a/web-app/src/app/map/controls/search.component.html b/web-app/src/app/map/controls/search.component.html index 79ffe676d..073fac82d 100644 --- a/web-app/src/app/map/controls/search.component.html +++ b/web-app/src/app/map/controls/search.component.html @@ -1,4 +1,4 @@ -
+
@@ -14,7 +14,7 @@ place - {{result.properties.display_name}} + {{result.name}}
diff --git a/web-app/src/app/map/controls/search.component.spec.ts b/web-app/src/app/map/controls/search.component.spec.ts index 7d7444c50..61cc67ecb 100644 --- a/web-app/src/app/map/controls/search.component.spec.ts +++ b/web-app/src/app/map/controls/search.component.spec.ts @@ -8,20 +8,22 @@ import { MatInputModule } from '@angular/material/input'; import { MatListModule, MatListItem } from '@angular/material/list'; import { MatProgressSpinnerModule } from '@angular/material/progress-spinner'; import { BrowserAnimationsModule } from '@angular/platform-browser/animations'; -import { NominatimService } from '../search/nominatim.service'; import { By } from '@angular/platform-browser'; -import { defer } from 'rxjs'; import { HttpClientTestingModule } from '@angular/common/http/testing'; +import { PlacenameSearchResult, PlacenameSearchService } from '../search/search.service'; +import { MatSnackBarModule } from '@angular/material/snack-bar'; +import { defer } from 'rxjs'; +import { MobileSearchType, WebSearchType } from 'src/app/entities/map/entities.map'; describe('SearchComponent', () => { let component: SearchComponent; let fixture: ComponentFixture; let injector: TestBed; - let service: NominatimService; + let service: PlacenameSearchService; beforeEach(async(() => { TestBed.configureTestingModule({ - imports: [BrowserAnimationsModule, MatIconModule, MatButtonModule, HttpClientTestingModule, MatInputModule, MatProgressSpinnerModule, MatListModule, MatCardModule], + imports: [BrowserAnimationsModule, HttpClientTestingModule, MatCardModule, MatButtonModule, MatIconModule, MatInputModule, MatListModule, MatProgressSpinnerModule, MatSnackBarModule ], declarations: [ SearchComponent ], providers: [] }) @@ -34,7 +36,7 @@ describe('SearchComponent', () => { fixture.detectChanges(); injector = getTestBed(); - service = injector.get(NominatimService); + service = injector.get(PlacenameSearchService); }); it('should create', () => { @@ -60,33 +62,38 @@ describe('SearchComponent', () => { it('should search', fakeAsync(() => { spyOn(component.onSearch, 'emit'); - let mockResult: any = { - features: [{ - properties: { - display_name: 'test' - } - }] - }; + component.searchState = SearchState.ON - service.search = () => { - return defer(() => Promise.resolve(mockResult)); + component.mapSettings = { + webSearchType: WebSearchType.NOMINATIM, + webNominatimUrl: '', + mobileSearchType: MobileSearchType.NONE, + mobileNominatimUrl: '' } - const button = fixture.debugElement.query(By.css('button')); - button.nativeElement.click(); + fixture.detectChanges() - fixture.detectChanges(); + let results: PlacenameSearchResult[] = [{ + name: "test", + bbox: [0, 0, 0, 0], + position: [0, 0] + }]; - component.searchInput.value = "test"; + service.search = () => { + return defer(() => Promise.resolve(results)); + } + const input = fixture.debugElement.query(By.css('input')).nativeElement + input.value = "test" + const event = new KeyboardEvent("keydown", { "key": "Enter" }); - component.searchInput.nativeElement.dispatchEvent(event); + input.dispatchEvent(event); tick(100); - expect(component.searchResults).toEqual(mockResult.features); + expect(component.searchResults).toEqual(results); fixture.detectChanges(); @@ -94,28 +101,37 @@ describe('SearchComponent', () => { item.nativeElement.click(); expect(component.onSearch.emit).toHaveBeenCalledWith({ - feature: mockResult.features[0] + result: results[0] }); })); it('should clear', () => { spyOn(component.onSearchClear, 'emit'); - const button = fixture.debugElement.query(By.css('button')); - button.nativeElement.click(); + component.searchState = SearchState.ON + + component.mapSettings = { + webSearchType: WebSearchType.NOMINATIM, + webNominatimUrl: '', + mobileSearchType: MobileSearchType.NONE, + mobileNominatimUrl: '' + } fixture.detectChanges(); - component.searchInput.nativeElement.value = "test"; + const input = fixture.debugElement.query(By.css('input')).nativeElement + input.value = "test" fixture.detectChanges(); + expect(input.value).toEqual("test"); + const clearButton = fixture.debugElement.queryAll(By.css('button'))[1]; clearButton.nativeElement.click(); fixture.detectChanges(); - expect(component.searchInput.nativeElement.value).toEqual(""); + expect(input.value).toEqual(""); expect(component.onSearchClear.emit).toHaveBeenCalled(); }); }); diff --git a/web-app/src/app/map/controls/search.component.ts b/web-app/src/app/map/controls/search.component.ts index a2530a34a..442f3c491 100644 --- a/web-app/src/app/map/controls/search.component.ts +++ b/web-app/src/app/map/controls/search.component.ts @@ -1,7 +1,10 @@ import { Component, Output, EventEmitter, ViewChild, ElementRef, AfterViewInit } from '@angular/core'; -import { NominatimService } from '../search/nominatim.service'; import { MatList } from '@angular/material/list'; import { DomEvent } from 'leaflet'; +import { MapSettingsService } from '../settings/map.settings.service'; +import { MapSettings } from 'src/app/entities/map/entities.map'; +import { PlacenameSearchResult, PlacenameSearchService } from '../search/search.service'; +import { MatSnackBar } from '@angular/material/snack-bar'; export enum SearchState { ON, @@ -9,7 +12,7 @@ export enum SearchState { } export interface SearchEvent { - feature: any; + result: PlacenameSearchResult; } @Component({ @@ -25,13 +28,25 @@ export class SearchComponent implements AfterViewInit { @Output() onSearch = new EventEmitter(); @Output() onSearchClear = new EventEmitter(); + mapSettings: MapSettings + SearchState = SearchState; searchState = SearchState.OFF; searchResults: any[] = []; searching = false; - constructor(private nominatim: NominatimService) { } + constructor( + private mapSettingsService: MapSettingsService, + private searchService: PlacenameSearchService, + private snackBar: MatSnackBar + ) { } + + ngOnInit(): void { + this.mapSettingsService.getMapSettings().subscribe((settings: MapSettings) => { + this.mapSettings = settings + }) + } ngAfterViewInit(): void { DomEvent.disableClickPropagation(this.matList.nativeElement); @@ -50,10 +65,15 @@ export class SearchComponent implements AfterViewInit { search(value: string): void { this.searching = true; - this.nominatim.search(value).subscribe((data: any) => { + this.searchService.search(this.mapSettings, value).subscribe((results: PlacenameSearchResult[]) => { + this.searching = false; + this.searchResults = results; + }, () => { this.searching = false; - this.searchResults = data.features; - }); + this.snackBar.open("Error accessing place name server ", null, { + duration: 2000, + }) + }) } clear($event: MouseEvent, input: HTMLInputElement): void { @@ -65,8 +85,8 @@ export class SearchComponent implements AfterViewInit { this.onSearchClear.emit(); } - searchResultClick(result: any): void { + searchResultClick(result: PlacenameSearchResult): void { this.searchToggle(); - this.onSearch.emit({ feature: result }); + this.onSearch.emit({ result: result }); } } diff --git a/web-app/src/app/map/search/nominatim.service.spec.ts b/web-app/src/app/map/search/nominatim.service.spec.ts deleted file mode 100644 index 7f803486b..000000000 --- a/web-app/src/app/map/search/nominatim.service.spec.ts +++ /dev/null @@ -1,54 +0,0 @@ -import { TestBed, getTestBed } from '@angular/core/testing'; - -import { NominatimService } from './nominatim.service'; -import { HttpClientTestingModule, HttpTestingController } from '@angular/common/http/testing'; - -describe('NominatimService', () => { - let injector: TestBed; - let service: NominatimService; - let httpMock: HttpTestingController; - - beforeEach(() => { - TestBed.configureTestingModule({ - imports: [HttpClientTestingModule] - }); - - injector = getTestBed(); - service = injector.get(NominatimService); - httpMock = injector.get(HttpTestingController); - }); - - afterEach(() => { - httpMock.verify(); - }); - - it('should be created', () => { - const service: NominatimService = TestBed.inject(NominatimService); - expect(service).toBeTruthy(); - }); - - it('should return an Observable', () => { - const data = { - features: [{ - geometry: { - type: 'Point', - coordinates: [0,0] - }, - properties: { - display_name: '123 South' - } - }] - }; - - const search = '123 South Madeup Way'; - service.search(search).subscribe((data: any) => { - expect(data).toEqual(data); - }); - - const req = httpMock.expectOne(req => req.url.startsWith(`${NominatimService.URL}`)); - expect(req.request.method).toBe("GET"); - expect(req.request.params.has('q')).toBeTruthy(); - expect(req.request.params.get('q')).toBe(`${search}`) - req.flush(data); - }); -}); diff --git a/web-app/src/app/map/search/nominatim.service.ts b/web-app/src/app/map/search/nominatim.service.ts deleted file mode 100644 index a86fd5d45..000000000 --- a/web-app/src/app/map/search/nominatim.service.ts +++ /dev/null @@ -1,25 +0,0 @@ -import { Injectable } from '@angular/core'; -import { HttpClient, HttpParams } from '@angular/common/http'; - -@Injectable({ - providedIn: 'root' -}) -export class NominatimService { - public static readonly URL = 'https://nominatim.openstreetmap.org/search'; - public static readonly FORMAT = 'geojson'; - public static readonly ADDRESS = '1'; - public static readonly LIMIT = '10'; - - constructor(private http: HttpClient) { } - - search(query: string) { - const params = new HttpParams() - .set('q', query) - .set('format', NominatimService.FORMAT) - .set('limit', NominatimService.LIMIT) - .set('addressdetails', NominatimService.ADDRESS); - - - return this.http.get(NominatimService.URL, { params: params }); - } -} diff --git a/web-app/src/app/map/search/search.service.spec.ts b/web-app/src/app/map/search/search.service.spec.ts new file mode 100644 index 000000000..71d3b5cd8 --- /dev/null +++ b/web-app/src/app/map/search/search.service.spec.ts @@ -0,0 +1,74 @@ +import { TestBed, getTestBed } from '@angular/core/testing'; + +import { PlacenameSearchResult, PlacenameSearchService } from './search.service'; +import { HttpClientTestingModule, HttpTestingController } from '@angular/common/http/testing'; +import { MapSettings, MobileSearchType, WebSearchType } from 'src/app/entities/map/entities.map'; +import { FeatureCollection } from 'geojson'; + +describe('SearchService', () => { + let injector: TestBed; + let service: PlacenameSearchService; + let httpMock: HttpTestingController; + + beforeEach(() => { + TestBed.configureTestingModule({ + imports: [HttpClientTestingModule] + }); + + injector = getTestBed(); + service = injector.get(PlacenameSearchService); + httpMock = injector.get(HttpTestingController); + }); + + afterEach(() => { + httpMock.verify(); + }); + + it('should be created', () => { + const service: PlacenameSearchService = TestBed.inject(PlacenameSearchService); + expect(service).toBeTruthy(); + }); + + it('should return an Observable', () => { + const results: PlacenameSearchResult[] = [new PlacenameSearchResult( + "somewhere", + [0, 0, 0, 0], + [0, 0] + )] + + const featureCollection: FeatureCollection = { + type: "FeatureCollection", + features: [{ + "type": "Feature", + "properties": { + "display_name": "somewhere", + }, + "bbox": [0,0,0,0], + "geometry": { + "type": "Point", + "coordinates": [0,0] + } + }] + } + + const mapSettings: MapSettings = { + webSearchType: WebSearchType.NOMINATIM, + webNominatimUrl: "www.test.com", + mobileSearchType: MobileSearchType.NONE, + mobileNominatimUrl: null + } + + const search = '123 South Madeup Way'; + service.search(mapSettings, search).subscribe((response: PlacenameSearchResult[]) => { + console.log("response", response) + console.log("results", results) + expect(results).toEqual(response); + }); + + const req = httpMock.expectOne(req => req.url.startsWith("www.test.com")); + expect(req.request.method).toBe("GET"); + expect(req.request.params.has('q')).toBeTruthy(); + expect(req.request.params.get('q')).toBe(`${search}`) + req.flush(featureCollection); + }); +}); diff --git a/web-app/src/app/map/search/search.service.ts b/web-app/src/app/map/search/search.service.ts new file mode 100644 index 000000000..f8ab2df3d --- /dev/null +++ b/web-app/src/app/map/search/search.service.ts @@ -0,0 +1,80 @@ +import { Injectable } from '@angular/core'; +import { HttpClient, HttpParams } from '@angular/common/http'; +import { MapSettings, WebSearchType } from 'src/app/entities/map/entities.map'; +import { Observable } from 'rxjs'; +import { BBox, FeatureCollection, Position } from 'geojson'; +import { map } from 'rxjs/operators'; +import * as center from '@turf/center'; + +export class PlacenameSearchResult { + name: string + bbox: BBox + position: Position + + constructor(name: string, bbox: BBox, position: Position) { + this.name = name + this.bbox = bbox + this.position = position + } +} + +interface PlacenameSearch { + search(text: string): Observable +} + +@Injectable({ + providedIn: 'root' +}) +export class PlacenameSearchService { + constructor( + private http: HttpClient + ) {} + + search(settings: MapSettings, text: string): Observable { + const service = this.getSearchService(settings.webSearchType, settings.webNominatimUrl) + return service.search(text) + } + + private getSearchService(type: WebSearchType, url: string): PlacenameSearch | null { + switch (type) { + case WebSearchType.NOMINATIM: + return new NominatimService(this.http, url) + case WebSearchType.NONE: + return null + } + } +} + +class NominatimService implements PlacenameSearch { + private static readonly FORMAT = 'geojson'; + private static readonly ADDRESS = '1'; + private static readonly LIMIT = '10'; + + constructor( + private http: HttpClient, + private url: string + ) {} + + search(text: string): Observable { + const params = new HttpParams() + .set('q', text) + .set('format', NominatimService.FORMAT) + .set('limit', NominatimService.LIMIT) + .set('addressdetails', NominatimService.ADDRESS); + + return this.http.get(`${this.url}/search`, { params: params }).pipe(map(featureCollection => { + const result = featureCollection.features.map(feature => { + const name = feature.properties['display_name'] || text + const position: Position = center(feature).geometry.coordinates + let bbox: BBox = [position[0], position[1], position[0], position[1]] + if (feature.bbox) { + bbox = feature.bbox + } + + return new PlacenameSearchResult(name, bbox, position) + }) + + return result + })) + } +} diff --git a/web-app/src/app/map/settings/map.settings.service.spec.ts b/web-app/src/app/map/settings/map.settings.service.spec.ts new file mode 100644 index 000000000..35af4e125 --- /dev/null +++ b/web-app/src/app/map/settings/map.settings.service.spec.ts @@ -0,0 +1,18 @@ +import { TestBed } from '@angular/core/testing'; + +import { MapSettingsService } from './map.settings.service'; +import { HttpClientTestingModule } from '@angular/common/http/testing'; + +describe('MapSettingsService', () => { + + beforeEach(() => { + TestBed.configureTestingModule({ + imports: [HttpClientTestingModule] + }); + }); + + it('should be created', () => { + const service: MapSettingsService = TestBed.inject(MapSettingsService); + expect(service).toBeTruthy(); + }); +}); diff --git a/web-app/src/app/map/settings/map.settings.service.ts b/web-app/src/app/map/settings/map.settings.service.ts new file mode 100644 index 000000000..f84536b46 --- /dev/null +++ b/web-app/src/app/map/settings/map.settings.service.ts @@ -0,0 +1,22 @@ +import { HttpClient } from '@angular/common/http'; +import { Injectable } from '@angular/core'; +import { Observable } from 'rxjs'; +import { MapSettings } from 'src/app/entities/map/entities.map'; + +@Injectable({ + providedIn: 'root' +}) +export class MapSettingsService { + + constructor(private http: HttpClient) { } + + getMapSettings(): Observable { + return this.http.get('/api/settings/map'); + } + + updateMapSettings(settings: MapSettings): Observable { + return this.http.post('/api/settings/map/', settings, { + headers: { "Content-Type": "application/json" } + }); + } +} diff --git a/web-app/src/app/observation/observation-edit/observation-edit-password/observation-edit-password.component.spec.ts b/web-app/src/app/observation/observation-edit/observation-edit-password/observation-edit-password.component.spec.ts index 4f638a918..decaa9365 100644 --- a/web-app/src/app/observation/observation-edit/observation-edit-password/observation-edit-password.component.spec.ts +++ b/web-app/src/app/observation/observation-edit/observation-edit-password/observation-edit-password.component.spec.ts @@ -1,8 +1,6 @@ import { ChangeDetectionStrategy, Component, ViewChild } from '@angular/core'; import { async, ComponentFixture, TestBed } from '@angular/core/testing'; import { FormControl, FormGroup, Validators } from '@angular/forms'; -import { MatError } from '@angular/material/form-field'; -import { By } from 'protractor'; import { ObservationEditPasswordComponent } from './observation-edit-password.component'; diff --git a/web-app/src/app/observation/observation-edit/observation-edit-radio/observation-edit-radio.component.spec.ts b/web-app/src/app/observation/observation-edit/observation-edit-radio/observation-edit-radio.component.spec.ts index 0406d9e03..125575300 100644 --- a/web-app/src/app/observation/observation-edit/observation-edit-radio/observation-edit-radio.component.spec.ts +++ b/web-app/src/app/observation/observation-edit/observation-edit-radio/observation-edit-radio.component.spec.ts @@ -4,7 +4,6 @@ import { FormControl, FormGroup, FormsModule, ReactiveFormsModule, Validators } import { MatError, MatFormFieldModule } from '@angular/material/form-field'; import { MatRadioModule } from '@angular/material/radio'; import { By } from '@angular/platform-browser' -import { by } from 'protractor'; import { ObservationEditRadioComponent } from './observation-edit-radio.component' diff --git a/web-app/src/ng1/admin/admin.tab.component.js b/web-app/src/ng1/admin/admin.tab.component.js index 92caeb747..73da0d748 100644 --- a/web-app/src/ng1/admin/admin.tab.component.js +++ b/web-app/src/ng1/admin/admin.tab.component.js @@ -12,6 +12,7 @@ class AdminTabController { tabChanged(state) { this.$state.go(state); + console.log('state changes', state) } } diff --git a/web-app/src/ng1/admin/admin.tab.html b/web-app/src/ng1/admin/admin.tab.html index dfcebf898..ad48248ae 100644 --- a/web-app/src/ng1/admin/admin.tab.html +++ b/web-app/src/ng1/admin/admin.tab.html @@ -52,6 +52,12 @@
Feeds
+
+
+ +
Map
+
+
diff --git a/web-app/src/ng1/app.js b/web-app/src/ng1/app.js index f40f225cc..065a49eaf 100644 --- a/web-app/src/ng1/app.js +++ b/web-app/src/ng1/app.js @@ -27,7 +27,8 @@ import { LocationComponent } from '../app/map/controls/location.component'; import { AddObservationComponent } from '../app/map/controls/add-observation.component'; import { LeafletComponent } from '../app/map/leaflet.component'; import { ExportComponent } from '../app/export/export.component'; -import { AdminSettingsComponent } from '../app/admin/admin-settings/admin-settings.component'; + + import { FeedService } from '@ngageoint/mage.web-core-lib/feed' import { ExportService } from '../app/export/export.service' @@ -46,6 +47,8 @@ import { UserPopupComponent } from '../app/user/user-popup/user-popup.component' import { ContactComponent } from '../app/contact/contact.component'; +import { AdminSettingsComponent } from '../app/admin/admin-settings/admin-settings.component'; +import { AdminMapComponent } from '../app/admin/admin-map/admin-map.component'; import { AdminFeedsComponent } from '../app/admin/admin-feeds/admin-feeds.component'; import { AdminFeedComponent } from '../app/admin/admin-feeds/admin-feed/admin-feed.component'; import { AdminServiceComponent } from '../app/admin/admin-feeds/admin-service/admin-service.component' @@ -106,6 +109,7 @@ app .directive('feedEdit', downgradeComponent({ component: AdminFeedEditComponent })) .directive('swagger', downgradeComponent({ component: SwaggerComponent })) .directive('export', downgradeComponent({ component: ExportComponent })) + .directive('upgradedAdminMapSettings', downgradeComponent({ component: AdminMapComponent })) .directive('upgradedAdminSettings', downgradeComponent({ component: AdminSettingsComponent })) .directive('authenticationCreate', downgradeComponent({ component: AuthenticationCreateComponent })) .directive('contact', downgradeComponent({ component: ContactComponent })) @@ -434,6 +438,13 @@ function config($httpProvider, $stateProvider, $urlRouterProvider, $urlServicePr resolve: resolveAdmin() }); + // Admin map routes + $stateProvider.state('admin.map', { + url: '/map', + component: "upgradedAdminMapSettings", + resolve: resolveAdmin() + }); + // Admin settings routes $stateProvider.state('admin.settings', { url: '/settings', diff --git a/web-app/src/ng1/mage/leaflet.component.js b/web-app/src/ng1/mage/leaflet.component.js index 0139060d9..054e8ada1 100644 --- a/web-app/src/ng1/mage/leaflet.component.js +++ b/web-app/src/ng1/mage/leaflet.component.js @@ -184,13 +184,13 @@ class LeafletController { this.map.fitBounds( L.latLngBounds( - L.latLng($event.feature.bbox[1], $event.feature.bbox[0]), - L.latLng($event.feature.bbox[3], $event.feature.bbox[2]) + L.latLng($event.result.bbox[1], $event.result.bbox[0]), + L.latLng($event.result.bbox[3], $event.result.bbox[2]) ) ); - const popup = L.popup({ className: 'leaflet-material-popup' }).setContent($event.feature.properties.display_name); - this.searchMarker = L.marker([$event.feature.geometry.coordinates[1], $event.feature.geometry.coordinates[0]]) + const popup = L.popup({ className: 'leaflet-material-popup' }).setContent($event.result.name); + this.searchMarker = L.marker([$event.result.position[1], $event.result.position[0]]) .addTo(this.map) .bindPopup(popup) .openPopup();