diff --git a/workspaces/api/.prettierrc b/workspaces/api/.prettierrc index dcb72794..6bcd5c22 100644 --- a/workspaces/api/.prettierrc +++ b/workspaces/api/.prettierrc @@ -1,4 +1,5 @@ { "singleQuote": true, - "trailingComma": "all" + "trailingComma": "all", + "printWidth": 150 } \ No newline at end of file diff --git a/workspaces/api/src/location/dto/create-location.dto.ts b/workspaces/api/src/location/dto/create-location.dto.ts new file mode 100644 index 00000000..8928e4e9 --- /dev/null +++ b/workspaces/api/src/location/dto/create-location.dto.ts @@ -0,0 +1,4 @@ +import { OmitType } from '@nestjs/swagger'; +import { LocationDto } from './location.dto'; + +export class CreateLocationDto extends OmitType(LocationDto, ['id'] as const) {} diff --git a/workspaces/api/src/location/dto/location.dto.ts b/workspaces/api/src/location/dto/location.dto.ts new file mode 100644 index 00000000..11daaf37 --- /dev/null +++ b/workspaces/api/src/location/dto/location.dto.ts @@ -0,0 +1,28 @@ +import { ApiProperty } from '@nestjs/swagger'; + +export class LocationDto { + @ApiProperty() + id: string; + + @ApiProperty() + name: string; + + @ApiProperty() + shortName: string; + + @ApiProperty() + description: string; + + @ApiProperty() + image: string; + + @ApiProperty() + width: number; + + @ApiProperty() + height: number; + + constructor(partial: Partial) { + Object.assign(this, partial); + } +} diff --git a/workspaces/api/src/location/dto/update-location.dto.ts b/workspaces/api/src/location/dto/update-location.dto.ts new file mode 100644 index 00000000..7acbc13e --- /dev/null +++ b/workspaces/api/src/location/dto/update-location.dto.ts @@ -0,0 +1,3 @@ +import { LocationDto } from './location.dto'; + +export class UpdateLocationDto extends LocationDto {} diff --git a/workspaces/api/src/location/entities/location.entity.spec.ts b/workspaces/api/src/location/entities/location.entity.spec.ts index b58673c6..c29c6a68 100644 --- a/workspaces/api/src/location/entities/location.entity.spec.ts +++ b/workspaces/api/src/location/entities/location.entity.spec.ts @@ -4,9 +4,9 @@ import { Location } from './location.entity'; describe('Location', () => { describe('getType', () => { it('should provide database entity type', () => { - const marker = new Location(); + const location = new Location(); - expect(marker.getType()).toBe('/locations'); + expect(location.getType()).toBe('/locations'); }); }); }); diff --git a/workspaces/api/src/location/entities/location.entity.ts b/workspaces/api/src/location/entities/location.entity.ts index afd439e6..0f09b8e1 100644 --- a/workspaces/api/src/location/entities/location.entity.ts +++ b/workspaces/api/src/location/entities/location.entity.ts @@ -4,6 +4,9 @@ import { EntityType } from '../../persistence/entities/entity.type'; export class Location extends Entity { static TYPE: EntityType = '/locations'; + name: string; + shortName: string; + description: string; image: string; width: number; height: number; diff --git a/workspaces/api/src/location/location.controller.spec.ts b/workspaces/api/src/location/location.controller.spec.ts new file mode 100644 index 00000000..3519e09b --- /dev/null +++ b/workspaces/api/src/location/location.controller.spec.ts @@ -0,0 +1,134 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { LocationController } from './location.controller'; +import { LocationService } from './location.service'; +import { LocationDto } from './dto/location.dto'; +import { CreateLocationDto } from './dto/create-location.dto'; +import { UpdateLocationDto } from './dto/update-location.dto'; +import { UploadDto } from '../persistence/dto/upload.dto'; +import { Response } from 'express'; + +describe('LocationController', () => { + let controller: LocationController; + const locationService: LocationService = {} as LocationService; + + beforeEach(async () => { + controller = new LocationController(locationService); + }); + + describe('create', () => { + it('should return newly created location', async () => { + const expectedResult = new LocationDto({}); + const result: Promise = new Promise( + (resolve) => { + resolve(expectedResult); + }, + ); + locationService.create = vi.fn(); + vi.spyOn(locationService, 'create').mockImplementation(() => result); + + const actual = await controller.create({} as CreateLocationDto); + + expect(actual).toBe(expectedResult); + }); + }); + + describe('findAll', () => { + it('should return an array of locations', async () => { + const expectedResult = [new LocationDto({}), new LocationDto({})]; + const result: Promise = new Promise( + (resolve) => { + resolve(expectedResult); + }, + ); + locationService.findAll = vi.fn(); + vi.spyOn(locationService, 'findAll').mockImplementation(() => result); + + const actual = await controller.findAll(); + + expect(actual).toBe(expectedResult); + }); + }); + + describe('findOne', () => { + it('should return a single location', async () => { + const expectedResult = new LocationDto({}); + const result: Promise = new Promise( + (resolve) => { + resolve(expectedResult); + }, + ); + locationService.findOne = vi.fn(); + vi.spyOn(locationService, 'findOne').mockImplementation(() => result); + + const actual = await controller.findOne('abc-123'); + + expect(actual).toBe(expectedResult); + }); + }); + + describe('update', () => { + it('should return the updated location', async () => { + const expectedResult = new LocationDto({}); + const result: Promise = new Promise( + (resolve) => { + resolve(expectedResult); + }, + ); + locationService.update = vi.fn(); + vi.spyOn(locationService, 'update').mockImplementation(() => result); + + const actual = await controller.update( + 'abc-123', + {} as UpdateLocationDto, + ); + + expect(actual).toBe(expectedResult); + }); + }); + + describe('delete', () => { + it('should call location deletion', async () => { + locationService.delete = vi.fn(); + vi.spyOn(locationService, 'delete'); + + await controller.delete('abc-123'); + + expect(locationService.delete).toHaveBeenCalledTimes(1); + }); + }); + + describe('getImage', () => { + it('should retrieve image', async () => { + const mockResponse = { + type: vi.fn(), + end: vi.fn(), + } as unknown as Response; + const mockImage = Buffer.from('abc'); + locationService.getImage = vi.fn(); + vi.spyOn(locationService, 'getImage').mockImplementation(() => { + return new Promise((resolve) => resolve(mockImage)); + }); + + await controller.getImage('abc-123', mockResponse); + + expect(locationService.getImage).toHaveBeenCalledTimes(1); + expect(mockResponse.type).toHaveBeenCalledWith('png'); + expect(mockResponse.end).toHaveBeenCalledWith(mockImage, 'binary'); + }); + }); + + describe('uploadImage', () => { + it('should upload image', async () => { + locationService.uploadImage = vi.fn(); + vi.spyOn(locationService, 'uploadImage'); + + await controller.uploadImage( + 'abc-123', + new UploadDto(), + {} as Express.Multer.File, + ); + + expect(locationService.uploadImage).toHaveBeenCalledTimes(1); + }); + }); +}); diff --git a/workspaces/api/src/location/location.controller.ts b/workspaces/api/src/location/location.controller.ts new file mode 100644 index 00000000..43836ad3 --- /dev/null +++ b/workspaces/api/src/location/location.controller.ts @@ -0,0 +1,143 @@ +import { + Controller, + Get, + Post, + Body, + Put, + Param, + Delete, + UploadedFile, + ParseUUIDPipe, + ParseFilePipe, + MaxFileSizeValidator, + FileTypeValidator, + Res, +} from '@nestjs/common'; +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'; + +@Controller(['locations']) +@ApiTags('locations') +@ApiExtraModels(LocationDto) +export class LocationController { + private static readonly MAX_FILE_SIZE = Math.pow(1024, 2); + + constructor(private readonly locationService: LocationService) {} + + @Post() + @ApiOperation({ + summary: 'Create a new location', + }) + create(@Body() createLocationDto: CreateLocationDto) { + return this.locationService.create(createLocationDto); + } + + @Get() + @ApiOperation({ + summary: 'List all locations', + }) + @ApiResponse({ + description: 'List of location objects', + schema: { + type: 'array', + items: { + $ref: getSchemaPath(LocationDto), + }, + }, + }) + findAll(): Promise { + return this.locationService.findAll(); + } + + @Get(':id') + @ApiOperation({ + summary: 'Get a single location by its ID', + }) + @ApiParam({ name: 'id', description: 'Marker ID' }) + @ApiResponse({ + description: 'Marker object', + schema: { + $ref: getSchemaPath(LocationDto), + }, + }) + async findOne(@Param('id', ParseUUIDPipe) id: string): Promise { + return await this.locationService.findOne(id); + } + + @Put(':id') + @ApiOperation({ + summary: 'Update a location', + }) + @ApiParam({ name: 'id', description: 'Marker ID' }) + update( + @Param('id', ParseUUIDPipe) id: string, + @Body() updateMarkerDto: UpdateLocationDto, + ): Promise { + return this.locationService.update(id, updateMarkerDto); + } + + @Delete(':id') + @ApiOperation({ + summary: 'Delete a location', + }) + @ApiParam({ name: 'id', description: 'Marker ID' }) + delete(@Param('id', ParseUUIDPipe) id: string): Promise { + return this.locationService.delete(id); + } + + @Get(':id/image') + @ApiOperation({ + summary: 'Provides location image', + }) + @ApiParam({ name: 'id', description: 'Marker ID' }) + @ApiImageDownload() + async getImage( + @Param('id', ParseUUIDPipe) id: string, + @Res() + response: Response, + ): Promise { + const buffer = await this.locationService.getImage(id); + response.type('png'); + response.end(buffer, 'binary'); + } + + @Post(':id/image/upload') + @ApiOperation({ + 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, + @UploadedFile( + new ParseFilePipe({ + fileIsRequired: true, + validators: [ + new MaxFileSizeValidator({ maxSize: LocationController.MAX_FILE_SIZE }), + new FileTypeValidator({ fileType: new RegExp('png|jpeg|jpg') }), + ], + }), + ) + file: Express.Multer.File, + ): Promise { + return this.locationService.uploadImage(id, file); + } +} diff --git a/workspaces/api/src/location/location.module.ts b/workspaces/api/src/location/location.module.ts index d879247f..0ed09509 100644 --- a/workspaces/api/src/location/location.module.ts +++ b/workspaces/api/src/location/location.module.ts @@ -1,4 +1,12 @@ import { Module } from '@nestjs/common'; +import { PersistenceModule } from '../persistence/persistence.module'; +import { LocationMapperService } from './utils/location-mapper.service'; +import { LocationService } from './location.service'; +import { LocationController } from './location.controller'; -@Module({}) +@Module({ + imports: [PersistenceModule], + controllers: [LocationController], + providers: [LocationService, LocationMapperService], +}) export class LocationModule {} diff --git a/workspaces/api/src/location/location.service.spec.ts b/workspaces/api/src/location/location.service.spec.ts new file mode 100644 index 00000000..ca27ca16 --- /dev/null +++ b/workspaces/api/src/location/location.service.spec.ts @@ -0,0 +1,181 @@ +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'; +import { LocationService } from './location.service'; +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'; + +describe('LocationService', () => { + let service: LocationService; + const entityManagerService: EntityManagerService = {} as EntityManagerService; + const uploadManagerService: UploadManagerService = {} as UploadManagerService; + const locationMapper: LocationMapperService = {} as LocationMapperService; + + const id = 'abc-123'; + const entity = new Location({ id: id }); + const locationDto = new LocationDto({ id: id }); + + beforeEach(async () => { + locationMapper.dtoToEntity = vi.fn(); + locationMapper.entityToDto = vi.fn(); + entityManagerService.get = vi.fn(); + entityManagerService.create = vi.fn(); + entityManagerService.getAll = vi.fn(); + entityManagerService.update = vi.fn(); + entityManagerService.delete = vi.fn(); + uploadManagerService.get = vi.fn(); + uploadManagerService.upload = vi.fn(); + uploadManagerService.delete = vi.fn(); + + vi.spyOn(locationMapper, 'dtoToEntity').mockImplementation(() => entity); + vi.spyOn(locationMapper, 'entityToDto').mockImplementation(() => locationDto); + + service = new LocationService(entityManagerService, uploadManagerService, locationMapper); + }); + + describe('create', () => { + it('should create entity', async () => { + const createDto = new CreateLocationDto(); + vi.spyOn(entityManagerService, 'create').mockImplementation(() => new Promise((resolve) => resolve(entity))); + + const result = await service.create(createDto); + + expect(result.id).toBe(id); + expect(locationMapper.dtoToEntity).toHaveBeenCalledWith(createDto); + expect(entityManagerService.create).toHaveBeenCalledWith(Location.TYPE, entity); + expect(locationMapper.entityToDto).toHaveBeenCalledWith(entity); + }); + + it('should create entity with overwritten ID', async () => { + const createDto = new CreateLocationDto({ id: 'abc-123' }); + vi.spyOn(entityManagerService, 'create').mockImplementation(() => new Promise((resolve) => resolve(entity))); + + const result = await service.create(createDto); + + expect(result.id).toBe(id); + expect(locationMapper.dtoToEntity).toHaveBeenCalledWith(createDto); + expect(entityManagerService.create).toHaveBeenCalledWith(Location.TYPE, entity); + expect(locationMapper.entityToDto).toHaveBeenCalledWith(entity); + }); + }); + + describe('findAll', () => { + it('should find all', async () => { + vi.spyOn(entityManagerService, 'getAll').mockImplementation(() => new Promise((resolve) => resolve([entity]))); + + const result = await service.findAll(); + + expect(result).toBeDefined(); + expect(result.length).toBe(1); + expect(result[0]).toBeDefined(); + expect(entityManagerService.getAll).toHaveBeenCalledWith(Location.TYPE); + expect(locationMapper.entityToDto).toHaveBeenCalledWith(entity); + }); + }); + + describe('findOne', () => { + it('should find one', async () => { + vi.spyOn(entityManagerService, 'get').mockImplementation(() => new Promise((resolve) => resolve(entity))); + + const result = await service.findOne(id); + + expect(result).toBeDefined(); + expect(entityManagerService.get).toHaveBeenCalledWith(Location.TYPE, id); + expect(locationMapper.entityToDto).toHaveBeenCalledWith(entity); + }); + }); + + describe('update', () => { + it('should update', async () => { + const updateDto = new UpdateLocationDto({}); + vi.spyOn(entityManagerService, 'update').mockImplementation(() => new Promise((resolve) => resolve(entity))); + + const result = await service.update(id, updateDto); + + expect(result).toBeDefined(); + expect(entityManagerService.update).toHaveBeenCalledWith(Location.TYPE, entity); + expect(locationMapper.entityToDto).toHaveBeenCalledWith(entity); + }); + }); + + describe('delete', () => { + it('should delete', async () => { + const entity = new Location({ 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(Location.TYPE, entity); + expect(locationMapper.entityToDto).not.toHaveBeenCalled(); + }); + }); + + describe('getImage', () => { + it('should get image', async () => { + const imageId = 'xyz-789'; + const entity = new Location({ id: id, image: imageId }); + vi.spyOn(entityManagerService, 'get').mockImplementation(() => new Promise((resolve) => resolve(entity))); + + await service.getImage(id); + + expect(entityManagerService.get).toHaveBeenCalledWith(Location.TYPE, id); + expect(uploadManagerService.get).toHaveBeenCalledWith(imageId); + }); + + it('should throw error 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; + } + + expect(errorWasTriggered).toBe(true); + }); + }); + + describe('uploadImage', () => { + it('should upload image', async () => { + const imageId = 'xyz-789'; + 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); + + expect(entityManagerService.get).toHaveBeenCalledWith(Location.TYPE, id); + expect(uploadManagerService.upload).toHaveBeenCalledWith(file); + expect(entityManagerService.update).toHaveBeenCalledWith(Location.TYPE, entity); + }); + + it('should upload image and delete previous one', async () => { + const previousImageId = 'ooo-000'; + const newImageId = 'xyz-789'; + const file = {} as Express.Multer.File; + const entity = new Location({ id: id, image: previousImageId }); + const updatedEntity = new Location({ 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(Location.TYPE, id); + expect(uploadManagerService.upload).toHaveBeenCalledWith(file); + expect(entityManagerService.update).toHaveBeenCalledWith(Location.TYPE, entity); + expect(uploadManagerService.delete).toHaveBeenCalledWith(previousImageId); + }); + }); +}); diff --git a/workspaces/api/src/location/location.service.ts b/workspaces/api/src/location/location.service.ts new file mode 100644 index 00000000..47897897 --- /dev/null +++ b/workspaces/api/src/location/location.service.ts @@ -0,0 +1,67 @@ +import { Inject, Injectable, NotFoundException } from '@nestjs/common'; +import { EntityManagerService } from '../persistence/entity-manager.service'; +import { UploadManagerService } from '../persistence/upload-manager.service'; +import { CreateLocationDto } from './dto/create-location.dto'; +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'; + +@Injectable() +export class LocationService { + constructor( + private readonly entityManagerService: EntityManagerService, + @Inject(UploadManagerService.PROVIDER) + private readonly uploadManager: UploadManagerService, + private readonly mapper: LocationMapperService, + ) {} + + async create(createLocationDto: CreateLocationDto): Promise { + const entity = this.mapper.dtoToEntity(createLocationDto); + entity.id = undefined; + const persistedEntity = await this.entityManagerService.create(Location.TYPE, entity); + return this.mapper.entityToDto(persistedEntity); + } + + async findAll(): Promise { + const locations = await this.entityManagerService.getAll(Location.TYPE); + return locations.map((m) => { + return this.mapper.entityToDto(m); + }); + } + + async findOne(id: string): Promise { + const location = await this.entityManagerService.get(Location.TYPE, id); + return this.mapper.entityToDto(location); + } + + async update(id: string, updateLocationDto: UpdateLocationDto): Promise { + const entity = this.mapper.dtoToEntity(updateLocationDto); + const updatedEntity = await this.entityManagerService.update(Location.TYPE, entity); + return this.mapper.entityToDto(updatedEntity); + } + + async delete(id: string): Promise { + const location = await this.entityManagerService.get(Location.TYPE, id); + await this.entityManagerService.delete(Location.TYPE, location); + } + + async getImage(id: string): Promise { + const location = await this.entityManagerService.get(Location.TYPE, id); + if (!location.image) { + throw new NotFoundException('No image available'); + } + return this.uploadManager.get(location.image); + } + + async uploadImage(id: string, 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); + const updatedLocation = await this.entityManagerService.update(Location.TYPE, location); + if (previousImageId) { + await this.uploadManager.delete(previousImageId); + } + return updatedLocation.image; + } +} diff --git a/workspaces/api/src/location/utils/location-mapper.service.spec.ts b/workspaces/api/src/location/utils/location-mapper.service.spec.ts new file mode 100644 index 00000000..1a2282dd --- /dev/null +++ b/workspaces/api/src/location/utils/location-mapper.service.spec.ts @@ -0,0 +1,59 @@ +import { beforeEach, describe, expect, it } from 'vitest'; +import { LocationMapperService } from './location-mapper.service'; +import { CreateLocationDto } from '../dto/create-location.dto'; +import { Location } from '../entities/location.entity'; +import { UpdateLocationDto } from '../dto/update-location.dto'; + +describe('LocationMapperService', () => { + let service: LocationMapperService; + const objData = { + id: 'abc-123', + name: 'This is a test', + shortName: 'T', + description: 'A description text', + image: 'def-456-ghi-789', + width: 100, + height: 200, + }; + + beforeEach(async () => { + service = new LocationMapperService(); + }); + + describe('dtoToEntity', () => { + it('should create Location entity from CreateLocationDto', async () => { + const createObjData = structuredClone(objData); + delete createObjData.id; + + const input = createObjData as CreateLocationDto; + + const actual = service.dtoToEntity(input); + + expect(actual.id).toBeUndefined(); + expect(actual.name).toBe(objData.name); + expect(actual.image).toBe(objData.image); + }); + + it('should create Location entity from UpdateLocationDto', async () => { + const input = objData as UpdateLocationDto; + + const actual = service.dtoToEntity(input); + + expect(actual.id).toBe(objData.id); + expect(actual.name).toBe(objData.name); + expect(actual.image).toBe(objData.image); + }); + }); + + describe('entityToDto', () => { + it('should create LocationDto from Location entity', async () => { + 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); + }); + }); +}); diff --git a/workspaces/api/src/location/utils/location-mapper.service.ts b/workspaces/api/src/location/utils/location-mapper.service.ts new file mode 100644 index 00000000..dd42404f --- /dev/null +++ b/workspaces/api/src/location/utils/location-mapper.service.ts @@ -0,0 +1,30 @@ +import { Location } from '../entities/location.entity'; +import { LocationDto } from '../dto/location.dto'; +import { Injectable } from '@nestjs/common'; + +@Injectable() +export class LocationMapperService { + entityToDto(entity: Location): LocationDto { + return new LocationDto({ + id: entity.id, + name: entity.name, + shortName: entity.shortName, + description: entity.description, + image: entity.image, + width: entity.width, + height: entity.height, + }); + } + + dtoToEntity(dto: Partial): Location { + return new Location({ + id: dto.id, + name: dto.name, + shortName: dto.shortName, + description: dto.description, + image: dto.image, + width: dto.width, + height: dto.height, + }); + } +} diff --git a/workspaces/frontend/package.json b/workspaces/frontend/package.json index 0279435c..e9cc93ad 100644 --- a/workspaces/frontend/package.json +++ b/workspaces/frontend/package.json @@ -64,6 +64,7 @@ "@svelteuidev/composables": "^0.13.0", "cross-fetch": "^3.1.5", "leaflet": "^1.9.3", + "svelte-htm": "^1.2.0", "svelte-spa-router": "^3.3.0" }, "lint-staged": { diff --git a/workspaces/frontend/src/components/NavigationItem.spec.ts b/workspaces/frontend/src/components/NavigationItem.spec.ts new file mode 100644 index 00000000..05efeb18 --- /dev/null +++ b/workspaces/frontend/src/components/NavigationItem.spec.ts @@ -0,0 +1,21 @@ +import { render, screen } from '@testing-library/svelte'; +import { describe, expect, test } from 'vitest'; +import html from 'svelte-htm'; +import NavigationItem from './NavigationItem.svelte'; + +describe('NavigationItem', () => { + test('should be initialized', async () => { + render(html` + <${NavigationItem} counter='5'> + Foo + Bar + Actions + + `); + + expect(screen.getByText('Foo')).toBeInTheDocument(); + expect(screen.getByText('Bar')).toBeInTheDocument(); + expect(screen.getByText('5')).toBeInTheDocument(); + expect(screen.getByText('Actions')).toBeInTheDocument(); + }); +}); diff --git a/workspaces/frontend/src/components/NavigationItem.svelte b/workspaces/frontend/src/components/NavigationItem.svelte new file mode 100644 index 00000000..ca0b4d7a --- /dev/null +++ b/workspaces/frontend/src/components/NavigationItem.svelte @@ -0,0 +1,35 @@ + + + diff --git a/workspaces/frontend/src/components/NavigationSideBar.svelte b/workspaces/frontend/src/components/NavigationSideBar.svelte index 6cb9a5e6..a85994b9 100644 --- a/workspaces/frontend/src/components/NavigationSideBar.svelte +++ b/workspaces/frontend/src/components/NavigationSideBar.svelte @@ -6,6 +6,7 @@ import type { MType } from '../ts/MarkerType'; import { viewport } from '../ts/ViewportSingleton'; import { generateMarker } from '../ts/Marker'; + import NavigationItem from './NavigationItem.svelte'; let slim = false; @@ -28,6 +29,10 @@ }); } + const selectLocation = (location: string): void => { + // do nothing yet + }; + function createMarker(markerType: MType): void { const mapCenter = viewport.getCenter(); const marker = generateMarker({ @@ -79,36 +84,48 @@ + + @@ -146,65 +163,3 @@ {/if} - - diff --git a/workspaces/frontend/src/desk-compass.postcss b/workspaces/frontend/src/desk-compass.postcss index c3fb2249..c2a3737e 100644 --- a/workspaces/frontend/src/desk-compass.postcss +++ b/workspaces/frontend/src/desk-compass.postcss @@ -24,6 +24,7 @@ } } @layer components { + [contenteditable='false'] { @apply font-bold text-base text-default border-0 outline-0; } @@ -211,3 +212,69 @@ .unknown { @apply before:content-['\f86e'] text-violet; } + +/* Navigation */ +.nav { + @apply bg-white overflow-hidden w-full md:w-72 md:h-screen md:min-h-screen flex flex-col z-[1234] shadow-sidebar; + @apply transition-sidebar duration-300 ease-in-out; +} +.nav.slim { + @apply w-12; +} +.nav-section { + @apply text-grey uppercase mt-8 mb-1 px-10 font-light text-xs; +} +.nav-item-list { + @apply flex flex-col gap-px my-2 md:my-0; +} + +.nav-item { + @apply text-base text-grey-700 flex flex-row items-center font-bold mx-2 gap-3; + @apply md:mx-0 md:pl-10 md:pr-6 md:h-10; +} +.nav-item--active { + @apply bg-grey-300 text-default; +} +.nav-item-title { + @apply grow; +} +[class$='--short'], +[class*='--short'], +[class$='--short'] .icon, +[class*='--short'] .icon { + @apply hidden; +} + +.counter-badge { + @apply inline-block whitespace-nowrap rounded-full bg-blue-400 px-[0.4em] pb-[0.2em] pt-[0.25em]; + @apply text-center align-top text-[0.7em] font-normal leading-none text-white; +} + +.slim .nav-section { + @apply md:invisible md:h-4; +} +.slim .search { + @apply md:hidden; +} +.slim .nav-item { + @apply px-2 justify-center; +} +.slim .nav-item button { + @apply w-auto; +} +.slim .nav-item-title { + @apply grow-0; +} +.slim [class$='--short'], +.slim [class*='--short'], +.slim [class$='--short'] .icon, +.slim [class*='--short'] .icon { + @apply inline-block; +} +.slim [class$='--long'], +.slim [class*='--long'] { + @apply hidden; +} +.slim .counter-badge { + @apply absolute left-6; +} diff --git a/workspaces/frontend/src/i18n/en.json b/workspaces/frontend/src/i18n/en.json index 5833308a..ff3efcbc 100644 --- a/workspaces/frontend/src/i18n/en.json +++ b/workspaces/frontend/src/i18n/en.json @@ -56,6 +56,9 @@ "tooltips": { "toggle": "Toggle tooltips of {type} markers" } + }, + "locations": { + "title": "Locations" } } } diff --git a/yarn.lock b/yarn.lock index 34e86150..b8aa322b 100644 --- a/yarn.lock +++ b/yarn.lock @@ -96,6 +96,14 @@ chalk "^2.0.0" js-tokens "^4.0.0" +"@babel/runtime-corejs3@^7.10.3", "@babel/runtime-corejs3@^7.19.0": + version "7.22.5" + resolved "https://registry.yarnpkg.com/@babel/runtime-corejs3/-/runtime-corejs3-7.22.5.tgz#bbc769b48edb2bdfd404b65ad1fc3952bf33e3c2" + integrity sha512-TNPDN6aBFaUox2Lu+H/Y1dKKQgr4ucz/FGyCz67RVYLsBpVpUFf1dDngzg+Od8aqbrqwyztkaZjtWCZEUOT8zA== + dependencies: + core-js-pure "^3.30.2" + regenerator-runtime "^0.13.11" + "@babel/runtime@^7.12.5", "@babel/runtime@^7.18.9", "@babel/runtime@^7.9.2": version "7.21.5" resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.21.5.tgz#8492dddda9644ae3bda3b45eabe87382caee7200" @@ -3226,6 +3234,16 @@ cookie@^0.4.2: resolved "https://registry.yarnpkg.com/cookie/-/cookie-0.4.2.tgz#0e41f24de5ecf317947c82fc789e06a884824432" integrity sha512-aSWTXFzaKWkvHO1Ny/s+ePFpvKsPnjc551iI41v3ny/ow6tBG5Vd+FuqGNhh1LxOmVzOlGUriIlOaokOvhaStA== +core-js-pure@^3.30.2: + version "3.31.0" + resolved "https://registry.yarnpkg.com/core-js-pure/-/core-js-pure-3.31.0.tgz#052fd9e82fbaaf86457f5db1fadcd06f15966ff2" + integrity sha512-/AnE9Y4OsJZicCzIe97JP5XoPKQJfTuEG43aEVLFJGOJpyqELod+pE6LEl63DfG1Mp8wX97LDaDpy1GmLEUxlg== + +core-js@^3.25.1, core-js@^3.6.5: + version "3.31.0" + resolved "https://registry.yarnpkg.com/core-js/-/core-js-3.31.0.tgz#4471dd33e366c79d8c0977ed2d940821719db344" + integrity sha512-NIp2TQSGfR6ba5aalZD+ZQ1fSxGhDo/s1w0nx3RYzf2pnJxt7YynxFlFScP6eV7+GZsKO95NSjGxyJsU3DZgeQ== + core-util-is@~1.0.0: version "1.0.3" resolved "https://registry.yarnpkg.com/core-util-is/-/core-util-is-1.0.3.tgz#a6042d3634c2b27e9328f837b965fac83808db85" @@ -5120,6 +5138,11 @@ hosted-git-info@^6.0.0, hosted-git-info@^6.1.1: dependencies: lru-cache "^7.5.1" +htm@^3.1.1: + version "3.1.1" + resolved "https://registry.yarnpkg.com/htm/-/htm-3.1.1.tgz#49266582be0dc66ed2235d5ea892307cc0c24b78" + integrity sha512-983Vyg8NwUE7JkZ6NmOqpCZ+sh1bKv2iYTlUkzlWmA5JD2acKoxd4KVxbMmxX/85mtfdnDmTFoNKcg5DGAvxNQ== + html-encoding-sniffer@^3.0.0: version "3.0.0" resolved "https://registry.yarnpkg.com/html-encoding-sniffer/-/html-encoding-sniffer-3.0.0.tgz#2cb1a8cf0db52414776e5b2a7a04d5dd98158de9" @@ -9337,11 +9360,35 @@ svelte-eslint-parser@^0.31.0: postcss "^8.4.23" postcss-scss "^4.0.6" +svelte-fragment-component@^1.2.0: + version "1.2.0" + resolved "https://registry.yarnpkg.com/svelte-fragment-component/-/svelte-fragment-component-1.2.0.tgz#36842c609a15c934b9d32a8bc99c460b0374d796" + integrity sha512-rRstmz2oAy2Y/7X57tRaIAJdMYsa2K/MOx/YJN/ETb7Bj9U3vjgioz27dMG1hl2vAKFTtQpxDhC31ur7ECwpog== + svelte-hmr@^0.15.1: version "0.15.1" resolved "https://registry.yarnpkg.com/svelte-hmr/-/svelte-hmr-0.15.1.tgz#d11d878a0bbb12ec1cba030f580cd2049f4ec86b" integrity sha512-BiKB4RZ8YSwRKCNVdNxK/GfY+r4Kjgp9jCLEy0DuqAKfmQtpL38cQK3afdpjw4sqSs4PLi3jIPJIFp259NkZtA== +svelte-htm@^1.2.0: + version "1.2.0" + resolved "https://registry.yarnpkg.com/svelte-htm/-/svelte-htm-1.2.0.tgz#fe53d4ea48713dfa1acd275fa9ff14e2b4cccdd7" + integrity sha512-6YFNncbyXbCa3PSoMQc/JR6O/Yg1OgNioH5rMgE88RVPzU8714y2Urfenlqs+ryRS+Inv+m6TJ9jH3W7wDCS1A== + dependencies: + "@babel/runtime-corejs3" "^7.19.0" + core-js "^3.25.1" + htm "^3.1.1" + svelte-fragment-component "^1.2.0" + svelte-hyperscript "^1.2.1" + +svelte-hyperscript@^1.2.1: + version "1.2.1" + resolved "https://registry.yarnpkg.com/svelte-hyperscript/-/svelte-hyperscript-1.2.1.tgz#e8fd4194994eeb22dcb82a69caff98ab5b6fb40b" + integrity sha512-IVk91VDSOrhOUe0KI+Jfu7yCruUTOXgr4WysrK7hBNbFMDjeBOcPhk+LMYjUjdWxJx3+QSt3bUXj09YrUfRkjg== + dependencies: + "@babel/runtime-corejs3" "^7.10.3" + core-js "^3.6.5" + svelte-i18n@^3.6.0: version "3.6.0" resolved "https://registry.yarnpkg.com/svelte-i18n/-/svelte-i18n-3.6.0.tgz#0f345d066662dd8f46efefc0e867fb05b71c9dbd"