Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

#284 Add a copy mind map button #285

Merged
102 changes: 102 additions & 0 deletions teammapper-backend/src/map/controllers/maps.controller.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
import { Test, TestingModule } from '@nestjs/testing';
import MapsController from './maps.controller';
import { MapsService } from '../services/maps.service';
import { NotFoundException } from '@nestjs/common';
import { MmpMap } from '../entities/mmpMap.entity';
import { IMmpClientMap, IMmpClientPrivateMap } from '../types';
import { MmpNode } from '../entities/mmpNode.entity';

describe('MapsController', () => {
let mapsController: MapsController;
let mapsService: MapsService;

beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
controllers: [MapsController],
providers: [
{
provide: MapsService,
useValue: {
findMap: jest.fn(),
createEmptyMap: jest.fn(),
findNodes: jest.fn(),
addNodes: jest.fn(),
exportMapToClient: jest.fn(),
},
},
],
}).compile();

mapsController = module.get<MapsController>(MapsController);
mapsService = module.get<MapsService>(MapsService);
});

describe('duplicate', () => {
it('should duplicate a map correctly', async () => {
const oldMap: MmpMap = {
id: '6357cedd-2621-4033-8958-c50061306cb9',
adminId: 'old-admin-id',
modificationSecret: 'old-modification-secret',
name: 'Test Map',
lastModified: new Date('1970-01-01'),
options: {
fontMaxSize: 1,
fontMinSize: 1,
fontIncrement: 1
},
nodes: Array<MmpNode>()
};
const newMap: MmpMap = {
id: 'e7f66b65-ffd5-4387-b645-35f8e794c7e7',
adminId: 'new-admin-id',
modificationSecret: 'new-modification-secret',
name: 'Test Map',
lastModified: new Date('1970-01-01'),
options: {
fontMaxSize: 1,
fontMinSize: 1,
fontIncrement: 1
},
nodes: Array<MmpNode>()
};
const exportedMap: IMmpClientMap = {
uuid: 'e7f66b65-ffd5-4387-b645-35f8e794c7e7',
data: [],
deleteAfterDays: 30,
deletedAt: new Date('1970-01-01'),
lastModified: new Date('1970-01-01'),
options: {
fontMaxSize: 1,
fontMinSize: 1,
fontIncrement: 1
},
};
const result: IMmpClientPrivateMap = {
map: exportedMap,
adminId: 'new-admin-id',
modificationSecret: 'new-modification-secret'
};

jest.spyOn(mapsService, 'findMap').mockResolvedValueOnce(oldMap);
jest.spyOn(mapsService, 'createEmptyMap').mockResolvedValueOnce(newMap);
jest.spyOn(mapsService, 'findNodes').mockResolvedValueOnce(Array<MmpNode>());
jest.spyOn(mapsService, 'addNodes').mockResolvedValueOnce([]);
jest.spyOn(mapsService, 'exportMapToClient').mockResolvedValueOnce(exportedMap);

const response = await mapsController.duplicate(oldMap.id);

expect(response).toEqual(result);

expect(newMap.name).toEqual(oldMap.name);
expect(newMap.lastModified).toEqual(oldMap.lastModified);
});

it('should throw NotFoundException if old map is not found', async () => {
const mapId = 'test-map-id';

jest.spyOn(mapsService, 'findMap').mockRejectedValueOnce(new Error('MalformedUUIDError'));

await expect(mapsController.duplicate(mapId)).rejects.toThrow(NotFoundException);
});
});
});
23 changes: 23 additions & 0 deletions teammapper-backend/src/map/controllers/maps.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,4 +49,27 @@ export default class MapsController {
modificationSecret: newMap.modificationSecret,
}
}

