From 3d9047b73ccadc6b949a8da330d2ed578372d55e Mon Sep 17 00:00:00 2001 From: Theresa Henze Date: Mon, 3 Jul 2023 13:38:29 +0200 Subject: [PATCH] feat(locations): adjust api and UI to handle location CRUD Refs: #19 --- workspaces/api/src/app.controller.spec.ts | 72 ------- workspaces/api/src/app.controller.ts | 88 --------- workspaces/api/src/app.module.ts | 6 +- workspaces/api/src/app.service.spec.ts | 179 ------------------ workspaces/api/src/app.service.ts | 77 -------- .../src/location/entities/location.entity.ts | 2 + .../api/src/location/location.controller.ts | 42 ++-- .../api/src/location/location.module.ts | 1 + .../api/src/location/location.service.spec.ts | 65 +++++-- .../api/src/location/location.service.ts | 44 ++++- .../utils/location-mapper.service.spec.ts | 21 ++ .../location/utils/location-mapper.service.ts | 6 +- .../api/src/marker/marker.controller.spec.ts | 16 +- .../api/src/marker/marker.controller.ts | 67 +++---- workspaces/api/src/marker/marker.module.ts | 3 +- .../api/src/marker/marker.service.spec.ts | 156 ++++++--------- workspaces/api/src/marker/marker.service.ts | 44 ++--- .../entity-manager.service.spec.ts | 48 ++--- .../src/persistence/entity-manager.service.ts | 47 ++--- .../src/persistence/migrations/migration.ts | 2 +- .../migrations/v2-to-v3.migration.spec.ts | 67 +++++++ .../migrations/v2-to-v3.migration.ts | 22 +++ .../api/src/persistence/persistence.module.ts | 2 + .../src/components/ConfirmationDialog.svelte | 2 +- .../components/FloorPlanPaneSideBar.spec.ts | 31 --- .../components/FloorPlanPaneSideBar.svelte | 147 -------------- .../components/FloorPlanUploadButton.svelte | 5 +- .../src/components/InfoPaneSideBar.spec.ts | 6 +- .../src/components/InfoPaneSideBar.svelte | 11 +- .../src/components/NavigationItem.svelte | 24 ++- .../src/components/NavigationSideBar.spec.ts | 103 +--------- .../src/components/NavigationSideBar.svelte | 48 +++-- .../frontend/src/components/Screen.svelte | 2 +- .../locations/FloorPlanUpload.svelte | 124 ++++++++++++ .../locations/LocationSideBar.spec.ts | 42 ++++ .../locations/LocationSideBar.svelte | 148 +++++++++++++++ workspaces/frontend/src/desk-compass.postcss | 2 +- workspaces/frontend/src/i18n/de.json | 29 ++- workspaces/frontend/src/i18n/en.json | 28 ++- workspaces/frontend/src/mocks/handlers.ts | 56 ++++-- .../src/mocks/mockViewportSingleton.ts | 4 +- .../frontend/src/stores/locations.spec.ts | 83 ++++++++ workspaces/frontend/src/stores/locations.ts | 102 ++++++++++ .../frontend/src/stores/markers.spec.ts | 5 +- workspaces/frontend/src/stores/markers.ts | 14 +- .../src/ts/FloorPlanUploadMapControl.ts | 5 +- workspaces/frontend/src/ts/Location.spec.ts | 63 ++++++ workspaces/frontend/src/ts/Location.ts | 30 +++ workspaces/frontend/src/ts/Marker.ts | 6 +- .../frontend/src/ts/ViewportSingleton.spec.ts | 19 +- .../frontend/src/ts/ViewportSingleton.ts | 89 +++++---- workspaces/frontend/src/views/App.svelte | 3 +- workspaces/frontend/vite.config.js | 4 +- 53 files changed, 1192 insertions(+), 1120 deletions(-) delete mode 100644 workspaces/api/src/app.controller.spec.ts delete mode 100644 workspaces/api/src/app.controller.ts delete mode 100644 workspaces/api/src/app.service.spec.ts delete mode 100644 workspaces/api/src/app.service.ts create mode 100644 workspaces/api/src/persistence/migrations/v2-to-v3.migration.spec.ts create mode 100644 workspaces/api/src/persistence/migrations/v2-to-v3.migration.ts delete mode 100644 workspaces/frontend/src/components/FloorPlanPaneSideBar.spec.ts delete mode 100644 workspaces/frontend/src/components/FloorPlanPaneSideBar.svelte create mode 100644 workspaces/frontend/src/components/locations/FloorPlanUpload.svelte create mode 100644 workspaces/frontend/src/components/locations/LocationSideBar.spec.ts create mode 100644 workspaces/frontend/src/components/locations/LocationSideBar.svelte create mode 100644 workspaces/frontend/src/stores/locations.spec.ts create mode 100644 workspaces/frontend/src/stores/locations.ts create mode 100644 workspaces/frontend/src/ts/Location.spec.ts create mode 100644 workspaces/frontend/src/ts/Location.ts diff --git a/workspaces/api/src/app.controller.spec.ts b/workspaces/api/src/app.controller.spec.ts deleted file mode 100644 index 54f39a9..0000000 --- a/workspaces/api/src/app.controller.spec.ts +++ /dev/null @@ -1,72 +0,0 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest'; -import { Response } from 'express'; -import { AppController } from './app.controller'; -import { AppService } from './app.service'; -import { UploadDto } from './persistence/dto/upload.dto'; - -describe('AppController', () => { - let controller: AppController; - const appService: AppService = {} as AppService; - - beforeEach(async () => { - controller = new AppController(appService); - }); - - describe('getFloorPlan', () => { - it('should retrieve image', async () => { - const mockResponse = { - type: vi.fn(), - end: vi.fn(), - } as unknown as Response; - const mockImage = { - image: Buffer.from('abc'), - width: 123, - height: 456, - }; - appService.getFloorPlan = vi.fn(); - vi.spyOn(appService, 'getFloorPlan').mockImplementation(() => { - return new Promise((resolve) => resolve(mockImage)); - }); - - await controller.getFloorPlan(mockResponse); - - expect(appService.getFloorPlan).toHaveBeenCalledTimes(1); - expect(mockResponse.type).toHaveBeenCalledWith('png'); - expect(mockResponse.end).toHaveBeenCalledWith(mockImage.image, 'binary'); - }); - }); - - describe('uploadFloorPlan', () => { - it('should upload image', async () => { - appService.uploadFloorPlan = vi.fn(); - vi.spyOn(appService, 'uploadFloorPlan'); - - await controller.uploadFloorPlan( - new UploadDto(), - {} as Express.Multer.File, - ); - - expect(appService.uploadFloorPlan).toHaveBeenCalledTimes(1); - }); - }); - - describe('getFloorPlanMetaData', () => { - it('should retrieve image dimensions', async () => { - const mockImage = { - image: Buffer.from('abc'), - width: 123, - height: 456, - }; - appService.getFloorPlan = vi.fn(); - vi.spyOn(appService, 'getFloorPlan').mockImplementation(() => { - return new Promise((resolve) => resolve(mockImage)); - }); - - const actual = await controller.getFloorPlanMetaData(); - - expect(appService.getFloorPlan).toHaveBeenCalledTimes(1); - expect(actual.width).toBe(123); - expect(actual.height).toBe(456); - }); - }); -}); diff --git a/workspaces/api/src/app.controller.ts b/workspaces/api/src/app.controller.ts deleted file mode 100644 index 4446610..0000000 --- a/workspaces/api/src/app.controller.ts +++ /dev/null @@ -1,88 +0,0 @@ -import { - Body, - Controller, - FileTypeValidator, - Get, - MaxFileSizeValidator, - ParseFilePipe, - Post, - Res, - UploadedFile, -} from '@nestjs/common'; -import { - ApiOperation, - ApiProduces, - ApiResponse, - ApiTags, -} from '@nestjs/swagger'; -import { Response } from 'express'; -import { AppService } from './app.service'; -import { SizeAwareUploadDto } from './persistence/dto/size-aware-upload.dto'; -import { ApiUpload } from './persistence/decorators/api-upload.decorator'; -import { ApiImageDownload } from './persistence/decorators/api-image-download.decorator'; - -@Controller() -@ApiTags('floorplan') -export class AppController { - private static readonly MAX_FILE_SIZE = 5 * Math.pow(1024, 2); // 5 MB - - constructor(private readonly appService: AppService) {} - - @Get() - @ApiOperation({ - summary: 'Provides the floor plan image to display as map background', - }) - @ApiImageDownload() - async getFloorPlan( - @Res() - response: Response, - ): Promise { - const imageContainer = await this.appService.getFloorPlan(); - response.type('png'); - response.end(imageContainer.image, 'binary'); - } - - @Post() - @ApiOperation({ - summary: - 'Upload for floor plan image; Returns image ID; (This may change in the future)', - }) - @ApiUpload('file', AppController.MAX_FILE_SIZE) - @ApiProduces('text/plain') - async uploadFloorPlan( - @Body() uploadDto: SizeAwareUploadDto, - @UploadedFile( - new ParseFilePipe({ - fileIsRequired: true, - validators: [ - new MaxFileSizeValidator({ maxSize: AppController.MAX_FILE_SIZE }), - new FileTypeValidator({ fileType: new RegExp('png|jpeg|jpg') }), - ], - }), - ) - file: Express.Multer.File, - ): Promise { - return this.appService.uploadFloorPlan( - file, - uploadDto.width, - uploadDto.height, - ); - } - - @Get('info') - @ApiOperation({ - summary: 'Provides target dimensions of floor plan image', - }) - @ApiResponse({ - description: - 'Image dimensions for display, which do not necessarily match the actual image size', - schema: { - type: 'object', - properties: { width: { type: 'number' }, height: { type: 'number' } }, - }, - }) - async getFloorPlanMetaData(): Promise<{ width: number; height: number }> { - const imageContainer = await this.appService.getFloorPlan(); - return { width: imageContainer.width, height: imageContainer.height }; - } -} diff --git a/workspaces/api/src/app.module.ts b/workspaces/api/src/app.module.ts index 6a4bff6..adbeb76 100644 --- a/workspaces/api/src/app.module.ts +++ b/workspaces/api/src/app.module.ts @@ -7,8 +7,6 @@ import { HealthModule } from './health/health.module'; import { MarkerModule } from './marker/marker.module'; import { LocationModule } from './location/location.module'; import { PersistenceModule } from './persistence/persistence.module'; -import { AppController } from './app.controller'; -import { AppService } from './app.service'; import { ServeStaticModule } from '@nestjs/serve-static'; import { join } from 'path'; @@ -28,7 +26,7 @@ import { join } from 'path'; MarkerModule, LocationModule, ], - controllers: [AppController], - providers: [AppService], + controllers: [], + providers: [], }) export class AppModule {} diff --git a/workspaces/api/src/app.service.spec.ts b/workspaces/api/src/app.service.spec.ts deleted file mode 100644 index 1fbe532..0000000 --- a/workspaces/api/src/app.service.spec.ts +++ /dev/null @@ -1,179 +0,0 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest'; -import { EntityManagerService } from './persistence/entity-manager.service'; -import { UploadManagerService } from './persistence/upload-manager.service'; -import { AppService } from './app.service'; -import { Location } from './location/entities/location.entity'; - -describe('AppService', () => { - let service: AppService; - const entityManagerService: EntityManagerService = {} as EntityManagerService; - const uploadManagerService: UploadManagerService = {} as UploadManagerService; - - const id = 'abc-123'; - - beforeEach(async () => { - entityManagerService.create = vi.fn(); - entityManagerService.get = vi.fn(); - entityManagerService.getAll = vi.fn(); - entityManagerService.update = vi.fn(); - uploadManagerService.get = vi.fn(); - uploadManagerService.upload = vi.fn(); - uploadManagerService.delete = vi.fn(); - - service = new AppService(entityManagerService, uploadManagerService); - }); - - describe('getFloorPlan', () => { - it('should get image', async () => { - const imageId = 'xyz-789'; - const entity = new Location({ id: id, image: imageId }); - vi.spyOn(entityManagerService, 'getAll').mockImplementation( - () => new Promise((resolve) => resolve([entity])), - ); - - await service.getFloorPlan(); - - expect(entityManagerService.getAll).toHaveBeenCalledWith(Location.TYPE); - expect(uploadManagerService.get).toHaveBeenCalledWith(imageId); - }); - - it('should provide default image, if no image data is available', async () => { - const entity = new Location({ id: id }); - vi.spyOn(entityManagerService, 'getAll').mockImplementation( - () => new Promise((resolve) => resolve([entity])), - ); - - const actual = await service.getFloorPlan(); - - expect(actual).toBeDefined(); - expect(actual.image).toBeDefined(); - expect(actual.width).toBe(1000); - expect(actual.height).toBe(1000); - }); - - it('should create missing floor plan, if no database entry exists', async () => { - const entity = new Location({ id: id }); - vi.spyOn(entityManagerService, 'getAll').mockImplementation( - () => new Promise((_, reject) => reject()), - ); - vi.spyOn(entityManagerService, 'create').mockImplementation( - () => new Promise((resolve) => resolve(entity)), - ); - - const actual = await service.getFloorPlan(); - - expect(actual).toBeDefined(); - expect(actual.image).toBeDefined(); - expect(actual.width).toBe(1000); - expect(actual.height).toBe(1000); - }); - }); - - describe('uploadFloorPlan', () => { - it('should create location and upload image', async () => { - const imageId = 'xyz-789'; - const imageWidth = 123; - const imageHeight = 456; - const file = {} as Express.Multer.File; - const entity = new Location({ id: id }); - const updatedEntity = new Location({ - id: id, - image: imageId, - width: imageWidth, - height: imageHeight, - }); - vi.spyOn(entityManagerService, 'getAll').mockImplementation( - () => new Promise((_, reject) => reject()), - ); - vi.spyOn(entityManagerService, 'create').mockImplementation( - () => new Promise((resolve) => resolve(entity)), - ); - vi.spyOn(uploadManagerService, 'upload').mockImplementation( - () => new Promise((resolve) => resolve(imageId)), - ); - vi.spyOn(entityManagerService, 'update').mockImplementation( - () => new Promise((resolve) => resolve(updatedEntity)), - ); - - await service.uploadFloorPlan(file, imageWidth, imageHeight); - - expect(entityManagerService.getAll).toHaveBeenCalledWith(Location.TYPE); - expect(uploadManagerService.upload).toHaveBeenCalledWith(file); - expect(entityManagerService.update).toHaveBeenCalledWith( - Location.TYPE, - updatedEntity, - ); - }); - - it('should upload image', async () => { - const imageId = 'xyz-789'; - const imageWidth = 123; - const imageHeight = 456; - const file = {} as Express.Multer.File; - const entity = new Location({ id: id }); - const updatedEntity = new Location({ - id: id, - image: imageId, - width: imageWidth, - height: imageHeight, - }); - vi.spyOn(entityManagerService, 'getAll').mockImplementation( - () => new Promise((resolve) => resolve([entity])), - ); - vi.spyOn(uploadManagerService, 'upload').mockImplementation( - () => new Promise((resolve) => resolve(imageId)), - ); - vi.spyOn(entityManagerService, 'update').mockImplementation( - () => new Promise((resolve) => resolve(updatedEntity)), - ); - - await service.uploadFloorPlan(file, imageWidth, imageHeight); - - expect(entityManagerService.getAll).toHaveBeenCalledWith(Location.TYPE); - expect(uploadManagerService.upload).toHaveBeenCalledWith(file); - expect(entityManagerService.update).toHaveBeenCalledWith( - Location.TYPE, - updatedEntity, - ); - }); - - it('should upload image and delete previous one', async () => { - const previousImageId = 'ooo-000'; - const newImageId = 'xyz-789'; - const imageWidth = 123; - const imageHeight = 456; - const file = {} as Express.Multer.File; - const entity = new Location({ - id: id, - image: previousImageId, - width: 1, - height: 2, - }); - const updatedEntity = new Location({ - id: id, - image: newImageId, - width: imageWidth, - height: imageHeight, - }); - vi.spyOn(entityManagerService, 'getAll').mockImplementation( - () => new Promise((resolve) => resolve([entity])), - ); - vi.spyOn(uploadManagerService, 'upload').mockImplementation( - () => new Promise((resolve) => resolve(newImageId)), - ); - vi.spyOn(entityManagerService, 'update').mockImplementation( - () => new Promise((resolve) => resolve(updatedEntity)), - ); - - await service.uploadFloorPlan(file, imageWidth, imageHeight); - - expect(entityManagerService.getAll).toHaveBeenCalledWith(Location.TYPE); - expect(uploadManagerService.upload).toHaveBeenCalledWith(file); - expect(entityManagerService.update).toHaveBeenCalledWith( - Location.TYPE, - updatedEntity, - ); - expect(uploadManagerService.delete).toHaveBeenCalledWith(previousImageId); - }); - }); -}); diff --git a/workspaces/api/src/app.service.ts b/workspaces/api/src/app.service.ts deleted file mode 100644 index f528e84..0000000 --- a/workspaces/api/src/app.service.ts +++ /dev/null @@ -1,77 +0,0 @@ -import { Inject, Injectable } from '@nestjs/common'; -import { EntityManagerService } from './persistence/entity-manager.service'; -import { UploadManagerService } from './persistence/upload-manager.service'; -import { Location } from './location/entities/location.entity'; -import { promises as p } from 'fs'; -import { join } from 'path'; - -@Injectable() -export class AppService { - constructor( - private readonly em: EntityManagerService, - @Inject(UploadManagerService.PROVIDER) - private readonly uploadManager: UploadManagerService, - ) {} - - async getFloorPlan(): Promise<{ - image: Buffer; - width: number; - height: number; - }> { - let location; - try { - const allLocations = await this.em.getAll(Location.TYPE); - location = allLocations[0]; - } catch (e) { - location = await this.em.create( - Location.TYPE, - new Location(), - ); - } - - if (!location.image) { - // provide fallback - const fallbackFilePath = join(__dirname, 'location/default.png'); - return { - image: await p.readFile(fallbackFilePath), - width: 1000, - height: 1000, - }; - } - - return { - image: await this.uploadManager.get(location.image), - width: location.width, - height: location.height, - }; - } - - async uploadFloorPlan( - file: Express.Multer.File, - width: number, - height: number, - ): Promise { - let location: Location = new Location({ - width: width, - height: height, - }); - try { - const allLocations = await this.em.getAll(Location.TYPE); - location = allLocations[0]; - } catch (e) { - location = await this.em.create(Location.TYPE, location); - } - const previousImageId = location.image; - location.image = await this.uploadManager.upload(file); - location.width = width; - location.height = height; - const updatedLocation = await this.em.update( - Location.TYPE, - location, - ); - if (previousImageId) { - await this.uploadManager.delete(previousImageId); - } - return updatedLocation.image; - } -} diff --git a/workspaces/api/src/location/entities/location.entity.ts b/workspaces/api/src/location/entities/location.entity.ts index 0f09b8e..b0f220a 100644 --- a/workspaces/api/src/location/entities/location.entity.ts +++ b/workspaces/api/src/location/entities/location.entity.ts @@ -1,5 +1,6 @@ import { Entity } from '../../persistence/entities/entity'; import { EntityType } from '../../persistence/entities/entity.type'; +import { Marker } from '../../marker/entities/marker.entity'; export class Location extends Entity { static TYPE: EntityType = '/locations'; @@ -10,6 +11,7 @@ export class Location extends Entity { image: string; width: number; height: number; + markers: { [key: string]: Marker } = {}; constructor(partial?: Partial) { super(); diff --git a/workspaces/api/src/location/location.controller.ts b/workspaces/api/src/location/location.controller.ts index 43836ad..3d70a4e 100644 --- a/workspaces/api/src/location/location.controller.ts +++ b/workspaces/api/src/location/location.controller.ts @@ -1,41 +1,33 @@ import { + Body, Controller, + Delete, + FileTypeValidator, Get, - Post, - Body, - Put, + MaxFileSizeValidator, Param, - Delete, - UploadedFile, - ParseUUIDPipe, ParseFilePipe, - MaxFileSizeValidator, - FileTypeValidator, + ParseUUIDPipe, + Post, + Put, Res, + UploadedFile, } from '@nestjs/common'; -import { - ApiExtraModels, - ApiOperation, - ApiParam, - ApiProduces, - ApiResponse, - ApiTags, - getSchemaPath, -} from '@nestjs/swagger'; +import { ApiExtraModels, ApiOperation, ApiParam, ApiProduces, ApiResponse, ApiTags, getSchemaPath } from '@nestjs/swagger'; import { Response } from 'express'; import { CreateLocationDto } from './dto/create-location.dto'; import { UpdateLocationDto } from './dto/update-location.dto'; import { LocationDto } from './dto/location.dto'; -import { UploadDto } from '../persistence/dto/upload.dto'; import { ApiImageDownload } from '../persistence/decorators/api-image-download.decorator'; import { ApiUpload } from '../persistence/decorators/api-upload.decorator'; import { LocationService } from './location.service'; +import { SizeAwareUploadDto } from '../persistence/dto/size-aware-upload.dto'; @Controller(['locations']) @ApiTags('locations') @ApiExtraModels(LocationDto) export class LocationController { - private static readonly MAX_FILE_SIZE = Math.pow(1024, 2); + private static readonly MAX_FILE_SIZE = 5 * Math.pow(1024, 2); // 5 MB constructor(private readonly locationService: LocationService) {} @@ -84,10 +76,7 @@ export class LocationController { summary: 'Update a location', }) @ApiParam({ name: 'id', description: 'Marker ID' }) - update( - @Param('id', ParseUUIDPipe) id: string, - @Body() updateMarkerDto: UpdateLocationDto, - ): Promise { + update(@Param('id', ParseUUIDPipe) id: string, @Body() updateMarkerDto: UpdateLocationDto): Promise { return this.locationService.update(id, updateMarkerDto); } @@ -118,15 +107,14 @@ export class LocationController { @Post(':id/image/upload') @ApiOperation({ - summary: - 'Upload for a location image; Returns image ID; (This may change in the future)', + summary: 'Upload for a location image; Returns image ID; (This may change in the future)', }) @ApiUpload('file', LocationController.MAX_FILE_SIZE) @ApiProduces('text/plain') @ApiParam({ name: 'id', description: 'Marker ID' }) async uploadImage( @Param('id', ParseUUIDPipe) id: string, - @Body() uploadDto: UploadDto, + @Body() uploadDto: SizeAwareUploadDto, @UploadedFile( new ParseFilePipe({ fileIsRequired: true, @@ -138,6 +126,6 @@ export class LocationController { ) file: Express.Multer.File, ): Promise { - return this.locationService.uploadImage(id, file); + return this.locationService.uploadImage(id, uploadDto.width, uploadDto.height, file); } } diff --git a/workspaces/api/src/location/location.module.ts b/workspaces/api/src/location/location.module.ts index 0ed0950..e292b3f 100644 --- a/workspaces/api/src/location/location.module.ts +++ b/workspaces/api/src/location/location.module.ts @@ -8,5 +8,6 @@ import { LocationController } from './location.controller'; imports: [PersistenceModule], controllers: [LocationController], providers: [LocationService, LocationMapperService], + exports: [LocationService], }) export class LocationModule {} diff --git a/workspaces/api/src/location/location.service.spec.ts b/workspaces/api/src/location/location.service.spec.ts index ca27ca1..c953166 100644 --- a/workspaces/api/src/location/location.service.spec.ts +++ b/workspaces/api/src/location/location.service.spec.ts @@ -1,5 +1,4 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; -import { NotFoundException } from '@nestjs/common'; import { EntityManagerService } from '../persistence/entity-manager.service'; import { UploadManagerService } from '../persistence/upload-manager.service'; import { LocationMapperService } from './utils/location-mapper.service'; @@ -8,6 +7,7 @@ import { CreateLocationDto } from './dto/create-location.dto'; import { Location } from './entities/location.entity'; import { LocationDto } from './dto/location.dto'; import { UpdateLocationDto } from './dto/update-location.dto'; +import { Marker } from '../marker/entities/marker.entity'; describe('LocationService', () => { let service: LocationService; @@ -127,33 +127,28 @@ describe('LocationService', () => { expect(uploadManagerService.get).toHaveBeenCalledWith(imageId); }); - it('should throw error without image data', async () => { + it('should provide fallback image without image data', async () => { const entity = new Location({ id: id }); - const expectedError = new NotFoundException('No image available'); - let errorWasTriggered = false; vi.spyOn(entityManagerService, 'get').mockImplementation(() => new Promise((resolve) => resolve(entity))); - try { - await service.getImage(id); - } catch (error) { - expect(error.toString()).toBe(expectedError.toString()); - errorWasTriggered = true; - } + const actual = await service.getImage(id); - expect(errorWasTriggered).toBe(true); + expect(actual.toString()).toContain('PNG'); }); }); describe('uploadImage', () => { it('should upload image', async () => { const imageId = 'xyz-789'; + const width = 123; + const height = 456; const file = {} as Express.Multer.File; const updatedEntity = new Location({ id: id, image: imageId }); vi.spyOn(entityManagerService, 'get').mockImplementation(() => new Promise((resolve) => resolve(entity))); vi.spyOn(uploadManagerService, 'upload').mockImplementation(() => new Promise((resolve) => resolve(imageId))); vi.spyOn(entityManagerService, 'update').mockImplementation(() => new Promise((resolve) => resolve(updatedEntity))); - await service.uploadImage(id, file); + await service.uploadImage(id, width, height, file); expect(entityManagerService.get).toHaveBeenCalledWith(Location.TYPE, id); expect(uploadManagerService.upload).toHaveBeenCalledWith(file); @@ -163,6 +158,8 @@ describe('LocationService', () => { it('should upload image and delete previous one', async () => { const previousImageId = 'ooo-000'; const newImageId = 'xyz-789'; + const width = 123; + const height = 456; const file = {} as Express.Multer.File; const entity = new Location({ id: id, image: previousImageId }); const updatedEntity = new Location({ id: id, image: newImageId }); @@ -170,7 +167,7 @@ describe('LocationService', () => { vi.spyOn(uploadManagerService, 'upload').mockImplementation(() => new Promise((resolve) => resolve(newImageId))); vi.spyOn(entityManagerService, 'update').mockImplementation(() => new Promise((resolve) => resolve(updatedEntity))); - await service.uploadImage(id, file); + await service.uploadImage(id, width, height, file); expect(entityManagerService.get).toHaveBeenCalledWith(Location.TYPE, id); expect(uploadManagerService.upload).toHaveBeenCalledWith(file); @@ -178,4 +175,46 @@ describe('LocationService', () => { expect(uploadManagerService.delete).toHaveBeenCalledWith(previousImageId); }); }); + + describe('addMarker', () => { + it('should add marker to location', async () => { + const marker = new Marker({ id: 'xyz-000' }); + const updatedEntity = new Location({ id: id, markers: { 'xyz-000': marker } }); + vi.spyOn(entityManagerService, 'get').mockImplementation(() => new Promise((resolve) => resolve(entity))); + vi.spyOn(entityManagerService, 'update').mockImplementation(() => new Promise((resolve) => resolve(updatedEntity))); + + await service.addMarker(id, marker); + + expect(entityManagerService.get).toHaveBeenCalledWith(Location.TYPE, id); + expect(entityManagerService.update).toHaveBeenCalledWith(Location.TYPE, entity); + }); + }); + + describe('updateMarker', () => { + it('should update a marker in a location', async () => { + const marker = new Marker({ id: 'xyz-000' }); + const updatedEntity = new Location({ id: id, markers: { 'xyz-000': marker } }); + vi.spyOn(entityManagerService, 'get').mockImplementation(() => new Promise((resolve) => resolve(entity))); + vi.spyOn(entityManagerService, 'update').mockImplementation(() => new Promise((resolve) => resolve(updatedEntity))); + + await service.updateMarker(id, marker); + + expect(entityManagerService.get).toHaveBeenCalledWith(Location.TYPE, id); + expect(entityManagerService.update).toHaveBeenCalledWith(Location.TYPE, entity); + }); + }); + + describe('deleteMarker', () => { + it('should delete a marker from a location', async () => { + const marker = new Marker({ id: 'xyz-000' }); + const updatedEntity = new Location({ id: id, markers: { 'xyz-000': marker } }); + vi.spyOn(entityManagerService, 'get').mockImplementation(() => new Promise((resolve) => resolve(entity))); + vi.spyOn(entityManagerService, 'update').mockImplementation(() => new Promise((resolve) => resolve(updatedEntity))); + + await service.deleteMarker(id, marker.id); + + expect(entityManagerService.get).toHaveBeenCalledWith(Location.TYPE, id); + expect(entityManagerService.update).toHaveBeenCalledWith(Location.TYPE, entity); + }); + }); }); diff --git a/workspaces/api/src/location/location.service.ts b/workspaces/api/src/location/location.service.ts index 4789789..4391d78 100644 --- a/workspaces/api/src/location/location.service.ts +++ b/workspaces/api/src/location/location.service.ts @@ -1,4 +1,4 @@ -import { Inject, Injectable, NotFoundException } from '@nestjs/common'; +import { Inject, Injectable, Logger } from '@nestjs/common'; import { EntityManagerService } from '../persistence/entity-manager.service'; import { UploadManagerService } from '../persistence/upload-manager.service'; import { CreateLocationDto } from './dto/create-location.dto'; @@ -6,9 +6,15 @@ import { LocationDto } from './dto/location.dto'; import { UpdateLocationDto } from './dto/update-location.dto'; import { LocationMapperService } from './utils/location-mapper.service'; import { Location } from './entities/location.entity'; +import { Marker } from '../marker/entities/marker.entity'; +import * as uuid from '@lukeed/uuid'; +import { join } from 'path'; +import { promises as p } from 'fs'; @Injectable() export class LocationService { + private readonly logger = new Logger(LocationService.name); + constructor( private readonly entityManagerService: EntityManagerService, @Inject(UploadManagerService.PROVIDER) @@ -49,19 +55,49 @@ export class LocationService { async getImage(id: string): Promise { const location = await this.entityManagerService.get(Location.TYPE, id); if (!location.image) { - throw new NotFoundException('No image available'); + // provide fallback + const fallbackFilePath = join(__dirname, 'default.png'); + return p.readFile(fallbackFilePath); } + return this.uploadManager.get(location.image); } - async uploadImage(id: string, file: Express.Multer.File): Promise { + async uploadImage(id: string, width: number, height: number, file: Express.Multer.File): Promise { const location = await this.entityManagerService.get(Location.TYPE, id); const previousImageId = location.image; location.image = await this.uploadManager.upload(file); + location.width = width; + location.height = height; const updatedLocation = await this.entityManagerService.update(Location.TYPE, location); if (previousImageId) { - await this.uploadManager.delete(previousImageId); + try { + await this.uploadManager.delete(previousImageId); + } catch (e) { + this.logger.warn(`Could not delete previous image: ${previousImageId}`, { cause: e }); + } } return updatedLocation.image; } + + async addMarker(id: string, marker: Marker): Promise { + const location = await this.entityManagerService.get(Location.TYPE, id); + marker.id = uuid.v4(); + location.markers[marker.id] = marker; + await this.entityManagerService.update(Location.TYPE, location); + return marker; + } + + async updateMarker(id: string, marker: Marker): Promise { + const location = await this.entityManagerService.get(Location.TYPE, id); + location.markers[marker.id] = marker; + await this.entityManagerService.update(Location.TYPE, location); + return marker; + } + + async deleteMarker(id: string, markerId: string): Promise { + const location = await this.entityManagerService.get(Location.TYPE, id); + delete location.markers[markerId]; + await this.entityManagerService.update(Location.TYPE, location); + } } diff --git a/workspaces/api/src/location/utils/location-mapper.service.spec.ts b/workspaces/api/src/location/utils/location-mapper.service.spec.ts index 1a2282d..10d24c0 100644 --- a/workspaces/api/src/location/utils/location-mapper.service.spec.ts +++ b/workspaces/api/src/location/utils/location-mapper.service.spec.ts @@ -54,6 +54,27 @@ describe('LocationMapperService', () => { expect(actual.id).toBe(objData.id); expect(actual.name).toBe(objData.name); expect(actual.image).toBe(objData.image); + expect(actual.width).toBe(objData.width); + expect(actual.height).toBe(objData.height); + }); + + it('should create LocationDto from Location entity with fallback size', async () => { + const objData = { + id: 'abc-123', + name: 'This is a test', + shortName: 'T', + description: 'A description text', + image: 'def-456-ghi-789', + }; + const input = new Location(objData); + + const actual = service.entityToDto(input); + + expect(actual.id).toBe(objData.id); + expect(actual.name).toBe(objData.name); + expect(actual.image).toBe(objData.image); + expect(actual.width).toBe(1000); + expect(actual.height).toBe(1000); }); }); }); diff --git a/workspaces/api/src/location/utils/location-mapper.service.ts b/workspaces/api/src/location/utils/location-mapper.service.ts index dd42404..12b6be9 100644 --- a/workspaces/api/src/location/utils/location-mapper.service.ts +++ b/workspaces/api/src/location/utils/location-mapper.service.ts @@ -1,6 +1,6 @@ +import { Injectable } from '@nestjs/common'; import { Location } from '../entities/location.entity'; import { LocationDto } from '../dto/location.dto'; -import { Injectable } from '@nestjs/common'; @Injectable() export class LocationMapperService { @@ -11,8 +11,8 @@ export class LocationMapperService { shortName: entity.shortName, description: entity.description, image: entity.image, - width: entity.width, - height: entity.height, + width: entity.width ?? 1000, + height: entity.height ?? 1000, }); } diff --git a/workspaces/api/src/marker/marker.controller.spec.ts b/workspaces/api/src/marker/marker.controller.spec.ts index 84e2c71..4453705 100644 --- a/workspaces/api/src/marker/marker.controller.spec.ts +++ b/workspaces/api/src/marker/marker.controller.spec.ts @@ -33,11 +33,9 @@ describe('MarkerController', () => { describe('findAll', () => { it('should return an array of markers', async () => { const expectedResult = [new MarkerDto({}), new MarkerDto({})]; - const result: Promise = new Promise( - (resolve) => { - resolve(expectedResult); - }, - ); + const result: Promise = new Promise((resolve) => { + resolve(expectedResult); + }); markerService.findAll = vi.fn(); vi.spyOn(markerService, 'findAll').mockImplementation(() => result); @@ -100,7 +98,7 @@ describe('MarkerController', () => { return new Promise((resolve) => resolve(mockImage)); }); - await controller.getImage('abc-123', mockResponse); + await controller.getImage('xyz-000', 'abc-123', mockResponse); expect(markerService.getImage).toHaveBeenCalledTimes(1); expect(mockResponse.type).toHaveBeenCalledWith('png'); @@ -113,11 +111,7 @@ describe('MarkerController', () => { markerService.uploadImage = vi.fn(); vi.spyOn(markerService, 'uploadImage'); - await controller.uploadImage( - 'abc-123', - new UploadDto(), - {} as Express.Multer.File, - ); + await controller.uploadImage('abc-123', new UploadDto(), {} as Express.Multer.File); expect(markerService.uploadImage).toHaveBeenCalledTimes(1); }); diff --git a/workspaces/api/src/marker/marker.controller.ts b/workspaces/api/src/marker/marker.controller.ts index fa92b49..9f4a1b4 100644 --- a/workspaces/api/src/marker/marker.controller.ts +++ b/workspaces/api/src/marker/marker.controller.ts @@ -1,37 +1,29 @@ import { + Body, Controller, + Delete, + FileTypeValidator, Get, - Post, - Body, - Put, + MaxFileSizeValidator, Param, - Delete, - UploadedFile, - ParseUUIDPipe, ParseFilePipe, - MaxFileSizeValidator, - FileTypeValidator, + ParseUUIDPipe, + Post, + Put, Res, + UploadedFile, } from '@nestjs/common'; -import { - ApiExtraModels, - ApiOperation, - ApiParam, - ApiProduces, - ApiResponse, - ApiTags, - getSchemaPath, -} from '@nestjs/swagger'; -import { MarkerService } from './marker.service'; +import { ApiExtraModels, ApiOperation, ApiParam, ApiProduces, ApiResponse, ApiTags, getSchemaPath } from '@nestjs/swagger'; +import { Response } from 'express'; import { CreateMarkerDto } from './dto/create-marker.dto'; import { UpdateMarkerDto } from './dto/update-marker.dto'; import { MarkerDto } from './dto/marker.dto'; import { UploadDto } from '../persistence/dto/upload.dto'; -import { Response } from 'express'; import { ApiImageDownload } from '../persistence/decorators/api-image-download.decorator'; import { ApiUpload } from '../persistence/decorators/api-upload.decorator'; +import { MarkerService } from './marker.service'; -@Controller(['markers', 'marker']) +@Controller('locations/:locationId/markers') @ApiTags('markers') @ApiExtraModels(MarkerDto) export class MarkerController { @@ -43,14 +35,16 @@ export class MarkerController { @ApiOperation({ summary: 'Create a new marker', }) - create(@Body() createMarkerDto: CreateMarkerDto) { - return this.markerService.create(createMarkerDto); + @ApiParam({ name: 'locationId', description: 'Location ID' }) + create(@Param('locationId', ParseUUIDPipe) locationId: string, @Body() createMarkerDto: CreateMarkerDto) { + return this.markerService.create(locationId, createMarkerDto); } @Get() @ApiOperation({ summary: 'List all markers', }) + @ApiParam({ name: 'locationId', description: 'Location ID' }) @ApiResponse({ description: 'List of marker objects', schema: { @@ -60,14 +54,15 @@ export class MarkerController { }, }, }) - findAll(): Promise { - return this.markerService.findAll(); + findAll(@Param('locationId', ParseUUIDPipe) locationId: string): Promise { + return this.markerService.findAll(locationId); } @Get(':id') @ApiOperation({ summary: 'Get a single marker by its ID', }) + @ApiParam({ name: 'locationId', description: 'Location ID' }) @ApiParam({ name: 'id', description: 'Marker ID' }) @ApiResponse({ description: 'Marker object', @@ -75,56 +70,62 @@ export class MarkerController { $ref: getSchemaPath(MarkerDto), }, }) - async findOne(@Param('id', ParseUUIDPipe) id: string): Promise { - return await this.markerService.findOne(id); + async findOne(@Param('locationId', ParseUUIDPipe) locationId: string, @Param('id', ParseUUIDPipe) id: string): Promise { + return await this.markerService.findOne(locationId, id); } @Put(':id') @ApiOperation({ summary: 'Update a marker', }) + @ApiParam({ name: 'locationId', description: 'Location ID' }) @ApiParam({ name: 'id', description: 'Marker ID' }) update( + @Param('locationId', ParseUUIDPipe) locationId: string, @Param('id', ParseUUIDPipe) id: string, @Body() updateMarkerDto: UpdateMarkerDto, ): Promise { - return this.markerService.update(id, updateMarkerDto); + return this.markerService.update(locationId, id, updateMarkerDto); } @Delete(':id') @ApiOperation({ summary: 'Delete a marker', }) + @ApiParam({ name: 'locationId', description: 'Location ID' }) @ApiParam({ name: 'id', description: 'Marker ID' }) - delete(@Param('id', ParseUUIDPipe) id: string): Promise { - return this.markerService.delete(id); + delete(@Param('locationId', ParseUUIDPipe) locationId: string, @Param('id', ParseUUIDPipe) id: string): Promise { + return this.markerService.delete(locationId, id); } @Get(':id/image') @ApiOperation({ summary: 'Provides marker image', }) + @ApiParam({ name: 'locationId', description: 'Location ID' }) @ApiParam({ name: 'id', description: 'Marker ID' }) @ApiImageDownload() async getImage( + @Param('locationId', ParseUUIDPipe) locationId: string, @Param('id', ParseUUIDPipe) id: string, @Res() response: Response, ): Promise { - const buffer = await this.markerService.getImage(id); + const buffer = await this.markerService.getImage(locationId, id); response.type('png'); response.end(buffer, 'binary'); } @Post(':id/image/upload') @ApiOperation({ - summary: - 'Upload for a marker image; Returns image ID; (This may change in the future)', + summary: 'Upload for a marker image; Returns image ID; (This may change in the future)', }) @ApiUpload('file', MarkerController.MAX_FILE_SIZE) @ApiProduces('text/plain') + @ApiParam({ name: 'locationId', description: 'Location ID' }) @ApiParam({ name: 'id', description: 'Marker ID' }) async uploadImage( + @Param('locationId', ParseUUIDPipe) locationId: string, @Param('id', ParseUUIDPipe) id: string, @Body() uploadDto: UploadDto, @UploadedFile( @@ -138,6 +139,6 @@ export class MarkerController { ) file: Express.Multer.File, ): Promise { - return this.markerService.uploadImage(id, file); + return this.markerService.uploadImage(locationId, id, file); } } diff --git a/workspaces/api/src/marker/marker.module.ts b/workspaces/api/src/marker/marker.module.ts index 37b59f4..45e9e24 100644 --- a/workspaces/api/src/marker/marker.module.ts +++ b/workspaces/api/src/marker/marker.module.ts @@ -1,11 +1,12 @@ import { Module } from '@nestjs/common'; import { PersistenceModule } from '../persistence/persistence.module'; +import { LocationModule } from '../location/location.module'; import { MarkerService } from './marker.service'; import { MarkerController } from './marker.controller'; import { MarkerMapperService } from './utils/marker-mapper.service'; @Module({ - imports: [PersistenceModule], + imports: [PersistenceModule, LocationModule], controllers: [MarkerController], providers: [MarkerService, MarkerMapperService], }) diff --git a/workspaces/api/src/marker/marker.service.spec.ts b/workspaces/api/src/marker/marker.service.spec.ts index 5e1a1d7..556c76a 100644 --- a/workspaces/api/src/marker/marker.service.spec.ts +++ b/workspaces/api/src/marker/marker.service.spec.ts @@ -1,23 +1,28 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { NotFoundException } from '@nestjs/common'; import { EntityManagerService } from '../persistence/entity-manager.service'; import { UploadManagerService } from '../persistence/upload-manager.service'; +import { Location } from '../location/entities/location.entity'; +import { LocationService } from '../location/location.service'; import { MarkerMapperService } from './utils/marker-mapper.service'; -import { MarkerService } from './marker.service'; -import { CreateMarkerDto } from './dto/create-marker.dto'; import { Marker } from './entities/marker.entity'; +import { CreateMarkerDto } from './dto/create-marker.dto'; import { MarkerDto } from './dto/marker.dto'; import { UpdateMarkerDto } from './dto/update-marker.dto'; -import { NotFoundException } from '@nestjs/common'; +import { MarkerService } from './marker.service'; describe('MarkerService', () => { let service: MarkerService; const entityManagerService: EntityManagerService = {} as EntityManagerService; const uploadManagerService: UploadManagerService = {} as UploadManagerService; const markerMapper: MarkerMapperService = {} as MarkerMapperService; + const locationService: LocationService = {} as LocationService; const id = 'abc-123'; const entity = new Marker({ id: id }); const markerDto = new MarkerDto({ id: id }); + const locationId = 'xyz-000'; + const location = new Location({ id: locationId, markers: { 'abc-123': entity } }); beforeEach(async () => { markerMapper.dtoToEntity = vi.fn(); @@ -30,79 +35,64 @@ describe('MarkerService', () => { uploadManagerService.get = vi.fn(); uploadManagerService.upload = vi.fn(); uploadManagerService.delete = vi.fn(); + locationService.addMarker = vi.fn(); + locationService.updateMarker = vi.fn(); + locationService.deleteMarker = vi.fn(); vi.spyOn(markerMapper, 'dtoToEntity').mockImplementation(() => entity); vi.spyOn(markerMapper, 'entityToDto').mockImplementation(() => markerDto); - service = new MarkerService( - entityManagerService, - uploadManagerService, - markerMapper, - ); + service = new MarkerService(entityManagerService, uploadManagerService, markerMapper, locationService); }); describe('create', () => { it('should create entity', async () => { const createDto = new CreateMarkerDto(); - vi.spyOn(entityManagerService, 'create').mockImplementation( - () => new Promise((resolve) => resolve(entity)), - ); + vi.spyOn(locationService, 'addMarker').mockImplementation(() => new Promise((resolve) => resolve(entity))); - const result = await service.create(createDto); + const result = await service.create(locationId, createDto); expect(result.id).toBe(id); expect(markerMapper.dtoToEntity).toHaveBeenCalledWith(createDto); - expect(entityManagerService.create).toHaveBeenCalledWith( - Marker.TYPE, - entity, - ); + expect(locationService.addMarker).toHaveBeenCalledWith(locationId, entity); expect(markerMapper.entityToDto).toHaveBeenCalledWith(entity); }); it('should create entity with overwritten ID', async () => { const createDto = new CreateMarkerDto({ id: 'abc-123' }); - vi.spyOn(entityManagerService, 'create').mockImplementation( - () => new Promise((resolve) => resolve(entity)), - ); + vi.spyOn(locationService, 'addMarker').mockImplementation(() => new Promise((resolve) => resolve(entity))); - const result = await service.create(createDto); + const result = await service.create(locationId, createDto); expect(result.id).toBe(id); expect(markerMapper.dtoToEntity).toHaveBeenCalledWith(createDto); - expect(entityManagerService.create).toHaveBeenCalledWith( - Marker.TYPE, - entity, - ); + expect(locationService.addMarker).toHaveBeenCalledWith(locationId, entity); expect(markerMapper.entityToDto).toHaveBeenCalledWith(entity); }); }); describe('findAll', () => { it('should find all', async () => { - vi.spyOn(entityManagerService, 'getAll').mockImplementation( - () => new Promise((resolve) => resolve([entity])), - ); + vi.spyOn(entityManagerService, 'get').mockImplementation(() => new Promise((resolve) => resolve(location))); - const result = await service.findAll(); + const result = await service.findAll(locationId); expect(result).toBeDefined(); expect(result.length).toBe(1); expect(result[0]).toBeDefined(); - expect(entityManagerService.getAll).toHaveBeenCalledWith(Marker.TYPE); + expect(entityManagerService.get).toHaveBeenCalledWith(Location.TYPE, locationId); expect(markerMapper.entityToDto).toHaveBeenCalledWith(entity); }); }); describe('findOne', () => { it('should find one', async () => { - vi.spyOn(entityManagerService, 'get').mockImplementation( - () => new Promise((resolve) => resolve(entity)), - ); + vi.spyOn(entityManagerService, 'get').mockImplementation(() => new Promise((resolve) => resolve(location))); - const result = await service.findOne(id); + const result = await service.findOne(locationId, id); expect(result).toBeDefined(); - expect(entityManagerService.get).toHaveBeenCalledWith(Marker.TYPE, id); + expect(entityManagerService.get).toHaveBeenCalledWith(Location.TYPE, locationId); expect(markerMapper.entityToDto).toHaveBeenCalledWith(entity); }); }); @@ -110,37 +100,23 @@ describe('MarkerService', () => { describe('update', () => { it('should update', async () => { const updateDto = new UpdateMarkerDto({}); - vi.spyOn(entityManagerService, 'update').mockImplementation( - () => new Promise((resolve) => resolve(entity)), - ); + vi.spyOn(locationService, 'updateMarker').mockImplementation(() => new Promise((resolve) => resolve(entity))); - const result = await service.update(id, updateDto); + const result = await service.update(locationId, id, updateDto); expect(result).toBeDefined(); - expect(entityManagerService.update).toHaveBeenCalledWith( - Marker.TYPE, - entity, - ); + expect(locationService.updateMarker).toHaveBeenCalledWith(locationId, entity); expect(markerMapper.entityToDto).toHaveBeenCalledWith(entity); }); }); describe('delete', () => { it('should delete', async () => { - const entity = new Marker({ id: id }); - vi.spyOn(entityManagerService, 'get').mockImplementation( - () => new Promise((resolve) => resolve(entity)), - ); - vi.spyOn(entityManagerService, 'delete').mockImplementation( - () => new Promise((resolve) => resolve()), - ); - - await service.delete(id); - - expect(entityManagerService.delete).toHaveBeenCalledWith( - Marker.TYPE, - entity, - ); + vi.spyOn(locationService, 'deleteMarker').mockImplementation(() => new Promise((resolve) => resolve())); + + await service.delete(locationId, id); + + expect(locationService.deleteMarker).toHaveBeenCalledWith(locationId, id); expect(markerMapper.entityToDto).not.toHaveBeenCalled(); }); }); @@ -149,26 +125,24 @@ describe('MarkerService', () => { it('should get image', async () => { const imageId = 'xyz-789'; const entity = new Marker({ id: id, image: imageId }); - vi.spyOn(entityManagerService, 'get').mockImplementation( - () => new Promise((resolve) => resolve(entity)), - ); + const locationEntity = new Location({ id: locationId, markers: { 'abc-123': entity } }); + vi.spyOn(entityManagerService, 'get').mockImplementation(() => new Promise((resolve) => resolve(locationEntity))); - await service.getImage(id); + await service.getImage(locationId, id); - expect(entityManagerService.get).toHaveBeenCalledWith(Marker.TYPE, id); + expect(entityManagerService.get).toHaveBeenCalledWith(Location.TYPE, locationId); expect(uploadManagerService.get).toHaveBeenCalledWith(imageId); }); it('should throw error without image data', async () => { const entity = new Marker({ id: id }); + const locationEntity = new Location({ id: locationId, markers: { 'abc-123': entity } }); const expectedError = new NotFoundException('No image available'); let errorWasTriggered = false; - vi.spyOn(entityManagerService, 'get').mockImplementation( - () => new Promise((resolve) => resolve(entity)), - ); + vi.spyOn(entityManagerService, 'get').mockImplementation(() => new Promise((resolve) => resolve(locationEntity))); try { - await service.getImage(id); + await service.getImage(locationId, id); } catch (error) { expect(error.toString()).toBe(expectedError.toString()); errorWasTriggered = true; @@ -183,24 +157,15 @@ describe('MarkerService', () => { const imageId = 'xyz-789'; const file = {} as Express.Multer.File; const updatedEntity = new Marker({ id: id, image: imageId }); - vi.spyOn(entityManagerService, 'get').mockImplementation( - () => new Promise((resolve) => resolve(entity)), - ); - vi.spyOn(uploadManagerService, 'upload').mockImplementation( - () => new Promise((resolve) => resolve(imageId)), - ); - vi.spyOn(entityManagerService, 'update').mockImplementation( - () => new Promise((resolve) => resolve(updatedEntity)), - ); - - await service.uploadImage(id, file); - - expect(entityManagerService.get).toHaveBeenCalledWith(Marker.TYPE, id); + vi.spyOn(entityManagerService, 'get').mockImplementation(() => new Promise((resolve) => resolve(location))); + vi.spyOn(uploadManagerService, 'upload').mockImplementation(() => new Promise((resolve) => resolve(imageId))); + vi.spyOn(locationService, 'updateMarker').mockImplementation(() => new Promise((resolve) => resolve(updatedEntity))); + + await service.uploadImage(locationId, id, file); + + expect(entityManagerService.get).toHaveBeenCalledWith(Location.TYPE, locationId); expect(uploadManagerService.upload).toHaveBeenCalledWith(file); - expect(entityManagerService.update).toHaveBeenCalledWith( - Marker.TYPE, - entity, - ); + expect(locationService.updateMarker).toHaveBeenCalledWith(locationId, entity); }); it('should upload image and delete previous one', async () => { @@ -208,25 +173,18 @@ describe('MarkerService', () => { const newImageId = 'xyz-789'; const file = {} as Express.Multer.File; const entity = new Marker({ id: id, image: previousImageId }); + const location = new Location({ id: locationId, markers: { 'abc-123': entity } }); const updatedEntity = new Marker({ id: id, image: newImageId }); - vi.spyOn(entityManagerService, 'get').mockImplementation( - () => new Promise((resolve) => resolve(entity)), - ); - vi.spyOn(uploadManagerService, 'upload').mockImplementation( - () => new Promise((resolve) => resolve(newImageId)), - ); - vi.spyOn(entityManagerService, 'update').mockImplementation( - () => new Promise((resolve) => resolve(updatedEntity)), - ); - - await service.uploadImage(id, file); - - expect(entityManagerService.get).toHaveBeenCalledWith(Marker.TYPE, id); + vi.spyOn(entityManagerService, 'get').mockImplementation(() => new Promise((resolve) => resolve(location))); + vi.spyOn(uploadManagerService, 'upload').mockImplementation(() => new Promise((resolve) => resolve(newImageId))); + vi.spyOn(locationService, 'updateMarker').mockImplementation(() => new Promise((resolve) => resolve(updatedEntity))); + vi.spyOn(uploadManagerService, 'delete').mockImplementation(() => new Promise((resolve) => resolve())); + + await service.uploadImage(locationId, id, file); + + expect(entityManagerService.get).toHaveBeenCalledWith(Location.TYPE, locationId); expect(uploadManagerService.upload).toHaveBeenCalledWith(file); - expect(entityManagerService.update).toHaveBeenCalledWith( - Marker.TYPE, - entity, - ); + expect(locationService.updateMarker).toHaveBeenCalledWith(locationId, updatedEntity); expect(uploadManagerService.delete).toHaveBeenCalledWith(previousImageId); }); }); diff --git a/workspaces/api/src/marker/marker.service.ts b/workspaces/api/src/marker/marker.service.ts index 187c031..50edfac 100644 --- a/workspaces/api/src/marker/marker.service.ts +++ b/workspaces/api/src/marker/marker.service.ts @@ -1,10 +1,11 @@ import { Inject, Injectable, NotFoundException } from '@nestjs/common'; +import { Location } from '../location/entities/location.entity'; +import { LocationService } from '../location/location.service'; import { EntityManagerService } from '../persistence/entity-manager.service'; import { UploadManagerService } from '../persistence/upload-manager.service'; import { CreateMarkerDto } from './dto/create-marker.dto'; import { UpdateMarkerDto } from './dto/update-marker.dto'; import { MarkerDto } from './dto/marker.dto'; -import { Marker } from './entities/marker.entity'; import { MarkerMapperService } from './utils/marker-mapper.service'; @Injectable() @@ -14,54 +15,53 @@ export class MarkerService { @Inject(UploadManagerService.PROVIDER) private readonly uploadManager: UploadManagerService, private readonly mapper: MarkerMapperService, + private readonly locationService: LocationService, ) {} - async create(createMarkerDto: CreateMarkerDto): Promise { + async create(locationId: string, createMarkerDto: CreateMarkerDto): Promise { const entity = this.mapper.dtoToEntity(createMarkerDto); - entity.id = undefined; - const persistedEntity = await this.entityManagerService.create(Marker.TYPE, entity); + const persistedEntity = await this.locationService.addMarker(locationId, entity); return this.mapper.entityToDto(persistedEntity); } - async findAll(): Promise { - const markers = await this.entityManagerService.getAll(Marker.TYPE); - return markers.map((m) => { + async findAll(locationId: string): Promise { + const location = await this.entityManagerService.get(Location.TYPE, locationId); + return Object.values(location.markers).map((m) => { return this.mapper.entityToDto(m); }); } - async findOne(id: string): Promise { - const marker = await this.entityManagerService.get(Marker.TYPE, id); + async findOne(locationId: string, id: string): Promise { + const location = await this.entityManagerService.get(Location.TYPE, locationId); + const marker = location.markers[id]; return this.mapper.entityToDto(marker); } - async update( - id: string, - updateMarkerDto: UpdateMarkerDto, - ): Promise { + async update(locationId: string, id: string, updateMarkerDto: UpdateMarkerDto): Promise { const entity = this.mapper.dtoToEntity(updateMarkerDto); - const updatedEntity = await this.entityManagerService.update(Marker.TYPE, entity); + const updatedEntity = await this.locationService.updateMarker(locationId, entity); return this.mapper.entityToDto(updatedEntity); } - async delete(id: string): Promise { - const marker = await this.entityManagerService.get(Marker.TYPE, id); - await this.entityManagerService.delete(Marker.TYPE, marker); + async delete(locationId: string, id: string): Promise { + await this.locationService.deleteMarker(locationId, id); } - async getImage(id: string): Promise { - const marker = await this.entityManagerService.get(Marker.TYPE, id); + async getImage(locationId: string, id: string): Promise { + const location = await this.entityManagerService.get(Location.TYPE, locationId); + const marker = location.markers[id]; if (!marker.image) { throw new NotFoundException('No image available'); } return this.uploadManager.get(marker.image); } - async uploadImage(id: string, file: Express.Multer.File): Promise { - const marker = await this.entityManagerService.get(Marker.TYPE, id); + async uploadImage(locationId: string, id: string, file: Express.Multer.File): Promise { + const location = await this.entityManagerService.get(Location.TYPE, locationId); + const marker = location.markers[id]; const previousImageId = marker.image; marker.image = await this.uploadManager.upload(file); - const updatedMarker = await this.entityManagerService.update(Marker.TYPE, marker); + const updatedMarker = await this.locationService.updateMarker(locationId, marker); if (previousImageId) { await this.uploadManager.delete(previousImageId); } diff --git a/workspaces/api/src/persistence/entity-manager.service.spec.ts b/workspaces/api/src/persistence/entity-manager.service.spec.ts index bb79e8f..c76e675 100644 --- a/workspaces/api/src/persistence/entity-manager.service.spec.ts +++ b/workspaces/api/src/persistence/entity-manager.service.spec.ts @@ -83,9 +83,7 @@ describe('EntityManagerService', () => { .mockImplementationOnce(() => false) .mockImplementationOnce(() => true); const writeFilePromise = new Promise((_, reject) => reject()); - vi.spyOn(fs.promises, 'writeFile').mockImplementation( - () => writeFilePromise, - ); + vi.spyOn(fs.promises, 'writeFile').mockImplementation(() => writeFilePromise); const exec = async () => await service.onModuleInit(); @@ -98,9 +96,7 @@ describe('EntityManagerService', () => { vi.spyOn(configService, 'getOrThrow').mockReturnValue(dbPath); vi.spyOn(fs, 'existsSync').mockImplementation(() => true); const readFilePromise = new Promise((_, reject) => reject()); - vi.spyOn(fs.promises, 'readFile').mockImplementation( - () => readFilePromise, - ); + vi.spyOn(fs.promises, 'readFile').mockImplementation(() => readFilePromise); const exec = async () => await service.onModuleInit(); @@ -111,12 +107,8 @@ describe('EntityManagerService', () => { vi.spyOn(configService, 'getOrThrow').mockReturnValue(dbPath); vi.spyOn(configService, 'get').mockReturnValue(true); vi.spyOn(fs, 'existsSync').mockImplementation(() => true); - const readFilePromise = new Promise((resolve) => - resolve(Buffer.from('abc')), - ); - vi.spyOn(fs.promises, 'readFile').mockImplementation( - () => readFilePromise, - ); + const readFilePromise = new Promise((resolve) => resolve(Buffer.from('abc'))); + vi.spyOn(fs.promises, 'readFile').mockImplementation(() => readFilePromise); await service.onModuleInit(); @@ -132,12 +124,8 @@ describe('EntityManagerService', () => { vi.spyOn(configService, 'getOrThrow').mockReturnValue(dbPath); vi.spyOn(configService, 'get').mockReturnValue(true); vi.spyOn(fs, 'existsSync').mockImplementation(() => true); - const readFilePromise = new Promise((resolve) => - resolve(Buffer.from('abc')), - ); - vi.spyOn(fs.promises, 'readFile').mockImplementation( - () => readFilePromise, - ); + const readFilePromise = new Promise((resolve) => resolve(Buffer.from('abc'))); + vi.spyOn(fs.promises, 'readFile').mockImplementation(() => readFilePromise); await service.onModuleInit(); }); @@ -197,10 +185,7 @@ describe('EntityManagerService', () => { const actual = await service.create(Marker.TYPE, expected); - expect(jsonDB.push).toHaveBeenCalledWith( - expect.stringContaining('/markers/'), - expected, - ); + expect(jsonDB.push).toHaveBeenCalledWith(expect.stringContaining('/markers/'), expected); expect(actual).toBe(expected); }); @@ -213,10 +198,7 @@ describe('EntityManagerService', () => { const actual = await service.create(Marker.TYPE, expected); - expect(jsonDB.push).toHaveBeenCalledWith( - expect.stringContaining('/markers/'), - expected, - ); + expect(jsonDB.push).toHaveBeenCalledWith(expect.stringContaining('/markers/'), expected); expect(actual).toBe(expected); }); }); @@ -233,11 +215,7 @@ describe('EntityManagerService', () => { const actual = await service.update(Marker.TYPE, expected); expect(jsonDB.getObject).toHaveBeenCalledWith(`/markers/${id}`); - expect(jsonDB.push).toHaveBeenCalledWith( - `/markers/${id}`, - expected, - true, - ); + expect(jsonDB.push).toHaveBeenCalledWith(`/markers/${id}`, expected, false); expect(actual).toBe(expected); expect(actual.id).toBe(id); }); @@ -282,9 +260,7 @@ describe('EntityManagerService', () => { describe('isHealthy', () => { it('should return true, if everything works', async () => { - jsonDB.exists.mockImplementation( - () => new Promise((resolve) => resolve(true)), - ); + jsonDB.exists.mockImplementation(() => new Promise((resolve) => resolve(true))); const actual = await service.isHealthy(); @@ -292,9 +268,7 @@ describe('EntityManagerService', () => { }); it('should return false, if something fails', async () => { - jsonDB.exists.mockImplementation( - () => new Promise((resolve) => resolve(false)), - ); + jsonDB.exists.mockImplementation(() => new Promise((resolve) => resolve(false))); const actual = await service.isHealthy(); diff --git a/workspaces/api/src/persistence/entity-manager.service.ts b/workspaces/api/src/persistence/entity-manager.service.ts index 6da7125..7fbf87e 100644 --- a/workspaces/api/src/persistence/entity-manager.service.ts +++ b/workspaces/api/src/persistence/entity-manager.service.ts @@ -1,13 +1,8 @@ import { existsSync, promises as p } from 'fs'; -import { - Injectable, - Logger, - NotFoundException, - OnModuleInit, -} from '@nestjs/common'; +import { Injectable, Logger, NotFoundException, OnModuleInit } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import * as uuid from '@lukeed/uuid'; -import { JsonDB, Config } from 'node-json-db'; +import { Config, JsonDB } from 'node-json-db'; import { Entity } from './entities/entity'; import { EntityType } from './entities/entity.type'; import { MigrationService } from './migrations/migration.service'; @@ -20,10 +15,7 @@ export class EntityManagerService implements OnModuleInit { private db: JsonDB; - constructor( - private readonly configService: ConfigService, - private readonly migrationService: MigrationService, - ) {} + constructor(private readonly configService: ConfigService, private readonly migrationService: MigrationService) {} async onModuleInit(): Promise { const dbPath = this.configService.getOrThrow('database.path'); @@ -33,9 +25,7 @@ export class EntityManagerService implements OnModuleInit { // Check data storage availability this.logger.debug(`Checking data folder ${dbFilePath}`); if (!existsSync(dbFilePath)) { - this.logger.warn( - 'Target file not found, creating new empty database file', - ); + this.logger.warn('Target file not found, creating new empty database file'); if (!existsSync(dbPath)) { try { await p.mkdir(dbPath, { @@ -72,15 +62,11 @@ export class EntityManagerService implements OnModuleInit { } this.logger.debug('Loading database from ' + dbFilePath); - const humanReadable = this.configService.get( - 'database.humanReadable', - ); + const humanReadable = this.configService.get('database.humanReadable'); await this.migrationService.migrate(dbFilePath, humanReadable); - this.db = new JsonDB( - new Config(dbFilePath, true, humanReadable, '/', true), - ); + this.db = new JsonDB(new Config(dbFilePath, true, humanReadable, '/', true)); } async getAll(entityType: EntityType): Promise { @@ -89,10 +75,7 @@ export class EntityManagerService implements OnModuleInit { return Object.values(data); } - async get( - entityType: EntityType, - id: string, - ): Promise { + async get(entityType: EntityType, id: string): Promise { this.logger.debug(`Load ${entityType} ${id}`); try { return await this.db.getObject(`${entityType}/${id}`); @@ -101,26 +84,18 @@ export class EntityManagerService implements OnModuleInit { } } - async create( - entityType: EntityType, - entity: Type, - ): Promise { + async create(entityType: EntityType, entity: Type): Promise { this.logger.debug(`Create new ${entityType}`); - if (!entity.id) { - entity.id = uuid.v4(); - } + entity.id = uuid.v4(); await this.db.push(`${entityType}/${entity.id}`, entity); return entity; } - async update( - entityType: EntityType, - entity: Type, - ): Promise { + async update(entityType: EntityType, entity: Type): Promise { this.logger.debug(`Update existing ${entityType} ${entity.id}`); try { await this.db.getObject(`${entityType}/${entity.id}`); - await this.db.push(`${entityType}/${entity.id}`, entity, true); + await this.db.push(`${entityType}/${entity.id}`, entity, false); return entity; } catch (error) { throw new NotFoundException('Entity does not exist', { cause: error }); diff --git a/workspaces/api/src/persistence/migrations/migration.ts b/workspaces/api/src/persistence/migrations/migration.ts index a1fa181..0ebfb4a 100644 --- a/workspaces/api/src/persistence/migrations/migration.ts +++ b/workspaces/api/src/persistence/migrations/migration.ts @@ -13,7 +13,7 @@ export abstract class Migration { async migrate(jsonContent: string, humanReadable: boolean): Promise { const obj = JSON.parse(jsonContent); if (!this.isApplicable(jsonContent)) { - if (obj['version'] === this.version()) { + if (obj['version'] >= this.version()) { this.logger.debug(`Migration to version ${this.version()} is already applied, skipped`); } else { this.logger.error(`Migration to version ${this.version()} is not applicable, skipped`); diff --git a/workspaces/api/src/persistence/migrations/v2-to-v3.migration.spec.ts b/workspaces/api/src/persistence/migrations/v2-to-v3.migration.spec.ts new file mode 100644 index 0000000..baca3fd --- /dev/null +++ b/workspaces/api/src/persistence/migrations/v2-to-v3.migration.spec.ts @@ -0,0 +1,67 @@ +import { describe } from 'vitest'; +import { V2ToV3Migration } from './v2-to-v3.migration'; + +describe('V2ToV3Migration', () => { + const migration = new V2ToV3Migration(); + + describe('version', () => { + it('should provide target version number', () => { + const actual = migration.version(); + + expect(actual).toEqual(3); + }); + }); + + describe('isApplicable', () => { + it('should be true for database version 2', () => { + const dbContent = '{"marker":{},"locations":{},"version":2}'; + + const actual = migration.isApplicable(dbContent); + + expect(actual).toEqual(true); + }); + + it('should be false for a version 3 database layout', () => { + const dbContent = '{"locations":{},"version":3}'; + + const actual = migration.isApplicable(dbContent); + + expect(actual).toEqual(false); + }); + }); + + describe('getTransformation', () => { + it('should define a jq transformation command', () => { + const actual = migration.getTransformation(); + + expect(actual).toBeDefined(); + }); + }); + + describe('migrate', () => { + const dbContent = + '{"markers":{"000-aaa":{"id":"000-aaa"}},"locations":{"abc-123":{"id":"abc-123","image":"1234567890abc","width":100,"height":200}},"version":2}'; + + it('should add new properties to "locations"', async () => { + const expected = + '"locations":{"abc-123":{"id":"abc-123","image":"1234567890abc","width":100,"height":200,"name":"Home","shortName":"H","description":"","markers":{"000-aaa":{"id":"000-aaa"}}}}'; + + const actual = await migration.migrate(dbContent, false); + + expect(actual).toContain(expected); + }); + + it('should add version info set to "3"', async () => { + const actual = await migration.migrate(dbContent, true); + + expect(actual).toContain('"version": 3'); + }); + + it('should return original content, if migration is not applicable', async () => { + const dbContent = '{"and now for something":"completely different"}'; + const actual = await migration.migrate(dbContent, false); + + expect(actual).toContain(dbContent); + }); + }); +}); diff --git a/workspaces/api/src/persistence/migrations/v2-to-v3.migration.ts b/workspaces/api/src/persistence/migrations/v2-to-v3.migration.ts new file mode 100644 index 0000000..a41f950 --- /dev/null +++ b/workspaces/api/src/persistence/migrations/v2-to-v3.migration.ts @@ -0,0 +1,22 @@ +import { Injectable } from '@nestjs/common'; +import { Migration } from './migration'; +import { Discover } from '../../registry/discover.decorator'; + +@Injectable() +@Discover('migration') +export class V2ToV3Migration extends Migration { + version(): number { + return 3; + } + + isApplicable(jsonContent: string): boolean { + const obj = JSON.parse(jsonContent); + return !!(obj && obj['version'] && obj['version'] === 2); + } + + getTransformation(): string { + const markers = 'markers: .markers'; + const newLocProps = `{"name": "Home", "shortName": "H", "description": "", ${markers} }`; + return `{ ${markers}, locations: .locations, version: ${this.version()} } | .locations[] += ${newLocProps} | del(.markers)`; + } +} diff --git a/workspaces/api/src/persistence/persistence.module.ts b/workspaces/api/src/persistence/persistence.module.ts index ef0e897..9ca5d50 100644 --- a/workspaces/api/src/persistence/persistence.module.ts +++ b/workspaces/api/src/persistence/persistence.module.ts @@ -6,6 +6,7 @@ import { UploadManagerService } from './upload-manager.service'; import { EntityManagerHealthIndicator } from './entity-manager.health'; import { UploadManagerHealthIndicator } from './upload-manager.health'; import { V1ToV2Migration } from './migrations/v1-to-v2.migration'; +import { V2ToV3Migration } from './migrations/v2-to-v3.migration'; import { MigrationService } from './migrations/migration.service'; const uploadManagerProvider = { @@ -21,6 +22,7 @@ const uploadManagerProvider = { imports: [ConfigModule, RegistryModule], providers: [ V1ToV2Migration, + V2ToV3Migration, MigrationService, EntityManagerService, uploadManagerProvider, diff --git a/workspaces/frontend/src/components/ConfirmationDialog.svelte b/workspaces/frontend/src/components/ConfirmationDialog.svelte index f292a6f..d7eee83 100644 --- a/workspaces/frontend/src/components/ConfirmationDialog.svelte +++ b/workspaces/frontend/src/components/ConfirmationDialog.svelte @@ -1,4 +1,4 @@ - - - diff --git a/workspaces/frontend/src/components/FloorPlanUploadButton.svelte b/workspaces/frontend/src/components/FloorPlanUploadButton.svelte index acb6437..b6f1416 100644 --- a/workspaces/frontend/src/components/FloorPlanUploadButton.svelte +++ b/workspaces/frontend/src/components/FloorPlanUploadButton.svelte @@ -1,13 +1,10 @@ diff --git a/workspaces/frontend/src/components/InfoPaneSideBar.spec.ts b/workspaces/frontend/src/components/InfoPaneSideBar.spec.ts index ce1f249..24c3718 100644 --- a/workspaces/frontend/src/components/InfoPaneSideBar.spec.ts +++ b/workspaces/frontend/src/components/InfoPaneSideBar.spec.ts @@ -1,10 +1,11 @@ import { fireEvent, render, screen } from '@testing-library/svelte'; -import { vi } from 'vitest'; +import { beforeEach, describe, expect, test, vi } from 'vitest'; import { get } from 'svelte/store'; import { markerStore } from '../stores/markers'; +import { locationStore } from '../stores/locations'; +import { markerTypeStore } from '../stores/markerTypes'; import { viewport } from '../ts/ViewportSingleton'; import InfoPaneSideBar from './InfoPaneSideBar.svelte'; -import { markerTypeStore } from '../stores/markerTypes'; describe('InfoPaneSideBar', () => { test('should be initialized', async () => { @@ -16,6 +17,7 @@ describe('InfoPaneSideBar', () => { describe('initialized with a marker', () => { beforeEach(async () => { + await locationStore.init(); await markerTypeStore.init(); await markerStore.init(); const marker = get(markerStore)[1]; diff --git a/workspaces/frontend/src/components/InfoPaneSideBar.svelte b/workspaces/frontend/src/components/InfoPaneSideBar.svelte index 0134d02..35b0a67 100644 --- a/workspaces/frontend/src/components/InfoPaneSideBar.svelte +++ b/workspaces/frontend/src/components/InfoPaneSideBar.svelte @@ -3,12 +3,13 @@ import RangeSlider from 'svelte-range-slider-pips'; import { afterUpdate } from 'svelte'; import { markerStore } from '../stores/markers.js'; - import { viewport } from '../ts/ViewportSingleton'; + import { currentLocation } from '../stores/locations'; + import { markerTypeVariantByName } from '../ts/MarkerType'; import { generateMarker } from '../ts/Marker'; + import { viewport } from '../ts/ViewportSingleton'; import { getApiUrl } from '../ts/ApiUrl'; import ShareButton from './ShareButton.svelte'; import ConfirmationDialog from './ConfirmationDialog.svelte'; - import { markerTypeVariantByName } from '../ts/MarkerType'; let editMode = false; let dragMode = false; @@ -39,7 +40,7 @@ avatar = 'https://via.placeholder.com/100/575757/fff?text=Upload'; } if (viewMarker?.image) { - avatar = getApiUrl(`marker/${viewMarker.id}/image`); + avatar = getApiUrl(`locations/${$currentLocation.id}/markers/${viewMarker.id}/image`); } } @@ -108,7 +109,7 @@ if (files && files[0]) { const formData = new FormData(); formData.append('file', files[0]); - const response = await fetch(getApiUrl(`marker/${editMarker.id}/image/upload`), { + const response = await fetch(getApiUrl(`locations/${$currentLocation.id}/markers/${editMarker.id}/image/upload`), { method: 'POST', body: formData, }); @@ -131,7 +132,7 @@ }; afterUpdate(() => { - document.dispatchEvent(new CustomEvent('infopane', { detail: { open: !!viewMarker } })); + document.dispatchEvent(new CustomEvent('sidebar', { detail: { open: !!viewMarker } })); }); diff --git a/workspaces/frontend/src/components/NavigationItem.svelte b/workspaces/frontend/src/components/NavigationItem.svelte index ca0b4d7..b7a1f17 100644 --- a/workspaces/frontend/src/components/NavigationItem.svelte +++ b/workspaces/frontend/src/components/NavigationItem.svelte @@ -1,10 +1,10 @@ - - + {#if $$slots.actions} + + {/if} diff --git a/workspaces/frontend/src/components/NavigationSideBar.spec.ts b/workspaces/frontend/src/components/NavigationSideBar.spec.ts index 7018374..e0d2eff 100644 --- a/workspaces/frontend/src/components/NavigationSideBar.spec.ts +++ b/workspaces/frontend/src/components/NavigationSideBar.spec.ts @@ -1,14 +1,16 @@ import { fireEvent, render, screen, waitFor } from '@testing-library/svelte'; -import { beforeEach, describe, test, vi } from 'vitest'; +import { beforeEach, describe, expect, test, vi } from 'vitest'; import { get } from 'svelte/store'; -import { markerStore, markerTypeTooltips, markerTypeVisibility } from '../stores/markers'; +import { locationStore } from '../stores/locations'; +import { markerTypeStore } from '../stores/markerTypes'; +import { markerStore } from '../stores/markers'; import { viewport, viewportInitialized } from '../ts/ViewportSingleton'; import NavigationSideBar from './NavigationSideBar.svelte'; -import { markerTypeStore } from '../stores/markerTypes'; describe('NavigationSideBar', () => { beforeEach(async () => { await markerTypeStore.init(); + await locationStore.init(); await markerStore.init(); viewportInitialized.set(true); }); @@ -19,28 +21,6 @@ describe('NavigationSideBar', () => { expect(screen.getByPlaceholderText('Search')).toBeInTheDocument(); }); - test('should search for markers', async () => { - render(NavigationSideBar); - - const searchField = screen.getByPlaceholderText('Search'); - const removeLayerSpy = vi.spyOn(viewport, 'removeLayer'); - - await fireEvent.input(searchField, { target: { value: 'Some' } }); - - expect(removeLayerSpy).toHaveBeenCalledTimes(1); - }); - - test('should have rows for each marker type', async () => { - render(NavigationSideBar); - - expect(screen.getByRole('button', { name: new RegExp('Table 0') })).toBeInTheDocument(); - expect(screen.getByRole('button', { name: new RegExp('Person 1') })).toBeInTheDocument(); - expect(screen.getByRole('button', { name: new RegExp('Room 1') })).toBeInTheDocument(); - expect(screen.getByRole('button', { name: new RegExp('Toilet 0') })).toBeInTheDocument(); - expect(screen.getByRole('button', { name: new RegExp('Emergency 0') })).toBeInTheDocument(); - expect(screen.getByRole('button', { name: new RegExp('Other 0') })).toBeInTheDocument(); - }); - test('should be collapsible/expandable', async () => { render(NavigationSideBar); @@ -58,79 +38,6 @@ describe('NavigationSideBar', () => { expect(screen.queryByRole('button', { name: 'chevron_right' })).not.toBeInTheDocument(); }); - describe('visibility', () => { - test('should be enabled for all marker types', async () => { - render(NavigationSideBar); - - get(markerTypeStore).forEach((markerType) => { - const visibility = get(markerTypeVisibility)[markerType.id]; - expect(visibility).toBe(true); - }); - }); - - test('clicking button, should hide all markers of type', async () => { - render(NavigationSideBar); - - const personButton = screen.getByRole('button', { name: new RegExp('^Person') }); - const removeLayerSpy = vi.spyOn(viewport, 'removeLayer'); - - await fireEvent.click(personButton); - - // person markers invisible and actions deactivated - const currentMarkerTypeVisibility = get(markerTypeVisibility); - expect(currentMarkerTypeVisibility['person']).toBe(false); - expect(screen.getByTitle('Toggle tooltips of Person markers')).toBeDisabled(); - expect(screen.getByTitle('Create new Person marker')).toBeDisabled(); - - // others are still visible - const markerTypeStoreContent = get(markerTypeStore); - delete currentMarkerTypeVisibility['person']; - Object.keys(currentMarkerTypeVisibility).forEach((markerType) => { - expect(currentMarkerTypeVisibility[markerType]).toBe(true); - const mType = markerTypeStoreContent.find((mt) => mt.id === markerType); - if (markerType === 'Table') { - expect(screen.getByTitle(`Toggle tooltips of ${mType.name} markers`)).toBeDisabled(); - expect(screen.getByTitle(`Create new ${mType.name} marker`)).toBeDisabled(); - } else { - expect(screen.getByTitle(`Toggle tooltips of ${mType.name} markers`)).toBeEnabled(); - expect(screen.getByTitle(`Create new ${mType.name} marker`)).toBeEnabled(); - } - }); - - expect(removeLayerSpy).toHaveBeenCalledTimes(1); - }); - }); - - describe('marker tooltips', () => { - test('should be enabled for all marker types', async () => { - render(NavigationSideBar); - - const currentMarkerTypeTooltips = get(markerTypeTooltips); - expect(currentMarkerTypeTooltips['table']).toBe(false); - expect(currentMarkerTypeTooltips['person']).toBe(true); - expect(currentMarkerTypeTooltips['room']).toBe(true); - expect(currentMarkerTypeTooltips['toilet']).toBe(false); - expect(currentMarkerTypeTooltips['emergency']).toBe(true); - expect(currentMarkerTypeTooltips['other']).toBe(false); - }); - - test('clicking button, should toggle visibility of tooltips for all markers of type', async () => { - render(NavigationSideBar); - - const roomButton = screen.getByTitle('Toggle tooltips of Room markers'); - - await fireEvent.click(roomButton); - - const currentMarkerTypeTooltips = get(markerTypeTooltips); - expect(currentMarkerTypeTooltips['table']).toBe(false); - expect(currentMarkerTypeTooltips['person']).toBe(true); - expect(currentMarkerTypeTooltips['room']).toBe(false); - expect(currentMarkerTypeTooltips['toilet']).toBe(false); - expect(currentMarkerTypeTooltips['emergency']).toBe(true); - expect(currentMarkerTypeTooltips['other']).toBe(false); - }); - }); - describe('create marker', () => { test('clicking button should create a new marker', async () => { render(NavigationSideBar); diff --git a/workspaces/frontend/src/components/NavigationSideBar.svelte b/workspaces/frontend/src/components/NavigationSideBar.svelte index a85994b..05d3b2b 100644 --- a/workspaces/frontend/src/components/NavigationSideBar.svelte +++ b/workspaces/frontend/src/components/NavigationSideBar.svelte @@ -7,6 +7,8 @@ import { viewport } from '../ts/ViewportSingleton'; import { generateMarker } from '../ts/Marker'; import NavigationItem from './NavigationItem.svelte'; + import { currentLocation, locationStore } from '../stores/locations'; + import { Location } from '../ts/Location'; let slim = false; @@ -29,8 +31,14 @@ }); } - const selectLocation = (location: string): void => { - // do nothing yet + const createLocation = (): void => { + const newLocation = new Location({ name: '[New Location]', shortName: '--' }); + locationStore.createItem(newLocation); + }; + + const selectLocation = (location: Location): void => { + currentLocation.set(location); + document.dispatchEvent(new CustomEvent('location', { detail: { action: 'select', location: location } })); }; function createMarker(markerType: MType): void { @@ -85,24 +93,26 @@ diff --git a/workspaces/frontend/src/components/Screen.svelte b/workspaces/frontend/src/components/Screen.svelte index bd380d0..68252db 100644 --- a/workspaces/frontend/src/components/Screen.svelte +++ b/workspaces/frontend/src/components/Screen.svelte @@ -5,7 +5,7 @@ import InfoPane from './InfoPaneSideBar.svelte'; import Navigation from './NavigationSideBar.svelte'; import { markerStore } from '../stores/markers'; - import FloorPlanPaneSideBar from './FloorPlanPaneSideBar.svelte'; + import FloorPlanPaneSideBar from './locations/LocationSideBar.svelte'; export let params = {}; diff --git a/workspaces/frontend/src/components/locations/FloorPlanUpload.svelte b/workspaces/frontend/src/components/locations/FloorPlanUpload.svelte new file mode 100644 index 0000000..20b5cec --- /dev/null +++ b/workspaces/frontend/src/components/locations/FloorPlanUpload.svelte @@ -0,0 +1,124 @@ + + +
+
+

