From 87ac1f0cd1234432297c07f6d5da6a39dcb0eabf Mon Sep 17 00:00:00 2001 From: Uwe Ilgenstein Date: Mon, 22 Apr 2024 09:55:33 +0200 Subject: [PATCH] BC-6770 - Create a list board (#4914) * implement functionality to create separate list board * added property 'layout' for existing boards * adapt column board structure to list board * added feature flag for list board --- .../mikro-orm/Migration20240415124640.ts | 19 ++ .../api-test/board-create.api.spec.ts | 118 ++++++++++--- .../api-test/board-lookup.api.spec.ts | 167 +++++++++--------- .../controller/dto/board/board.response.ts | 7 +- .../dto/board/create-board.body.params.ts | 11 +- .../mapper/board-response.mapper.ts | 1 + .../board/repo/board-do.builder-impl.ts | 3 +- .../board/repo/recursive-save.visitor.ts | 1 + .../recursive-copy.visitor.ts | 1 + .../service/column-board.service.spec.ts | 5 +- .../board/service/column-board.service.ts | 4 +- apps/server/src/modules/board/uc/board.uc.ts | 3 +- .../board-column-board.response.ts | 7 +- .../mapper/room-board-response.mapper.spec.ts | 8 +- .../mapper/room-board-response.mapper.ts | 1 + .../common-cartridge-import.service.ts | 5 +- .../learnroom/types/room-board.types.ts | 2 + .../learnroom/uc/room-board-dto.factory.ts | 1 + .../modules/server/api/dto/config.response.ts | 4 + .../server/api/test/server.api.spec.ts | 1 + .../src/modules/server/server.config.ts | 2 + .../domainobject/board/column-board.do.ts | 6 + .../board/types/board-layout.enum.ts | 4 + .../domain/domainobject/board/types/index.ts | 1 + .../entity/boardnode/boardnode.entity.spec.ts | 3 +- .../boardnode/column-board-node.entity.ts | 7 + .../boardnode/column-board-node.factory.ts | 3 +- .../board/column-board.do.factory.ts | 3 +- backup/setup/migrations.json | 9 + config/default.schema.json | 6 +- config/development.json | 1 + 31 files changed, 289 insertions(+), 125 deletions(-) create mode 100644 apps/server/src/migrations/mikro-orm/Migration20240415124640.ts create mode 100644 apps/server/src/shared/domain/domainobject/board/types/board-layout.enum.ts diff --git a/apps/server/src/migrations/mikro-orm/Migration20240415124640.ts b/apps/server/src/migrations/mikro-orm/Migration20240415124640.ts new file mode 100644 index 00000000000..8cb380e903f --- /dev/null +++ b/apps/server/src/migrations/mikro-orm/Migration20240415124640.ts @@ -0,0 +1,19 @@ +import { Migration } from '@mikro-orm/migrations-mongodb'; +import { BoardLayout } from '@shared/domain/domainobject'; +import { BoardNodeType } from '@shared/domain/entity'; + +export class Migration20240415124640 extends Migration { + async up(): Promise { + const columBoardResponse = await this.driver.nativeUpdate<{ type: BoardNodeType; layout: BoardLayout }>( + 'boardnodes', + { $and: [{ type: 'column-board' }, { layout: { $exists: false } }] }, + { layout: BoardLayout.COLUMNS } + ); + console.info(`Updated ${columBoardResponse.affectedRows} records in boardnodes`); + } + + // eslint-disable-next-line @typescript-eslint/require-await + async down(): Promise { + console.error(`boardnodes cannot be rolled-back. It must be restored from backup!`); + } +} diff --git a/apps/server/src/modules/board/controller/api-test/board-create.api.spec.ts b/apps/server/src/modules/board/controller/api-test/board-create.api.spec.ts index 03bb025ed7e..433a00fef5c 100644 --- a/apps/server/src/modules/board/controller/api-test/board-create.api.spec.ts +++ b/apps/server/src/modules/board/controller/api-test/board-create.api.spec.ts @@ -2,9 +2,10 @@ import { EntityManager } from '@mikro-orm/mongodb'; import { ServerTestModule } from '@modules/server/server.module'; import { INestApplication } from '@nestjs/common'; import { Test, TestingModule } from '@nestjs/testing'; -import { BoardExternalReferenceType } from '@shared/domain/domainobject'; +import { BoardExternalReferenceType, BoardLayout } from '@shared/domain/domainobject'; import { ColumnBoardNode } from '@shared/domain/entity'; -import { TestApiClient, UserAndAccountTestFactory, cleanupCollections, courseFactory } from '@shared/testing'; +import { cleanupCollections, courseFactory, TestApiClient, UserAndAccountTestFactory } from '@shared/testing'; +import { CreateBoardBodyParams } from '../dto'; const baseRouteName = '/boards'; @@ -46,14 +47,15 @@ describe(`create board (api)`, () => { return { loggedInClient, course }; }; - it('should return status 204 and board', async () => { + it('should return status 201 and board', async () => { const { loggedInClient, course } = await setup(); const title = 'new board'; - const response = await loggedInClient.post(undefined, { + const response = await loggedInClient.post(undefined, { title, parentId: course.id, parentType: BoardExternalReferenceType.Course, + layout: BoardLayout.COLUMNS, }); const boardId = (response.body as { id: string }).id; @@ -63,6 +65,78 @@ describe(`create board (api)`, () => { const dbResult = await em.findOneOrFail(ColumnBoardNode, boardId); expect(dbResult.title).toEqual(title); }); + + describe('Board layout', () => { + describe(`When layout is set to "${BoardLayout.COLUMNS}"`, () => { + it('should create a column board', async () => { + const { loggedInClient, course } = await setup(); + + const response = await loggedInClient.post(undefined, { + title: 'new board', + parentId: course.id, + parentType: BoardExternalReferenceType.Course, + layout: BoardLayout.COLUMNS, + }); + + const boardId = (response.body as { id: string }).id; + expect(response.status).toEqual(201); + expect(boardId).toBeDefined(); + + const dbResult = await em.findOneOrFail(ColumnBoardNode, boardId); + expect(dbResult.layout).toEqual(BoardLayout.COLUMNS); + }); + }); + + describe(`When layout is set to "${BoardLayout.LIST}"`, () => { + it('should create a list board', async () => { + const { loggedInClient, course } = await setup(); + + const response = await loggedInClient.post(undefined, { + title: 'new board', + parentId: course.id, + parentType: BoardExternalReferenceType.Course, + layout: BoardLayout.LIST, + }); + + const boardId = (response.body as { id: string }).id; + expect(response.status).toEqual(201); + expect(boardId).toBeDefined(); + + const dbResult = await em.findOneOrFail(ColumnBoardNode, boardId); + expect(dbResult.layout).toEqual(BoardLayout.LIST); + }); + }); + }); + + describe('When layout is omitted', () => { + it('should return status 400', async () => { + const { loggedInClient, course } = await setup(); + + const response = await loggedInClient.post(undefined, >{ + title: 'new board', + parentId: course.id, + parentType: BoardExternalReferenceType.Course, + layout: undefined, + }); + + expect(response.status).toEqual(400); + }); + }); + + describe('When layout is invalid', () => { + it('should return status 400', async () => { + const { loggedInClient, course } = await setup(); + + const response = await loggedInClient.post(undefined, >{ + title: 'new board', + parentId: course.id, + parentType: BoardExternalReferenceType.Course, + layout: 'invalid', + }); + + expect(response.status).toEqual(400); + }); + }); }); describe('When user is teacher and has no course permission', () => { @@ -78,21 +152,21 @@ describe(`create board (api)`, () => { return { loggedInClient, course }; }; - it('should return status 204 and board', async () => { + it('should return status 403', async () => { const { loggedInClient, course } = await setup(); - const title = 'new board'; - const response = await loggedInClient.post(undefined, { - title, + const response = await loggedInClient.post(undefined, { + title: 'new board', parentId: course.id, parentType: BoardExternalReferenceType.Course, + layout: BoardLayout.COLUMNS, }); expect(response.status).toEqual(403); }); }); - describe('When user is student and has course permission', () => { + describe('When user is student and has no course permission', () => { const setup = async () => { const { studentAccount, studentUser } = UserAndAccountTestFactory.buildStudent(); @@ -105,14 +179,14 @@ describe(`create board (api)`, () => { return { loggedInClient, course }; }; - it('should return status 204 and board', async () => { + it('should return status 403', async () => { const { loggedInClient, course } = await setup(); - const title = 'new board'; - const response = await loggedInClient.post(undefined, { - title, + const response = await loggedInClient.post(undefined, { + title: 'new board', parentId: course.id, parentType: BoardExternalReferenceType.Course, + layout: BoardLayout.COLUMNS, }); expect(response.status).toEqual(403); @@ -136,10 +210,9 @@ describe(`create board (api)`, () => { it('should return status 400', async () => { const { loggedInClient, course } = await setup(); - const title = ''; - const response = await loggedInClient.post(undefined, { - title, + const response = await loggedInClient.post(undefined, { + title: '', parentId: course.id, parentType: BoardExternalReferenceType.Course, }); @@ -163,10 +236,9 @@ describe(`create board (api)`, () => { it('should return status 400', async () => { const { loggedInClient, course } = await setup(); - const title = 'a'.repeat(101); - const response = await loggedInClient.post(undefined, { - title, + const response = await loggedInClient.post(undefined, { + title: 'a'.repeat(101), parentId: course.id, parentType: BoardExternalReferenceType.Course, }); @@ -191,7 +263,7 @@ describe(`create board (api)`, () => { const { loggedInClient } = await setup(); const title = 'new board'; - const response = await loggedInClient.post(undefined, { + const response = await loggedInClient.post(undefined, { title, parentId: '123', parentType: BoardExternalReferenceType.Course, @@ -216,12 +288,12 @@ describe(`create board (api)`, () => { it('should return status 400', async () => { const { loggedInClient, course } = await setup(); - const title = 'new board'; - const response = await loggedInClient.post(undefined, { - title, + const response = await loggedInClient.post(undefined, >{ + title: 'new board', parentId: course.id, parentType: 'invalid', + layout: BoardLayout.COLUMNS, }); expect(response.status).toEqual(400); diff --git a/apps/server/src/modules/board/controller/api-test/board-lookup.api.spec.ts b/apps/server/src/modules/board/controller/api-test/board-lookup.api.spec.ts index fc5b3e49ed7..460242684bf 100644 --- a/apps/server/src/modules/board/controller/api-test/board-lookup.api.spec.ts +++ b/apps/server/src/modules/board/controller/api-test/board-lookup.api.spec.ts @@ -1,140 +1,137 @@ import { EntityManager, ObjectId } from '@mikro-orm/mongodb'; -import { ICurrentUser } from '@modules/authentication'; -import { JwtAuthGuard } from '@modules/authentication/guard/jwt-auth.guard'; import { ServerTestModule } from '@modules/server/server.module'; -import { ExecutionContext, INestApplication } from '@nestjs/common'; +import { INestApplication } from '@nestjs/common'; import { Test, TestingModule } from '@nestjs/testing'; -import { ApiValidationError } from '@shared/common'; -import { BoardExternalReferenceType } from '@shared/domain/domainobject'; +import { BoardExternalReferenceType, BoardLayout } from '@shared/domain/domainobject'; import { cardNodeFactory, cleanupCollections, columnBoardNodeFactory, columnNodeFactory, courseFactory, - mapUserToCurrentUser, - userFactory, + TestApiClient, + UserAndAccountTestFactory, } from '@shared/testing'; -import { Request } from 'express'; -import request from 'supertest'; import { BoardResponse } from '../dto'; const baseRouteName = '/boards'; -class API { - app: INestApplication; - - constructor(app: INestApplication) { - this.app = app; - } - - async get(id: string) { - const response = await request(this.app.getHttpServer()) - .get(`${baseRouteName}/${id}`) - .set('Accept', 'application/json'); - - return { - result: response.body as BoardResponse, - error: response.body as ApiValidationError, - status: response.status, - }; - } -} - describe(`board lookup (api)`, () => { let app: INestApplication; let em: EntityManager; - let currentUser: ICurrentUser; - let api: API; + let testApiClient: TestApiClient; beforeAll(async () => { const module: TestingModule = await Test.createTestingModule({ imports: [ServerTestModule], - }) - .overrideGuard(JwtAuthGuard) - .useValue({ - canActivate(context: ExecutionContext) { - const req: Request = context.switchToHttp().getRequest(); - req.user = currentUser; - return true; - }, - }) - .compile(); + }).compile(); app = module.createNestApplication(); await app.init(); em = module.get(EntityManager); - api = new API(app); + testApiClient = new TestApiClient(app, baseRouteName); }); afterAll(async () => { await app.close(); }); - const setup = async () => { + beforeEach(async () => { await cleanupCollections(em); - const user = userFactory.build(); - const course = courseFactory.build({ teachers: [user] }); - await em.persistAndFlush([user, course]); + }); - const columnBoardNode = columnBoardNodeFactory.buildWithId({ - context: { id: course.id, type: BoardExternalReferenceType.Course }, - }); - const columnNode = columnNodeFactory.buildWithId({ parent: columnBoardNode }); - const cardNode1 = cardNodeFactory.buildWithId({ parent: columnNode }); - const cardNode2 = cardNodeFactory.buildWithId({ parent: columnNode }); - const cardNode3 = cardNodeFactory.buildWithId({ parent: columnNode }); - const notOfThisBoardCardNode = cardNodeFactory.buildWithId(); + describe('When user is course teacher', () => { + const setup = async () => { + const { teacherAccount, teacherUser } = UserAndAccountTestFactory.buildTeacher(); + + const course = courseFactory.build({ teachers: [teacherUser] }); + await em.persistAndFlush([teacherUser, course, teacherAccount]); + + const columnBoardNode = columnBoardNodeFactory.buildWithId({ + context: { id: course.id, type: BoardExternalReferenceType.Course }, + }); + const columnNode = columnNodeFactory.buildWithId({ parent: columnBoardNode }); + const cardNode1 = cardNodeFactory.buildWithId({ parent: columnNode }); + const cardNode2 = cardNodeFactory.buildWithId({ parent: columnNode }); + const cardNode3 = cardNodeFactory.buildWithId({ parent: columnNode }); + const notOfThisBoardCardNode = cardNodeFactory.buildWithId(); + + await em.persistAndFlush([columnBoardNode, columnNode, cardNode1, cardNode2, cardNode3, notOfThisBoardCardNode]); + em.clear(); + + const loggedInClient = await testApiClient.login(teacherAccount); - await em.persistAndFlush([columnBoardNode, columnNode, cardNode1, cardNode2, cardNode3, notOfThisBoardCardNode]); - em.clear(); + return { loggedInClient, columnBoardNode, columnNode, card1: cardNode1, card2: cardNode2, card3: cardNode3 }; + }; + + describe('with valid board id', () => { + it('should return status 200', async () => { + const { loggedInClient, columnBoardNode } = await setup(); - currentUser = mapUserToCurrentUser(user); + const response = await loggedInClient.get(columnBoardNode.id); - return { columnBoardNode, columnNode, card1: cardNode1, card2: cardNode2, card3: cardNode3 }; - }; + expect(response.status).toEqual(200); + }); - describe('with valid board ids', () => { - it('should return status 200', async () => { - const { columnBoardNode } = await setup(); + it('should return the correct board', async () => { + const { loggedInClient, columnBoardNode, columnNode } = await setup(); - const response = await api.get(`${columnBoardNode.id}`); + const response = await loggedInClient.get(columnBoardNode.id); + const result = response.body as BoardResponse; - expect(response.status).toEqual(200); + expect(result.id).toEqual(columnBoardNode.id); + expect(result.columns).toHaveLength(1); + expect(result.columns[0].id).toEqual(columnNode.id); + expect(result.columns[0].cards).toHaveLength(3); + }); }); - it('should return the right board', async () => { - const { columnBoardNode, columnNode } = await setup(); + describe('board layout', () => { + it(`should default to ${BoardLayout.COLUMNS}`, async () => { + const { loggedInClient, columnBoardNode } = await setup(); - const { result } = await api.get(columnBoardNode.id); + const response = await loggedInClient.get(columnBoardNode.id); + const result = response.body as BoardResponse; - expect(result.id).toEqual(columnBoardNode.id); - expect(result.columns).toHaveLength(1); - expect(result.columns[0].id).toEqual(columnNode.id); - expect(result.columns[0].cards).toHaveLength(3); + expect(result.layout).toEqual(BoardLayout.COLUMNS); + }); }); - }); - describe('with invalid board id', () => { - it('should return status 404', async () => { - await setup(); - const notExistingBoardId = new ObjectId().toString(); + describe('with invalid board id', () => { + it('should return status 404', async () => { + const { loggedInClient } = await setup(); + const notExistingBoardId = new ObjectId().toString(); - const response = await api.get(notExistingBoardId); + const response = await loggedInClient.get(notExistingBoardId); - expect(response.status).toEqual(404); + expect(response.status).toEqual(404); + }); }); }); - describe('with invalid user', () => { - it('should return status 403', async () => { - const { columnBoardNode } = await setup(); + describe('When user does not belong to course', () => { + const setup = async () => { + const { teacherAccount, teacherUser } = UserAndAccountTestFactory.buildTeacher(); + + const course = courseFactory.build(); + await em.persistAndFlush([teacherUser, course, teacherAccount]); + + const columnBoardNode = columnBoardNodeFactory.buildWithId({ + context: { id: course.id, type: BoardExternalReferenceType.Course }, + }); + await em.persistAndFlush([columnBoardNode]); - const invalidUser = userFactory.build(); - await em.persistAndFlush([invalidUser]); - currentUser = mapUserToCurrentUser(invalidUser); + em.clear(); + + const loggedInClient = await testApiClient.login(teacherAccount); + + return { loggedInClient, columnBoardNode }; + }; + + it('should return status 403', async () => { + const { loggedInClient, columnBoardNode } = await setup(); - const response = await api.get(`${columnBoardNode.id}`); + const response = await loggedInClient.get(columnBoardNode.id); expect(response.status).toEqual(403); }); diff --git a/apps/server/src/modules/board/controller/dto/board/board.response.ts b/apps/server/src/modules/board/controller/dto/board/board.response.ts index c36ddf657c8..c8cb5128ed0 100644 --- a/apps/server/src/modules/board/controller/dto/board/board.response.ts +++ b/apps/server/src/modules/board/controller/dto/board/board.response.ts @@ -1,15 +1,17 @@ import { ApiProperty } from '@nestjs/swagger'; import { DecodeHtmlEntities } from '@shared/controller'; +import { BoardLayout } from '@shared/domain/domainobject'; import { ColumnResponse } from './column.response'; import { TimestampsResponse } from '../timestamps.response'; export class BoardResponse { - constructor({ id, title, columns, timestamps, isVisible }: BoardResponse) { + constructor({ id, title, columns, timestamps, isVisible, layout }: BoardResponse) { this.id = id; this.title = title; this.columns = columns; this.timestamps = timestamps; this.isVisible = isVisible; + this.layout = layout; } @ApiProperty({ @@ -31,4 +33,7 @@ export class BoardResponse { @ApiProperty() isVisible: boolean; + + @ApiProperty() + layout: BoardLayout; } diff --git a/apps/server/src/modules/board/controller/dto/board/create-board.body.params.ts b/apps/server/src/modules/board/controller/dto/board/create-board.body.params.ts index 08f70761ef4..aa9f01924d5 100644 --- a/apps/server/src/modules/board/controller/dto/board/create-board.body.params.ts +++ b/apps/server/src/modules/board/controller/dto/board/create-board.body.params.ts @@ -1,6 +1,6 @@ import { ApiProperty } from '@nestjs/swagger'; import { SanitizeHtml } from '@shared/controller'; -import { BoardExternalReferenceType } from '@shared/domain/domainobject'; +import { BoardExternalReferenceType, BoardLayout } from '@shared/domain/domainobject'; import { IsEnum, IsMongoId, MaxLength, MinLength } from 'class-validator'; export class CreateBoardBodyParams { @@ -28,4 +28,13 @@ export class CreateBoardBodyParams { }) @IsEnum(BoardExternalReferenceType) parentType!: BoardExternalReferenceType; + + @ApiProperty({ + description: 'The layout of the board', + default: BoardLayout.COLUMNS, + enum: BoardLayout, + enumName: 'BoardLayout', + }) + @IsEnum(BoardLayout, {}) + layout!: BoardLayout; } diff --git a/apps/server/src/modules/board/controller/mapper/board-response.mapper.ts b/apps/server/src/modules/board/controller/mapper/board-response.mapper.ts index 4c951d047ac..74682cf1e25 100644 --- a/apps/server/src/modules/board/controller/mapper/board-response.mapper.ts +++ b/apps/server/src/modules/board/controller/mapper/board-response.mapper.ts @@ -20,6 +20,7 @@ export class BoardResponseMapper { }), timestamps: new TimestampsResponse({ lastUpdatedAt: board.updatedAt, createdAt: board.createdAt }), isVisible: board.isVisible, + layout: board.layout, }); return result; } diff --git a/apps/server/src/modules/board/repo/board-do.builder-impl.ts b/apps/server/src/modules/board/repo/board-do.builder-impl.ts index 79b1ce51851..82394678d82 100644 --- a/apps/server/src/modules/board/repo/board-do.builder-impl.ts +++ b/apps/server/src/modules/board/repo/board-do.builder-impl.ts @@ -16,9 +16,9 @@ import { SubmissionItem, } from '@shared/domain/domainobject'; import { + BoardNodeType, type BoardDoBuilder, type BoardNode, - BoardNodeType, type CardNode, type ColumnBoardNode, type ColumnNode, @@ -61,6 +61,7 @@ export class BoardDoBuilderImpl implements BoardDoBuilder { updatedAt: boardNode.updatedAt, context: boardNode.context, isVisible: boardNode.isVisible ?? false, + layout: boardNode.layout, }); return columnBoard; diff --git a/apps/server/src/modules/board/repo/recursive-save.visitor.ts b/apps/server/src/modules/board/repo/recursive-save.visitor.ts index 9516809a748..51506bcadcd 100644 --- a/apps/server/src/modules/board/repo/recursive-save.visitor.ts +++ b/apps/server/src/modules/board/repo/recursive-save.visitor.ts @@ -72,6 +72,7 @@ export class RecursiveSaveVisitor implements BoardCompositeVisitor { position: parentData?.position, context: columnBoard.context, isVisible: columnBoard.isVisible, + layout: columnBoard.layout, }); this.saveRecursive(boardNode, columnBoard); diff --git a/apps/server/src/modules/board/service/board-do-copy-service/recursive-copy.visitor.ts b/apps/server/src/modules/board/service/board-do-copy-service/recursive-copy.visitor.ts index ddd74e0520e..13c9b5be1aa 100644 --- a/apps/server/src/modules/board/service/board-do-copy-service/recursive-copy.visitor.ts +++ b/apps/server/src/modules/board/service/board-do-copy-service/recursive-copy.visitor.ts @@ -57,6 +57,7 @@ export class RecursiveCopyVisitor implements BoardCompositeVisitorAsync { updatedAt: new Date(), children: this.getCopiesForChildrenOf(original), isVisible: false, + layout: original.layout, }); this.resultMap.set(original.id, { diff --git a/apps/server/src/modules/board/service/column-board.service.spec.ts b/apps/server/src/modules/board/service/column-board.service.spec.ts index d5609285f87..353f053967a 100644 --- a/apps/server/src/modules/board/service/column-board.service.spec.ts +++ b/apps/server/src/modules/board/service/column-board.service.spec.ts @@ -1,16 +1,17 @@ import { createMock, DeepMocked } from '@golevelup/ts-jest'; import { Configuration } from '@hpi-schul-cloud/commons/lib'; import { IConfig } from '@hpi-schul-cloud/commons/lib/interfaces/IConfig'; +import { ObjectId } from '@mikro-orm/mongodb'; import { Test, TestingModule } from '@nestjs/testing'; import { BoardExternalReference, BoardExternalReferenceType, + BoardLayout, ColumnBoard, ContentElementFactory, } from '@shared/domain/domainobject'; import { columnBoardNodeFactory, setupEntities } from '@shared/testing'; import { columnBoardFactory, columnFactory, richTextElementFactory } from '@shared/testing/factory/domainobject'; -import { ObjectId } from '@mikro-orm/mongodb'; import { BoardDoRepo } from '../repo'; import { BoardDoService } from './board-do.service'; import { ColumnBoardService } from './column-board.service'; @@ -164,7 +165,7 @@ describe(ColumnBoardService.name, () => { const { context } = setupBoards(); const title = `My brand new Mainboard`; - const columnBoardInfo = await service.create(context, title); + const columnBoardInfo = await service.create(context, BoardLayout.COLUMNS, title); expect(columnBoardInfo).toEqual(expect.objectContaining({ title })); }); diff --git a/apps/server/src/modules/board/service/column-board.service.ts b/apps/server/src/modules/board/service/column-board.service.ts index 647ff80e3a4..755373050c9 100644 --- a/apps/server/src/modules/board/service/column-board.service.ts +++ b/apps/server/src/modules/board/service/column-board.service.ts @@ -4,6 +4,7 @@ import { AnyBoardDo, BoardExternalReference, BoardExternalReferenceType, + BoardLayout, ColumnBoard, MediaBoard, } from '@shared/domain/domainobject'; @@ -38,7 +39,7 @@ export class ColumnBoardService { return titleMap; } - async create(context: BoardExternalReference, title = ''): Promise { + async create(context: BoardExternalReference, layout: BoardLayout, title: string): Promise { const columnBoard = new ColumnBoard({ id: new ObjectId().toHexString(), title, @@ -47,6 +48,7 @@ export class ColumnBoardService { updatedAt: new Date(), context, isVisible: false, + layout, }); await this.boardDoRepo.save(columnBoard); diff --git a/apps/server/src/modules/board/uc/board.uc.ts b/apps/server/src/modules/board/uc/board.uc.ts index 363b7b3aa5d..19b742c8f60 100644 --- a/apps/server/src/modules/board/uc/board.uc.ts +++ b/apps/server/src/modules/board/uc/board.uc.ts @@ -40,7 +40,8 @@ export class BoardUc extends BaseUc { }); const context = { type: params.parentType, id: params.parentId }; - const board = await this.columnBoardService.create(context, params.title); + + const board = await this.columnBoardService.create(context, params.layout, params.title); return board; } diff --git a/apps/server/src/modules/learnroom/controller/dto/single-column-board/board-column-board.response.ts b/apps/server/src/modules/learnroom/controller/dto/single-column-board/board-column-board.response.ts index 5471e4d40b4..9975470646d 100644 --- a/apps/server/src/modules/learnroom/controller/dto/single-column-board/board-column-board.response.ts +++ b/apps/server/src/modules/learnroom/controller/dto/single-column-board/board-column-board.response.ts @@ -1,14 +1,16 @@ import { ApiProperty } from '@nestjs/swagger'; import { DecodeHtmlEntities } from '@shared/controller'; +import { BoardLayout } from '@shared/domain/domainobject'; export class BoardColumnBoardResponse { - constructor({ id, columnBoardId, title, published, createdAt, updatedAt }: BoardColumnBoardResponse) { + constructor({ id, columnBoardId, title, published, createdAt, updatedAt, layout }: BoardColumnBoardResponse) { this.id = id; this.columnBoardId = columnBoardId; this.title = title; this.published = published; this.createdAt = createdAt; this.updatedAt = updatedAt; + this.layout = layout; } @ApiProperty() @@ -29,4 +31,7 @@ export class BoardColumnBoardResponse { @ApiProperty() columnBoardId: string; + + @ApiProperty() + layout: BoardLayout; } diff --git a/apps/server/src/modules/learnroom/mapper/room-board-response.mapper.spec.ts b/apps/server/src/modules/learnroom/mapper/room-board-response.mapper.spec.ts index 9244b453a21..36a5c9073c8 100644 --- a/apps/server/src/modules/learnroom/mapper/room-board-response.mapper.spec.ts +++ b/apps/server/src/modules/learnroom/mapper/room-board-response.mapper.spec.ts @@ -1,8 +1,9 @@ +import { ObjectId } from '@mikro-orm/mongodb'; import { Test, TestingModule } from '@nestjs/testing'; +import { BoardLayout } from '@shared/domain/domainobject'; import { courseFactory, setupEntities, taskFactory } from '@shared/testing'; -import { ObjectId } from '@mikro-orm/mongodb'; import { BoardElementResponse, SingleColumnBoardResponse } from '../controller/dto'; -import { RoomBoardDTO, RoomBoardElementTypes } from '../types'; +import { ColumnBoardMetaData, RoomBoardDTO, RoomBoardElementTypes } from '../types'; import { RoomBoardResponseMapper } from './room-board-response.mapper'; describe('room board response mapper', () => { @@ -157,11 +158,12 @@ describe('room board response mapper', () => { }); it('should map column board targets on board to response', () => { - const columnBoardMetaData = { + const columnBoardMetaData: ColumnBoardMetaData = { id: new ObjectId().toHexString(), columnBoardId: new ObjectId().toHexString(), title: 'column board #1', published: true, + layout: BoardLayout.COLUMNS, createdAt: new Date(), updatedAt: new Date(), }; diff --git a/apps/server/src/modules/learnroom/mapper/room-board-response.mapper.ts b/apps/server/src/modules/learnroom/mapper/room-board-response.mapper.ts index 66d10b74404..09703f57eec 100644 --- a/apps/server/src/modules/learnroom/mapper/room-board-response.mapper.ts +++ b/apps/server/src/modules/learnroom/mapper/room-board-response.mapper.ts @@ -99,6 +99,7 @@ export class RoomBoardResponseMapper { published: columnBoardInfo.published, createdAt: columnBoardInfo.createdAt, updatedAt: columnBoardInfo.updatedAt, + layout: columnBoardInfo.layout, }); const boardElementResponse = new BoardElementResponse({ diff --git a/apps/server/src/modules/learnroom/service/common-cartridge-import.service.ts b/apps/server/src/modules/learnroom/service/common-cartridge-import.service.ts index 88e6e1c6e0e..c53f98bb0d1 100644 --- a/apps/server/src/modules/learnroom/service/common-cartridge-import.service.ts +++ b/apps/server/src/modules/learnroom/service/common-cartridge-import.service.ts @@ -1,5 +1,5 @@ import { Injectable } from '@nestjs/common'; -import { BoardExternalReferenceType, Column, ColumnBoard } from '@shared/domain/domainobject'; +import { BoardExternalReferenceType, BoardLayout, Column, ColumnBoard } from '@shared/domain/domainobject'; import { Course, User } from '@shared/domain/entity'; import { CardService, ColumnBoardService, ColumnService, ContentElementService } from '@src/modules/board'; import { @@ -38,7 +38,8 @@ export class CommonCartridgeImportService { type: BoardExternalReferenceType.Course, id: course.id, }, - parser.getTitle() + BoardLayout.COLUMNS, + parser.getTitle() || '' ); await this.createColumns(parser, columnBoard); diff --git a/apps/server/src/modules/learnroom/types/room-board.types.ts b/apps/server/src/modules/learnroom/types/room-board.types.ts index 5c0297c6ff8..d657e0d1a9b 100644 --- a/apps/server/src/modules/learnroom/types/room-board.types.ts +++ b/apps/server/src/modules/learnroom/types/room-board.types.ts @@ -1,3 +1,4 @@ +import { BoardLayout } from '@shared/domain/domainobject'; import { TaskWithStatusVo } from '@shared/domain/entity'; import { EntityId } from '@shared/domain/types'; @@ -35,6 +36,7 @@ export type ColumnBoardMetaData = { published: boolean; createdAt: Date; updatedAt: Date; + layout: BoardLayout; }; export type RoomBoardElementDTO = { diff --git a/apps/server/src/modules/learnroom/uc/room-board-dto.factory.ts b/apps/server/src/modules/learnroom/uc/room-board-dto.factory.ts index 2670bf81f84..5ea348cb088 100644 --- a/apps/server/src/modules/learnroom/uc/room-board-dto.factory.ts +++ b/apps/server/src/modules/learnroom/uc/room-board-dto.factory.ts @@ -165,6 +165,7 @@ class DtoCreator { createdAt: columnBoardNode.createdAt, updatedAt: columnBoardNode.updatedAt, published: columnBoardNode.isVisible, + layout: columnBoardNode.layout, }; return { type, content }; diff --git a/apps/server/src/modules/server/api/dto/config.response.ts b/apps/server/src/modules/server/api/dto/config.response.ts index 163dd9bf6e6..3e996219b52 100644 --- a/apps/server/src/modules/server/api/dto/config.response.ts +++ b/apps/server/src/modules/server/api/dto/config.response.ts @@ -122,6 +122,9 @@ export class ConfigResponse { @ApiProperty() FEATURE_TASK_SHARE: boolean; + @ApiProperty() + FEATURE_BOARD_LAYOUT_ENABLED: boolean; + @ApiProperty() FEATURE_USER_MIGRATION_ENABLED: boolean; @@ -222,6 +225,7 @@ export class ConfigResponse { this.FEATURE_LOGIN_LINK_ENABLED = config.FEATURE_LOGIN_LINK_ENABLED; this.FEATURE_LESSON_SHARE = config.FEATURE_LESSON_SHARE; this.FEATURE_TASK_SHARE = config.FEATURE_TASK_SHARE; + this.FEATURE_BOARD_LAYOUT_ENABLED = config.FEATURE_BOARD_LAYOUT_ENABLED; this.FEATURE_USER_MIGRATION_ENABLED = config.userMigrationEnabled; this.FEATURE_COPY_SERVICE_ENABLED = config.FEATURE_COPY_SERVICE_ENABLED; this.FEATURE_CONSENT_NECESSARY = config.FEATURE_CONSENT_NECESSARY; diff --git a/apps/server/src/modules/server/api/test/server.api.spec.ts b/apps/server/src/modules/server/api/test/server.api.spec.ts index 2a271e7d256..d2b501dffea 100644 --- a/apps/server/src/modules/server/api/test/server.api.spec.ts +++ b/apps/server/src/modules/server/api/test/server.api.spec.ts @@ -46,6 +46,7 @@ describe('Server Controller (API)', () => { 'FEATURE_COLUMN_BOARD_LINK_ELEMENT_ENABLED', 'FEATURE_COLUMN_BOARD_SUBMISSIONS_ENABLED', 'FEATURE_COLUMN_BOARD_SHARE', + 'FEATURE_BOARD_LAYOUT_ENABLED', 'FEATURE_CONSENT_NECESSARY', 'FEATURE_COPY_SERVICE_ENABLED', 'FEATURE_COURSE_SHARE', diff --git a/apps/server/src/modules/server/server.config.ts b/apps/server/src/modules/server/server.config.ts index 8acfa3c8bc0..54e586be01d 100644 --- a/apps/server/src/modules/server/server.config.ts +++ b/apps/server/src/modules/server/server.config.ts @@ -76,6 +76,7 @@ export interface ServerConfig FEATURE_COLUMN_BOARD_LINK_ELEMENT_ENABLED: boolean; FEATURE_COLUMN_BOARD_EXTERNAL_TOOLS_ENABLED: boolean; FEATURE_COLUMN_BOARD_SHARE: boolean; + FEATURE_BOARD_LAYOUT_ENABLED: boolean; FEATURE_LOGIN_LINK_ENABLED: boolean; FEATURE_CONSENT_NECESSARY: boolean; FEATURE_SCHOOL_SANIS_USER_MIGRATION_ENABLED: boolean; @@ -129,6 +130,7 @@ const config: ServerConfig = { FEATURE_COURSE_SHARE: Configuration.get('FEATURE_COURSE_SHARE') as boolean, FEATURE_LESSON_SHARE: Configuration.get('FEATURE_LESSON_SHARE') as boolean, FEATURE_TASK_SHARE: Configuration.get('FEATURE_TASK_SHARE') as boolean, + FEATURE_BOARD_LAYOUT_ENABLED: Configuration.get('FEATURE_BOARD_LAYOUT_ENABLED') as boolean, FEATURE_LOGIN_LINK_ENABLED: Configuration.get('FEATURE_LOGIN_LINK_ENABLED') as boolean, FEATURE_COPY_SERVICE_ENABLED: Configuration.get('FEATURE_COPY_SERVICE_ENABLED') as boolean, FEATURE_CONSENT_NECESSARY: Configuration.get('FEATURE_CONSENT_NECESSARY') as boolean, diff --git a/apps/server/src/shared/domain/domainobject/board/column-board.do.ts b/apps/server/src/shared/domain/domainobject/board/column-board.do.ts index de420852694..7115e9a3c2c 100644 --- a/apps/server/src/shared/domain/domainobject/board/column-board.do.ts +++ b/apps/server/src/shared/domain/domainobject/board/column-board.do.ts @@ -1,6 +1,7 @@ import { BoardComposite, BoardCompositeProps } from './board-composite.do'; import { Column } from './column.do'; import type { AnyBoardDo, BoardCompositeVisitor, BoardCompositeVisitorAsync, BoardExternalReference } from './types'; +import { BoardLayout } from './types/board-layout.enum'; export class ColumnBoard extends BoardComposite { get title(): string { @@ -27,6 +28,10 @@ export class ColumnBoard extends BoardComposite { this.props.isVisible = isVisible; } + get layout(): BoardLayout { + return this.props.layout; + } + isAllowedAsChild(domainObject: AnyBoardDo): boolean { const allowed = domainObject instanceof Column; return allowed; @@ -45,6 +50,7 @@ export interface ColumnBoardProps extends BoardCompositeProps { title: string; context: BoardExternalReference; isVisible: boolean; + layout: BoardLayout; } export function isColumnBoard(reference: unknown): reference is ColumnBoard { diff --git a/apps/server/src/shared/domain/domainobject/board/types/board-layout.enum.ts b/apps/server/src/shared/domain/domainobject/board/types/board-layout.enum.ts new file mode 100644 index 00000000000..9d41bde2592 --- /dev/null +++ b/apps/server/src/shared/domain/domainobject/board/types/board-layout.enum.ts @@ -0,0 +1,4 @@ +export enum BoardLayout { + COLUMNS = 'columns', + LIST = 'list', +} diff --git a/apps/server/src/shared/domain/domainobject/board/types/index.ts b/apps/server/src/shared/domain/domainobject/board/types/index.ts index 0c3298673a0..8c080ffa4a9 100644 --- a/apps/server/src/shared/domain/domainobject/board/types/index.ts +++ b/apps/server/src/shared/domain/domainobject/board/types/index.ts @@ -7,3 +7,4 @@ export * from './board-do-authorizable'; export * from './board-external-reference'; export * from './column-board-info'; export * from './content-elements.enum'; +export * from './board-layout.enum'; diff --git a/apps/server/src/shared/domain/entity/boardnode/boardnode.entity.spec.ts b/apps/server/src/shared/domain/entity/boardnode/boardnode.entity.spec.ts index 131789557ac..a8dd6d411ef 100644 --- a/apps/server/src/shared/domain/entity/boardnode/boardnode.entity.spec.ts +++ b/apps/server/src/shared/domain/entity/boardnode/boardnode.entity.spec.ts @@ -1,4 +1,4 @@ -import { BoardExternalReferenceType } from '@shared/domain/domainobject'; +import { BoardExternalReferenceType, BoardLayout } from '@shared/domain/domainobject'; import { cardNodeFactory, columnBoardNodeFactory, setupEntities } from '@shared/testing'; import { BoardNode } from './boardnode.entity'; import { ColumnBoardNode } from './column-board-node.entity'; @@ -18,6 +18,7 @@ describe(BoardNode.name, () => { title: 'column #1', context: { type: BoardExternalReferenceType.Course, id: 'course1' }, isVisible: true, + layout: BoardLayout.COLUMNS, }); column.title = 'hate to get useless sonar lint errors'; }).toThrowError(); diff --git a/apps/server/src/shared/domain/entity/boardnode/column-board-node.entity.ts b/apps/server/src/shared/domain/entity/boardnode/column-board-node.entity.ts index 8539fefe2a2..237ae44bd61 100644 --- a/apps/server/src/shared/domain/entity/boardnode/column-board-node.entity.ts +++ b/apps/server/src/shared/domain/entity/boardnode/column-board-node.entity.ts @@ -4,6 +4,7 @@ import { AnyBoardDo, BoardExternalReference, BoardExternalReferenceType, + BoardLayout, } from '@shared/domain/domainobject/board/types'; import { LearnroomElement } from '../../interface'; import { BoardNode } from './boardnode.entity'; @@ -21,6 +22,8 @@ export class ColumnBoardNode extends BoardNode implements LearnroomElement { this._contextId = new ObjectId(props.context.id); this.isVisible = props.isVisible ?? false; + + this.layout = props.layout ?? BoardLayout.COLUMNS; } @Property({ fieldName: 'contextType' }) @@ -39,6 +42,9 @@ export class ColumnBoardNode extends BoardNode implements LearnroomElement { }; } + @Property({ nullable: false }) + layout: BoardLayout; + useDoBuilder(builder: BoardDoBuilder): AnyBoardDo { const domainObject = builder.buildColumnBoard(this); return domainObject; @@ -61,4 +67,5 @@ export class ColumnBoardNode extends BoardNode implements LearnroomElement { export interface ColumnBoardNodeProps extends RootBoardNodeProps { isVisible: boolean; + layout: BoardLayout; } diff --git a/apps/server/src/shared/testing/factory/boardnode/column-board-node.factory.ts b/apps/server/src/shared/testing/factory/boardnode/column-board-node.factory.ts index 6fed4bbbe77..7b6377c2f2d 100644 --- a/apps/server/src/shared/testing/factory/boardnode/column-board-node.factory.ts +++ b/apps/server/src/shared/testing/factory/boardnode/column-board-node.factory.ts @@ -1,5 +1,5 @@ /* istanbul ignore file */ -import { BoardExternalReferenceType } from '@shared/domain/domainobject/board/types'; +import { BoardExternalReferenceType, BoardLayout } from '@shared/domain/domainobject/board/types'; import { ColumnBoardNode, ColumnBoardNodeProps } from '@shared/domain/entity'; import { ObjectId } from '@mikro-orm/mongodb'; import { BaseFactory } from '../base.factory'; @@ -14,6 +14,7 @@ export const columnBoardNodeFactory = BaseFactory.define