@Post(':id/duplicate')
async duplicate(
@Param('id') mapId: string,
): Promise<IMmpClientPrivateMap> {
const oldMap = await this.mapsService.findMap(mapId).catch((e: Error) => {
if (e.name === 'MalformedUUIDError') throw new NotFoundException()
})

if (!oldMap) throw new NotFoundException()

const newMap = await this.mapsService.createEmptyMap()

const oldNodes = await this.mapsService.findNodes(oldMap.id)

await this.mapsService.addNodes(newMap.id, oldNodes)

return {
map: await this.mapsService.exportMapToClient(newMap.id),
adminId: newMap.adminId,
modificationSecret: newMap.modificationSecret
}
}
}
2 changes: 1 addition & 1 deletion teammapper-backend/src/map/controllers/maps.gateway.ts
Original file line number Diff line number Diff line change
Expand Up @@ -118,7 +118,7 @@ export class MapsGateway implements OnGatewayDisconnect {
@ConnectedSocket() client: Socket,
@MessageBody() request: IMmpClientNodeAddRequest
): Promise<boolean> {
const newNodes = await this.mapsService.addNodes(
const newNodes = await this.mapsService.addNodesFromClient(
request.mapId,
request.nodes
)
Expand Down
2 changes: 1 addition & 1 deletion teammapper-backend/src/map/services/maps.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,7 @@ describe('MapsController', () => {
coordinatesY: 1,
})

await mapsService.addNodes(map.id, [mapMmpNodeToClient(node)])
await mapsService.addNodes(map.id, [node])

const createdNode = await nodesRepo.findOne({
where: { id: node.id },
Expand Down
58 changes: 35 additions & 23 deletions teammapper-backend/src/map/services/maps.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,46 +54,55 @@ export class MapsService {
)
}

async addNode(mapId: string, clientNode: IMmpClientNode): Promise<MmpNode> {
async addNode(mapId: string, node: MmpNode): Promise<MmpNode> {
// detached nodes are not allowed to have a parent
if (clientNode.detached && clientNode.parent) return Promise.reject()
if (!mapId || !clientNode) return Promise.reject()
if (node.detached && node.nodeParentId) return Promise.reject()
if (!mapId || !node) return Promise.reject()

const existingNode = await this.nodesRepository.findOne({
where: { id: clientNode.id, nodeMapId: mapId },
where: { id: node.id, nodeMapId: mapId },
})
if (existingNode) return existingNode

const newNode = this.nodesRepository.create({
...mapClientNodeToMmpNode(clientNode, mapId),
...node,
nodeMapId: mapId,
})

return this.nodesRepository.save(newNode)
}

async addNodes(
async addNodesFromClient(
mapId: string,
clientNodes: IMmpClientNode[]
): Promise<MmpNode[]> {
if (!mapId || clientNodes.length === 0) Promise.reject()
const mmpNodes = clientNodes.map(x => mapClientNodeToMmpNode(x, mapId))
return await this.addNodes(mapId, mmpNodes)
}

async addNodes(
mapId: string,
nodes: Partial<MmpNode>[]
): Promise<MmpNode[]> {
if (!mapId || nodes.length === 0) Promise.reject()

const reducer = async (
previousPromise: Promise<MmpNode[]>,
clientNode: IMmpClientNode
node: MmpNode
): Promise<MmpNode[]> => {
const accCreatedNodes = await previousPromise

if (await this.validatesNodeParentForClientNode(mapId, clientNode)) {
return accCreatedNodes.concat([await this.addNode(mapId, clientNode)])
if (await this.validatesNodeParentForNode(mapId, node)) {
return accCreatedNodes.concat([await this.addNode(mapId, node)])
}

this.logger.warn(
`Parent with id ${clientNode.parent} does not exist for node ${clientNode.id} and map ${mapId}`
`Parent with id ${node.nodeParentId} does not exist for node ${node.id} and map ${mapId}`
)
return accCreatedNodes
}

return clientNodes.reduce(reducer, Promise.resolve(new Array<MmpNode>()))

return nodes.reduce(reducer, Promise.resolve(new Array<MmpNode>()))
}

async findNodes(mapId: string): Promise<MmpNode[]> {
Expand Down Expand Up @@ -145,13 +154,16 @@ export class MapsService {
return this.nodesRepository.remove(existingNode)
}

async createEmptyMap(rootNode: IMmpClientNodeBasics): Promise<MmpMap> {
async createEmptyMap(rootNode?: IMmpClientNodeBasics): Promise<MmpMap> {
const newMap: MmpMap = this.mapsRepository.create()
const savedNewMap: MmpMap = await this.mapsRepository.save(newMap)
const newRootNode = this.nodesRepository.create(
mapClientBasicNodeToMmpRootNode(rootNode, savedNewMap.id)
)
await this.nodesRepository.save(newRootNode)

if (rootNode) {
const newRootNode = this.nodesRepository.create(
mapClientBasicNodeToMmpRootNode(rootNode, savedNewMap.id)
)
await this.nodesRepository.save(newRootNode)
}

return newMap
}
Expand All @@ -161,7 +173,7 @@ export class MapsService {
// remove existing nodes, otherwise we will end up with multiple roots
await this.nodesRepository.delete({ nodeMapId: clientMap.uuid })
// Add new nodes from given map
await this.addNodes(clientMap.uuid, clientMap.data)
await this.addNodesFromClient(clientMap.uuid, clientMap.data)
// reload map
return this.findMap(clientMap.uuid)
}
Expand Down Expand Up @@ -256,14 +268,14 @@ export class MapsService {
this.mapsRepository.delete({ id: uuid })
}

async validatesNodeParentForClientNode(
async validatesNodeParentForNode(
mapId: string,
node: IMmpClientNode
node: MmpNode
): Promise<boolean> {
return (
node.isRoot ||
node.root ||
node.detached ||
(!!node.parent && (await this.existsNode(mapId, node.parent)))
(!!node.nodeParentId && (await this.existsNode(mapId, node.nodeParentId)))
)
}
}
7 changes: 3 additions & 4 deletions teammapper-backend/src/map/utils/clientServerMapping.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,7 @@ const mapMmpMapToClient = (
const mapClientNodeToMmpNode = (
clientNode: IMmpClientNode,
mapId: string
): Object => ({
): Partial<MmpNode> => ({
id: clientNode.id,
colorsBackground: clientNode.colors.background,
colorsBranch: clientNode.colors.branch,
Expand All @@ -73,7 +73,7 @@ const mapClientNodeToMmpNode = (
locked: clientNode.locked,
detached: clientNode.detached,
name: clientNode.name,
nodeParentId: clientNode.parent ? clientNode.parent : null,
nodeParentId: clientNode.parent || undefined,
JannikStreek marked this conversation as resolved.
Show resolved Hide resolved
root: clientNode.isRoot,
nodeMapId: mapId,
})
Expand All @@ -82,7 +82,7 @@ const mapClientNodeToMmpNode = (
const mapClientBasicNodeToMmpRootNode = (
clientRootNodeBasics: IMmpClientNodeBasics,
mapId: string
): Object => ({
): Partial<MmpNode> => ({
colorsBackground:
clientRootNodeBasics.colors.background || DEFAULT_COLOR_BACKGROUND,
colorsBranch: clientRootNodeBasics.colors.branch,
Expand All @@ -95,7 +95,6 @@ const mapClientBasicNodeToMmpRootNode = (
imageSrc: clientRootNodeBasics.image?.src,
imageSize: clientRootNodeBasics.image?.size,
name: clientRootNodeBasics.name || DEFAULT_NAME,
nodeParentId: null,
root: true,
detached: false,
nodeMapId: mapId,
Expand Down
3 changes: 2 additions & 1 deletion teammapper-frontend/angular.json
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,8 @@
],
"styles": [
"src/styles.scss",
"src/theme.scss"
"src/theme.scss",
"node_modules/ngx-toastr/toastr.css"
],
"scripts": [],
"serviceWorker": false
Expand Down
14 changes: 14 additions & 0 deletions teammapper-frontend/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

7 changes: 4 additions & 3 deletions teammapper-frontend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -37,14 +37,14 @@
"deploy": "ng deploy"
},
"dependencies": {
"@angular-devkit/build-angular": "^17.3.2",
"@angular/animations": "^17.3.1",
"@angular/cdk": "^17.3.1",
"@angular/common": "^17.3.1",
"@angular/cli": "^17.3.2",
"@angular/common": "^17.3.1",
"@angular/compiler": "^17.3.1",
"@angular/compiler-cli": "^17.3.1",
"@angular/core": "^17.3.1",
"@angular-devkit/build-angular": "^17.3.2",
"@angular/forms": "^17.3.1",
"@angular/material": "^17.3.1",
"@angular/platform-browser": "^17.3.1",
Expand All @@ -64,6 +64,7 @@
"jspdf": "^2.5.1",
"localforage": "1.10.0",
"ngx-color-picker": "^16.0.0",
"ngx-toastr": "^19.0.0",
"qr-code-styling": "1.5.0",
"rxjs": "~7.4.0",
"socket.io-client": "~4.6.1",
Expand Down Expand Up @@ -110,4 +111,4 @@
"@nx/nx-linux-x64-gnu": "17.2.8",
"@nx/nx-win32-x64-msvc": "17.2.8"
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -41,4 +41,7 @@ <h2 mat-dialog-title>{{ 'MODALS.SHARE.TITLE' | translate }}</h2>
<button mat-button (click)="downloadQrCode()">
<mat-icon>file_download</mat-icon>
</button>
<button mat-button (click)="duplicateMindMap()">
<mat-icon>content_copy</mat-icon>
</button>
</mat-dialog-actions>
Loading