{$_('location.floorplan.title')}

+

{$_(`location.floorplan.description`)}

+ previewImage(files[0])} /> + {#if imageDimensions} +

+ {$_(`location.floorplan.dimensions`)} + {imageDimensions.width} x {imageDimensions.height} +

+ {/if} +
+ {#if files && files[0]} +
+ + + + + + + + + +
{$_(`location.floorplan.scaleX`)} + previewImage(files[0])} + bind:value={imageManipulation.scaleX} /> +
{$_(`location.floorplan.scaleY`)} + previewImage(files[0])} + bind:value={imageManipulation.scaleY} /> +
+ + + +
+ {/if} +
diff --git a/workspaces/frontend/src/components/locations/LocationSideBar.spec.ts b/workspaces/frontend/src/components/locations/LocationSideBar.spec.ts new file mode 100644 index 0000000..8f10aaa --- /dev/null +++ b/workspaces/frontend/src/components/locations/LocationSideBar.spec.ts @@ -0,0 +1,42 @@ +import { beforeEach, describe, expect, it } from 'vitest'; +import { fireEvent, render, screen } from '@testing-library/svelte'; +import { currentLocation } from '../../stores/locations'; +import { Location } from '../../ts/Location'; +import LocationSideBar from './LocationSideBar.svelte'; + +describe('LocationSideBar', () => { + const location = new Location({ + name: 'abc', + shortName: 'def', + description: 'ghi', + width: 123, + height: 456, + }); + + beforeEach(async () => { + currentLocation.set(location); + }); + + it('should be initialized', async () => { + render(LocationSideBar); + + expect(screen.getByTestId('floorPlanPane')).toBeInTheDocument(); + expect(screen.getByTestId('floorPlanPane-form')).toBeInTheDocument(); + }); + + describe('initialized with a marker', () => { + beforeEach(async () => { + render(LocationSideBar); + + fireEvent(document, new CustomEvent('location', { detail: { action: 'select' } })); + }); + + it('should display image dimensions', async () => { + expect(screen.getByTestId('floorPlanPane-dimensions')).toBeInTheDocument(); + expect(screen.getByDisplayValue(location.name)).toBeInTheDocument(); + expect(screen.getByDisplayValue(location.shortName)).toBeInTheDocument(); + expect(screen.getByDisplayValue(location.description)).toBeInTheDocument(); + expect(screen.getByTestId('floorPlanPane-dimensions').textContent).toEqual('123 x 456'); + }); + }); +}); diff --git a/workspaces/frontend/src/components/locations/LocationSideBar.svelte b/workspaces/frontend/src/components/locations/LocationSideBar.svelte new file mode 100644 index 0000000..85726f6 --- /dev/null +++ b/workspaces/frontend/src/components/locations/LocationSideBar.svelte @@ -0,0 +1,148 @@ + + + diff --git a/workspaces/frontend/src/desk-compass.postcss b/workspaces/frontend/src/desk-compass.postcss index c2a3737..d3957e6 100644 --- a/workspaces/frontend/src/desk-compass.postcss +++ b/workspaces/frontend/src/desk-compass.postcss @@ -222,7 +222,7 @@ @apply w-12; } .nav-section { - @apply text-grey uppercase mt-8 mb-1 px-10 font-light text-xs; + @apply text-grey-800 uppercase mt-8 mb-1 pl-10 pr-6 font-light text-xs; } .nav-item-list { @apply flex flex-col gap-px my-2 md:my-0; diff --git a/workspaces/frontend/src/i18n/de.json b/workspaces/frontend/src/i18n/de.json index 456f005..744e59f 100644 --- a/workspaces/frontend/src/i18n/de.json +++ b/workspaces/frontend/src/i18n/de.json @@ -14,16 +14,28 @@ } }, "location": { - "upload": "Grundriss anpassen", - "sidebar": { + "upload": "Ort anpassen", + "action": { + "save": "Speichern", + "cancel": "Abbrechen", + "delete": "Löschen", + "confirm": { + "title": "Ort \"{name}\" löschen?", + "description": "Bist du sicher, dass du diesen Ort löschen möchtest?" + } + }, + "edit": { + "name": "Name", + "short": "Kurzname", + "description": "Beschreibung", + "dimensions": "Bildgröße" + }, + "floorplan": { + "title": "Grundriss hochladen", "description": "Lade ein Bild hoch, um die Darstellung als Karte und die Positionen der Marker zu prüfen:", "dimensions": "Bildgröße:", "scaleX": "Bildskalierung X", - "scaleY": "Bildskalierung Y", - "action": { - "save": "Speichern", - "cancel": "Abbrechen" - } + "scaleY": "Bildskalierung Y" } }, "marker": { @@ -56,6 +68,9 @@ "tooltips": { "toggle": "Label für {type} umschalten" } + }, + "locations": { + "title": "Orte" } } } diff --git a/workspaces/frontend/src/i18n/en.json b/workspaces/frontend/src/i18n/en.json index ff3efcb..cc03e7c 100644 --- a/workspaces/frontend/src/i18n/en.json +++ b/workspaces/frontend/src/i18n/en.json @@ -14,16 +14,28 @@ } }, "location": { - "upload": "Upload floor plan", - "sidebar": { - "description": "Upload an image to preview fit with the dimensions and marker positions:", + "upload": "Edit location", + "action": { + "save": "Save", + "cancel": "Cancel", + "delete": "Delete", + "confirm": { + "title": "Delete location \"{name}\"?", + "description": "Are you sure, you want to delete this location and its markers permanently?" + } + }, + "edit": { + "name": "Name", + "short": "Short name", + "description": "Description", + "dimensions": "Dimensions" + }, + "floorplan": { + "title": "Upload floor plan", + "description": "Upload an image and preview fit with the dimensions and marker positions:", "dimensions": "Image Dimensions:", "scaleX": "Scale image X", - "scaleY": "Scale image Y", - "action": { - "save": "Save", - "cancel": "Cancel" - } + "scaleY": "Scale image Y" } }, "marker": { diff --git a/workspaces/frontend/src/mocks/handlers.ts b/workspaces/frontend/src/mocks/handlers.ts index 3302658..c7c69c6 100644 --- a/workspaces/frontend/src/mocks/handlers.ts +++ b/workspaces/frontend/src/mocks/handlers.ts @@ -1,16 +1,27 @@ import { rest } from 'msw'; // Mock Data +const locations = [ + { + id: '1000', + name: 'Location', + shortName: 'loc', + description: 'This is an example location', + image: '53a9fe58-3036-47d3-838f-6e3e5300f322', + width: 123, + height: 456, + }, +]; export const markers = [ { - id: 1, + id: '1', lat: 1496, lng: 3676, name: 'First person', icon: 'person', type: 'person', rotation: 90, - image: '', + image: 'c4c980ca-328c-417e-a0dd-881afec4dfe3', attributes: { Post: 'Employee', Skype: 'skyper01', @@ -24,7 +35,7 @@ export const markers = [ }, }, { - id: 1, + id: '2', lat: 1496, lng: 3676, name: 'Some Room', @@ -43,25 +54,48 @@ export const handlers = [ rest.get('api', (req, res, ctx) => { return res(ctx.status(200), ctx.text('dummy')); }), - rest.get('api/info', (req, res, ctx) => { - return res(ctx.status(200), ctx.json({ width: 1000, height: 1000 })); + + rest.get('api/locations', (req, res, ctx) => { + return res(ctx.status(200), ctx.json(locations)); + }), + + rest.post('api/locations', async (req, res, ctx) => { + const newLocation = await req.json(); + return res(ctx.status(200), ctx.json(newLocation)); + }), + + rest.get('api/locations/1000', (req, res, ctx) => { + return res(ctx.status(200), ctx.json(locations[0])); + }), + + rest.put('api/locations/1000', async (req, res, ctx) => { + const newLocation = await req.json(); + return res(ctx.status(200), ctx.json(newLocation)); + }), + + rest.delete('api/locations/1000', async (req, res, ctx) => { + return res(ctx.status(200), ctx.json({})); }), // marker endpoints - rest.get('api/marker', (req, res, ctx) => { + rest.get('api/locations/1000/markers', (req, res, ctx) => { return res(ctx.status(200), ctx.json(markers)); }), - rest.put('api/marker/1', (req, res, ctx) => { + rest.post('api/locations/1000/markers', async (req, res, ctx) => { + const newMarker = await req.json(); + return res(ctx.status(200), ctx.json(newMarker)); + }), + + rest.put('api/locations/1000/markers/1', (req, res, ctx) => { return res(ctx.status(200), ctx.json(markers[0])); }), - rest.post('api/marker', async (req, res, ctx) => { - const newMarker = await req.json(); - return res(ctx.status(200), ctx.json(newMarker)); + rest.put('api/locations/1000/markers/2', (req, res, ctx) => { + return res(ctx.status(200), ctx.json(markers[1])); }), - rest.delete('api/marker/1', async (req, res, ctx) => { + rest.delete('api/locations/1000/markers/1', async (req, res, ctx) => { return res(ctx.status(200), ctx.json({})); }), ]; diff --git a/workspaces/frontend/src/mocks/mockViewportSingleton.ts b/workspaces/frontend/src/mocks/mockViewportSingleton.ts index d621727..0955f79 100644 --- a/workspaces/frontend/src/mocks/mockViewportSingleton.ts +++ b/workspaces/frontend/src/mocks/mockViewportSingleton.ts @@ -1,6 +1,6 @@ import { beforeEach, vi } from 'vitest'; import type { LatLng, Layer, Map, PointExpression } from 'leaflet'; -import { point, latLng, Renderer, latLngBounds } from 'leaflet'; +import { latLng, latLngBounds, point, Renderer } from 'leaflet'; import { setViewport, Viewport } from '../ts/ViewportSingleton'; import type { ImageDimensions } from '../ts/ImageDimensions'; @@ -30,7 +30,7 @@ const _mockViewport = { _leafletMap: mockMap, getLeafletMap: () => mockMap, - getImageUrl: () => vi.fn(), + getImageBounds: () => latLngBounds([1, 2], [3, 4]), getImageDimensions: () => vi.fn(), diff --git a/workspaces/frontend/src/stores/locations.spec.ts b/workspaces/frontend/src/stores/locations.spec.ts new file mode 100644 index 0000000..f3f1336 --- /dev/null +++ b/workspaces/frontend/src/stores/locations.spec.ts @@ -0,0 +1,83 @@ +import { waitFor } from '@testing-library/svelte'; +import { afterEach, beforeEach, describe, expect, test } from 'vitest'; +import { get } from 'svelte/store'; +import { server } from '../mocks/server'; +import { Location } from '../ts/Location'; +import { locationStore } from './locations'; + +describe('Locations store', () => { + beforeEach(async () => { + await locationStore.init(); + }); + + afterEach(async () => { + server.resetHandlers(); + }); + + test('should be initialized', async () => { + expect(locationStore).not.toBeNull(); + + const locations = get(locationStore); + expect(locations).toHaveLength(1); + expect(locations[0].name).toBe('Location'); + }); + + test('should create new location', async () => { + const locationsBeforeCreation = get(locationStore).length; + const expectedLocationName = `[New Location]`; + const location = new Location({ + name: expectedLocationName, + }); + + locationStore.createItem(location); + + await waitFor(() => { + const currentLocationStore = get(locationStore); + if (currentLocationStore.length === locationsBeforeCreation) { + throw new Error('Location store did not change'); + } + return true; + }); + + const locations = get(locationStore); + expect(locations).toHaveLength(locationsBeforeCreation + 1); + expect(locations[0].name).toBe('Location'); + expect(locations[1].name).toBe(expectedLocationName); + }); + + test('should update location', async () => { + const location = get(locationStore)[0]; + const originalName = location.name; + location.name = 'CHANGED NAME'; + + locationStore.updateItem(location); + + await waitFor(() => { + const currentLocationStore = get(locationStore); + if (currentLocationStore[0].name === originalName) { + throw new Error('Location did not change'); + } + return true; + }); + + expect(get(locationStore)[0].name).toBe('CHANGED NAME'); + }); + + test('should remove location', async () => { + const locationStoreBeforeUpdate = get(locationStore); + const locationStoreSizeBeforeUpdate = locationStoreBeforeUpdate.length; + + locationStore.deleteItem(locationStoreBeforeUpdate[0]); + + await waitFor(() => { + const currentLocationStore = get(locationStore); + if (currentLocationStore.length === locationStoreSizeBeforeUpdate) { + throw new Error('Location store did not change'); + } + return true; + }); + + const locations = get(locationStore); + expect(locations).toHaveLength(locationStoreSizeBeforeUpdate - 1); + }); +}); diff --git a/workspaces/frontend/src/stores/locations.ts b/workspaces/frontend/src/stores/locations.ts new file mode 100644 index 0000000..e9c3ffc --- /dev/null +++ b/workspaces/frontend/src/stores/locations.ts @@ -0,0 +1,102 @@ +import type { Writable } from 'svelte/store'; +import { writable } from 'svelte/store'; +import fetch from 'cross-fetch'; +import { getApiUrl } from '../ts/ApiUrl'; +import { Location } from '../ts/Location'; +import type { RemoteWritable } from './RemoteWritable'; + +export const currentLocation = writable(); + +const createLocationStore = (): RemoteWritable => { + const internalStore: Writable = writable([]); + const { subscribe, update, set } = internalStore; + + const _init = async (): Promise => { + const res = await fetch(getApiUrl('locations')); + if (res.ok) { + const json: any[] = await res.json(); + const resultObjects: Location[] = json.map((item) => new Location(item)).filter((item) => item !== null); + set(resultObjects); + currentLocation.set(resultObjects[0]); + } + return res.ok; + }; + + const _createItem = async (obj: Location) => { + const locationDto = obj.getDto(); + + const response = await fetch(getApiUrl('locations'), { + method: 'POST', + mode: 'cors', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify(locationDto), + }); + if (response.ok) { + const responseJson = await response.json(); + locationDto.id = responseJson.id; + const location = new Location(locationDto); + update((l) => { + l.push(location); + return l; + }); + currentLocation.set(location); + document.dispatchEvent(new CustomEvent('location', { detail: { action: 'select', location: location } })); + } else { + console.error('Error:', response.statusText); + } + }; + + const _updateItem = (obj: Location) => + update((l) => { + const item = l.find((x) => x.id === obj.id); + if (!item) { + throw new Error('Cannot update non-existing location'); + } + fetch(getApiUrl(`locations/${obj.id}`), { + method: 'PUT', + mode: 'cors', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify(obj.getDto()), + }).catch((error) => { + console.error('Error:', error); + }); + + return l; + }); + + const _deleteItem = async (obj: Location) => { + const response = await fetch(getApiUrl(`locations/${obj.id}`), { + method: 'DELETE', + mode: 'cors', + headers: { + 'Content-Type': 'application/json', + }, + }); + if (response.ok) { + // remove from locations + update((l) => { + const indexToRemove = l.indexOf(obj); + l.splice(indexToRemove, 1); + return l; + }); + } else { + console.error('Error:', response.statusText); + } + }; + + return { + subscribe, + set, + update, + init: _init, + createItem: _createItem, + updateItem: _updateItem, + deleteItem: _deleteItem, + }; +}; + +export const locationStore = createLocationStore(); diff --git a/workspaces/frontend/src/stores/markers.spec.ts b/workspaces/frontend/src/stores/markers.spec.ts index 328c28a..a658647 100644 --- a/workspaces/frontend/src/stores/markers.spec.ts +++ b/workspaces/frontend/src/stores/markers.spec.ts @@ -1,12 +1,15 @@ import { waitFor } from '@testing-library/svelte'; +import { afterEach, beforeEach, describe, expect, test } from 'vitest'; import { get } from 'svelte/store'; import { server } from '../mocks/server'; import { generateMarker } from '../ts/Marker'; -import { markerStore } from './markers'; import { markerTypeStore } from './markerTypes'; +import { locationStore } from './locations'; +import { markerStore } from './markers'; describe('Markers store', () => { beforeEach(async () => { + await locationStore.init(); await markerTypeStore.init(); await markerStore.init(); }); diff --git a/workspaces/frontend/src/stores/markers.ts b/workspaces/frontend/src/stores/markers.ts index 58e90ca..d12bc68 100644 --- a/workspaces/frontend/src/stores/markers.ts +++ b/workspaces/frontend/src/stores/markers.ts @@ -1,13 +1,14 @@ -import { derived, writable } from 'svelte/store'; import type { Writable } from 'svelte/store'; +import { derived, get, writable } from 'svelte/store'; import fetch from 'cross-fetch'; import { getApiUrl } from '../ts/ApiUrl'; import { generateMarker, Marker } from '../ts/Marker'; import { viewport, viewportInitialized } from '../ts/ViewportSingleton'; -import { generateMarkerTypeTableFromProperty, generateMarkerTypeTableWithDefaultValue } from '../ts/MarkerTypeTable'; import type { MarkerTypeTable } from '../ts/MarkerTypeTable'; +import { generateMarkerTypeTableFromProperty, generateMarkerTypeTableWithDefaultValue } from '../ts/MarkerTypeTable'; import type { RemoteWritable } from './RemoteWritable'; import { markerTypeStore } from './markerTypes'; +import { currentLocation } from './locations'; const createMarkerStore = (): RemoteWritable => { const internalStore: Writable = writable([]); @@ -18,7 +19,7 @@ const createMarkerStore = (): RemoteWritable => { set, update, init: async (): Promise => { - const res = await fetch(getApiUrl('marker')); + const res = await fetch(getApiUrl(`locations/${get(currentLocation).id}/markers`)); if (res.ok) { const json: any[] = await res.json(); const resultObjects: Marker[] = json.map((item) => generateMarker(item)).filter((item) => item !== null); @@ -29,7 +30,7 @@ const createMarkerStore = (): RemoteWritable => { createItem: (obj: Marker) => { const markerDto = obj.getDto(); - fetch(getApiUrl('marker'), { + fetch(getApiUrl(`locations/${get(currentLocation).id}/markers`), { method: 'POST', mode: 'cors', headers: { @@ -62,7 +63,7 @@ const createMarkerStore = (): RemoteWritable => { if (!item) { throw new Error('Cannot update non-existing marker'); } - fetch(getApiUrl(`marker/${obj.id}`), { + fetch(getApiUrl(`locations/${get(currentLocation).id}/markers/${obj.id}`), { method: 'PUT', mode: 'cors', headers: { @@ -80,7 +81,7 @@ const createMarkerStore = (): RemoteWritable => { return m; }), deleteItem: (obj: Marker) => - fetch(getApiUrl(`marker/${obj.id}`), { + fetch(getApiUrl(`locations/${get(currentLocation).id}/markers/${obj.id}`), { method: 'DELETE', mode: 'cors', headers: { @@ -115,6 +116,7 @@ export const visibleMarkers = derived( return; } + viewport.clearLayers(); $markerStore.forEach((marker) => { // set visibility const shouldBeVisible = diff --git a/workspaces/frontend/src/ts/FloorPlanUploadMapControl.ts b/workspaces/frontend/src/ts/FloorPlanUploadMapControl.ts index 248967f..9543b31 100644 --- a/workspaces/frontend/src/ts/FloorPlanUploadMapControl.ts +++ b/workspaces/frontend/src/ts/FloorPlanUploadMapControl.ts @@ -1,6 +1,6 @@ import * as L from 'leaflet'; -import FloorPlanUploadButton from '../components/FloorPlanUploadButton.svelte'; import { ImageOverlay } from 'leaflet'; +import FloorPlanUploadButton from '../components/FloorPlanUploadButton.svelte'; export class FloorPlanUploadMapControl extends L.Control { _uploadButton: FloorPlanUploadButton; @@ -21,9 +21,6 @@ export class FloorPlanUploadMapControl extends L.Control { container.className = 'leaflet-bar leaflet-control hidden md:block'; this._uploadButton = new FloorPlanUploadButton({ target: container, - props: { - targetImageOverlay: this.imageOverlay, - }, }); return container; diff --git a/workspaces/frontend/src/ts/Location.spec.ts b/workspaces/frontend/src/ts/Location.spec.ts new file mode 100644 index 0000000..34edbb3 --- /dev/null +++ b/workspaces/frontend/src/ts/Location.spec.ts @@ -0,0 +1,63 @@ +import { describe, expect, test } from 'vitest'; +import { Location } from './Location'; + +describe('Location', () => { + const locationPartial = { + id: 'a', + name: 'b', + shortName: 'c', + description: 'd', + image: 'e', + width: 111, + height: 222, + }; + const location = new Location(locationPartial); + + describe('create', () => { + test('should create a location', () => { + const actual = new Location(locationPartial); + + expect(actual).toBeDefined(); + expect(actual.id).toEqual('a'); + expect(actual.name).toEqual('b'); + expect(actual.shortName).toEqual('c'); + expect(actual.description).toEqual('d'); + expect(actual.image).toEqual('e'); + expect(actual.width).toEqual(111); + expect(actual.height).toEqual(222); + }); + }); + + describe('getDto', () => { + test('should create DTO', async () => { + const actual = location.getDto(); + + expect(actual).toBeDefined(); + expect(actual.id).toEqual('a'); + expect(actual.name).toEqual('b'); + expect(actual.shortName).toEqual('c'); + expect(actual.description).toEqual('d'); + expect(actual.image).toEqual('e'); + expect(actual.width).toEqual(111); + expect(actual.height).toEqual(222); + }); + }); + + describe('getImageUrl', () => { + test('should create image URL for location', async () => { + const actual = location.getImageUrl(); + + expect(actual).toEqual('http://localhost:3000/api/locations/a/image'); + }); + }); + + describe('getDimensions', () => { + test('should create image dimension from width and height', async () => { + const actual = location.getDimensions(); + + expect(actual).toBeDefined(); + expect(actual.width).toEqual(111); + expect(actual.height).toEqual(222); + }); + }); +}); diff --git a/workspaces/frontend/src/ts/Location.ts b/workspaces/frontend/src/ts/Location.ts new file mode 100644 index 0000000..90fa0f2 --- /dev/null +++ b/workspaces/frontend/src/ts/Location.ts @@ -0,0 +1,30 @@ +import { getApiUrl } from './ApiUrl'; +import type { ImageDimensions } from './ImageDimensions'; + +export type LocationDto = Omit; + +export class Location { + id: string; + name: string; + shortName: string; + description: string; + image: string; + width: number; + height: number; + + constructor(partial: Partial) { + Object.assign(this, partial); + } + + getDto(): LocationDto { + return this as LocationDto; + } + + getImageUrl(): string { + return getApiUrl(`locations/${this.id}/image`).toString(); + } + + getDimensions(): ImageDimensions { + return { width: this.width, height: this.height } as ImageDimensions; + } +} diff --git a/workspaces/frontend/src/ts/Marker.ts b/workspaces/frontend/src/ts/Marker.ts index 708699a..736d401 100644 --- a/workspaces/frontend/src/ts/Marker.ts +++ b/workspaces/frontend/src/ts/Marker.ts @@ -1,14 +1,14 @@ import * as L from 'leaflet'; import { markerStore } from '../stores/markers'; -import { markerTypeById, markerTypeVariantByName } from './MarkerType'; import type { MType, MTypeVariant } from './MarkerType'; -import { toMarkerDto } from './MarkerDto'; +import { markerTypeById, markerTypeVariantByName } from './MarkerType'; import type { MarkerDto } from './MarkerDto'; +import { toMarkerDto } from './MarkerDto'; import { viewport } from './ViewportSingleton'; import { MarkerOverlay } from './MarkerOverlay'; export class Marker { - id: number; + id: string; lat: number; lng: number; rotation: number; diff --git a/workspaces/frontend/src/ts/ViewportSingleton.spec.ts b/workspaces/frontend/src/ts/ViewportSingleton.spec.ts index 23eacdc..4e9322f 100644 --- a/workspaces/frontend/src/ts/ViewportSingleton.spec.ts +++ b/workspaces/frontend/src/ts/ViewportSingleton.spec.ts @@ -3,6 +3,7 @@ import { waitFor } from '@testing-library/svelte'; import * as L from 'leaflet'; import { get } from 'svelte/store'; import { mapAction, Viewport, viewport, viewportInitialized } from './ViewportSingleton'; +import { locationStore } from '../stores/locations'; describe('ViewportSingleton', () => { document.body.innerHTML = @@ -13,6 +14,10 @@ describe('ViewportSingleton', () => { '
'; const mapElement = document.getElementById('map'); + beforeEach(async () => { + await locationStore.init(); + }); + test('has empty viewport', () => { expect(viewport).toBeUndefined; expect(get(viewportInitialized)).toBe(false); @@ -48,20 +53,14 @@ describe('ViewportSingleton', () => { mapAction(mapElement); }); - test('.getImageUrl returns current image URL', () => { - const actual = viewport.getImageUrl(); - - expect(actual).toEqual('http://localhost:3000/api'); - }); - test('.getImageDimensions returns current image width and height', () => { const actual = viewport.getImageDimensions(); - expect(actual.width).toEqual(1000); - expect(actual.height).toEqual(1000); + expect(actual.width).toEqual(123); + expect(actual.height).toEqual(456); }); - test('.updateImage set new image URL, width and height', () => { + test('.updateImage set image width and height', () => { const expectedImage = 'abc'; const expectedImageWidth = 123; const expectedImageHeight = 456; @@ -71,8 +70,6 @@ describe('ViewportSingleton', () => { const actualDimension = viewport.getImageDimensions(); expect(actualDimension.width).toEqual(expectedImageWidth); expect(actualDimension.height).toEqual(expectedImageHeight); - const actualImage = viewport.getImageUrl(); - expect(actualImage).toEqual(expectedImage); }); test('.reset calls leaflet fitBounds internally', () => { diff --git a/workspaces/frontend/src/ts/ViewportSingleton.ts b/workspaces/frontend/src/ts/ViewportSingleton.ts index c1acb65..fcda322 100644 --- a/workspaces/frontend/src/ts/ViewportSingleton.ts +++ b/workspaces/frontend/src/ts/ViewportSingleton.ts @@ -1,15 +1,16 @@ import { _ } from 'svelte-i18n'; -import fetch from 'cross-fetch'; import * as L from 'leaflet'; import type { Writable } from 'svelte/store'; -import { writable } from 'svelte/store'; +import { get, writable } from 'svelte/store'; +import { currentLocation } from '../stores/locations'; +import { markerStore } from '../stores/markers'; +import { FloorPlanUploadMapControl } from './FloorPlanUploadMapControl'; import type { ImageDimensions } from './ImageDimensions'; import { ResetMapControl } from './ResetMapControl'; import { ShareMapControl } from './ShareMapControl'; -import { FloorPlanUploadMapControl } from './FloorPlanUploadMapControl'; -import { getApiUrl } from './ApiUrl'; -import { Grid } from './Grid'; +import type { Location } from './Location'; import type { GridZoomSpacing } from './Grid'; +import { Grid } from './Grid'; export let viewport: Viewport; export const viewportInitialized: Writable = writable(false); @@ -23,21 +24,32 @@ export const setViewport = (vp: Viewport) => { export const mapAction = (container): { destroy: () => void } => { viewport = Viewport.Instance(container); + viewportInitialized.set(true); const invalidateSizeFn = () => { viewport.invalidateSize(); }; + const updateLocation = async (e: CustomEvent) => { + if (e.detail['location'] && e.detail['action'] === 'select') { + const location: Location = get(currentLocation); + viewport.updateImage(location.getDimensions(), location.getImageUrl()); + await markerStore.init(); + } + }; + viewport.getLeafletMap().whenReady(() => { document.addEventListener('navbar', invalidateSizeFn); - document.addEventListener('infopane', invalidateSizeFn); + document.addEventListener('sidebar', invalidateSizeFn); + document.addEventListener('location', updateLocation); document.dispatchEvent(new CustomEvent('map:created')); }); return { destroy: () => { document.removeEventListener('navbar', invalidateSizeFn); - document.removeEventListener('infopane', invalidateSizeFn); + document.removeEventListener('sidebar', invalidateSizeFn); + document.removeEventListener('location', updateLocation); viewport.getControls().forEach((c) => c.remove()); viewport.remove(); }, @@ -53,13 +65,15 @@ export class Viewport { private readonly _grid: Grid; private readonly _imageOverlay: L.ImageOverlay; - private _imageUrl = getApiUrl().toString(); - private _imageWidth = 1000; - private _imageHeight = 1000; + private _imageWidth: number; + private _imageHeight: number; + + private readonly _markerLayerGroup: L.LayerGroup = L.layerGroup([]); getLeafletMap() { return this._leafletMap; } + getControls() { return this._controls; } @@ -69,7 +83,10 @@ export class Viewport { let zoomOutTitle = 'Zoom out'; // Office image - this._imageOverlay = L.imageOverlay(this._imageUrl, this.getImageBounds(), { + const imageUrl = get(currentLocation).getImageUrl(); + this._imageWidth = get(currentLocation).width; + this._imageHeight = get(currentLocation).height; + this._imageOverlay = L.imageOverlay(imageUrl, this.getImageBounds(), { opacity: 1, }); @@ -87,7 +104,7 @@ export class Viewport { minZoom: -3, maxZoom: 3, zoomControl: false, - layers: [this._imageOverlay], + layers: [this._imageOverlay, this._markerLayerGroup], }) .setView(L.latLng(this._imageHeight / 2, this._imageWidth / 2), -2, { animate: false, duration: 1 }) .fitBounds(this.getImageBounds(), { animate: false, duration: 1 }) @@ -95,7 +112,7 @@ export class Viewport { // Link to repository and version number const appVersion = import.meta.env.PACKAGE_VERSION ?? ''; - this._leafletMap.attributionControl.addAttribution(`Desk Compass ${appVersion}`); + this._leafletMap.attributionControl.addAttribution(`Desk Compass ${appVersion}`); // Grid const zoomIntervals: GridZoomSpacing[] = [ @@ -113,16 +130,6 @@ export class Viewport { }); this._grid.addTo(this._leafletMap); - // Set correct image dimensions - fetch(getApiUrl('info')).then((response) => { - if (response.ok) { - response.json().then((dimensions) => { - this.updateImage(dimensions); - viewportInitialized.set(true); - }); - } - }); - // Add controls L.control .zoom({ @@ -160,14 +167,6 @@ export class Viewport { return this._instance || (this._instance = new this(container)); } - private getImageBounds(): L.LatLngBounds { - return L.latLngBounds(L.latLng(0, 0), L.latLng(this._imageHeight, this._imageWidth)); - } - - public getImageUrl(): string { - return this._imageUrl; - } - public getImageDimensions(): ImageDimensions { return { width: this._imageWidth, @@ -175,11 +174,8 @@ export class Viewport { } as ImageDimensions; } - public updateImage(dimensions: ImageDimensions, image?: string): void { - if (image) { - this._imageUrl = image; - this._imageOverlay.setUrl(this._imageUrl); - } + public updateImage(dimensions: ImageDimensions, image: string): void { + this._imageOverlay.setUrl(image); this._imageWidth = dimensions.width; this._imageHeight = dimensions.height; @@ -205,25 +201,32 @@ export class Viewport { public flyTo(latLng: L.LatLngExpression, zoom?: number): void { this._leafletMap.flyTo(latLng, zoom); } + public panTo(latLng: L.LatLngExpression): void { this._leafletMap.panTo(latLng); } + public setView(center: L.LatLngExpression, zoom?: number, options?: L.ZoomPanOptions): void { this._leafletMap.setView(center, zoom, options); } + public fitBounds(bounds: L.LatLngBoundsExpression, options?: L.FitBoundsOptions): void { this.invalidateSize(); this._leafletMap.fitBounds(bounds, options); } + public setMaxBounds(bounds: L.LatLngBoundsExpression): void { this._leafletMap.setMaxBounds(bounds); } + public getCenter(): L.LatLng { return this._leafletMap.getCenter(); } + public invalidateSize(animate = true): void { this._leafletMap.invalidateSize(animate); } + public layerPointToLatLng(point: L.PointExpression): L.LatLng { return this._leafletMap.layerPointToLatLng(point); } @@ -231,14 +234,24 @@ export class Viewport { public hasLayer(layer: L.Layer): boolean { return this._leafletMap.hasLayer(layer); } + public addLayer(layer: L.Layer): void { - this._leafletMap.addLayer(layer); + this._markerLayerGroup.addLayer(layer); } + public removeLayer(layer: L.Layer): void { - this._leafletMap.removeLayer(layer); + this._markerLayerGroup.removeLayer(layer); + } + + public clearLayers(): void { + this._markerLayerGroup.clearLayers(); } public remove() { return this._leafletMap.remove(); } + + private getImageBounds(): L.LatLngBounds { + return L.latLngBounds(L.latLng(0, 0), L.latLng(this._imageHeight, this._imageWidth)); + } } diff --git a/workspaces/frontend/src/views/App.svelte b/workspaces/frontend/src/views/App.svelte index ef4c60d..39ff4db 100644 --- a/workspaces/frontend/src/views/App.svelte +++ b/workspaces/frontend/src/views/App.svelte @@ -4,6 +4,7 @@ import Loading from '../components/Loading.svelte'; import { markerStore } from '../stores/markers'; import { markerTypeStore } from '../stores/markerTypes'; + import { locationStore } from '../stores/locations'; const pageTitle = import.meta.env.VITE_PAGE_TITLE ?? 'Desk Compass'; const appVersion = import.meta.env.PACKAGE_VERSION ?? ''; @@ -11,7 +12,7 @@ const loadMarkersFn = async () => { const markerTypesInitialized = await markerTypeStore.init(); if (markerTypesInitialized) { - return await markerStore.init(); + return (await locationStore.init()) && (await markerStore.init()); } return false; }; diff --git a/workspaces/frontend/vite.config.js b/workspaces/frontend/vite.config.js index d5c4278..ee7da1d 100644 --- a/workspaces/frontend/vite.config.js +++ b/workspaces/frontend/vite.config.js @@ -15,10 +15,10 @@ export default defineConfig({ 'import.meta.env.PACKAGE_VERSION': JSON.stringify(pkg.version), }, server: { - port: 3000, + port: 3111, headers: { 'Content-Security-Policy': - "default-src 'self' http://localhost:3000 https://api.iconify.design; img-src http://localhost:3000 data: *.placeholder.com; child-src 'none'; style-src 'unsafe-inline'", + "default-src 'self' http://localhost:3111 https://api.iconify.design; img-src http://localhost:3111 data: *.placeholder.com; child-src 'none'; style-src 'unsafe-inline'", }, proxy: { '/api': 'http://localhost:3030',