Skip to content

Commit

Permalink
BC-5189 - link element (#4444)
Browse files Browse the repository at this point in the history
First implementation of LinkElement to be used on cards of columnBoards.

* implemented open-graph-proxy-service to gather open graph data from urls
* open-graph-data is fetched during updateElement and enriches the input from the user (=url)
* invalid urls are handled in the open-graph-proxy
* if multiple open-graph-images are provided the smallest one (exceeding a min-width) will be chosen
* feature toogle is used to disable the feature for the moment

---------

Co-authored-by: Oliver Happe <[email protected]>
  • Loading branch information
hoeppner-dataport and OliverHappe authored Oct 11, 2023
1 parent 4d5a69c commit a8f7cda
Show file tree
Hide file tree
Showing 56 changed files with 1,550 additions and 138 deletions.
2 changes: 2 additions & 0 deletions apps/server/src/modules/board/board.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import {
ColumnBoardService,
ColumnService,
ContentElementService,
OpenGraphProxyService,
SubmissionItemService,
} from './service';
import { BoardDoCopyService, SchoolSpecificFileCopyServiceFactory } from './service/board-do-copy-service';
Expand All @@ -37,6 +38,7 @@ import { ColumnBoardCopyService } from './service/column-board-copy.service';
BoardDoCopyService,
ColumnBoardCopyService,
SchoolSpecificFileCopyServiceFactory,
OpenGraphProxyService,
],
exports: [
BoardDoAuthorizableService,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -87,7 +87,7 @@ describe(`card create (api)`, () => {
em.clear();

const createCardBodyParams = {
requiredEmptyElements: [ContentElementType.RICH_TEXT, ContentElementType.FILE],
requiredEmptyElements: [ContentElementType.RICH_TEXT, ContentElementType.FILE, ContentElementType.LINK],
};

return { user, columnBoardNode, columnNode, createCardBodyParams };
Expand All @@ -111,7 +111,7 @@ describe(`card create (api)`, () => {

expect(result.id).toBeDefined();
});
it('created card should contain empty text and file elements', async () => {
it('created card should contain empty text, file and link elements', async () => {
const { user, columnNode, createCardBodyParams } = await setup();
currentUser = mapUserToCurrentUser(user);

Expand All @@ -129,13 +129,20 @@ describe(`card create (api)`, () => {
alternativeText: '',
},
},
{
type: 'link',
content: {
url: '',
},
},
];

const { result } = await api.post(columnNode.id, createCardBodyParams);
const { elements } = result;

expect(elements[0]).toMatchObject(expectedEmptyElements[0]);
expect(elements[1]).toMatchObject(expectedEmptyElements[1]);
expect(elements[2]).toMatchObject(expectedEmptyElements[2]);
});
it('should return status 400 as the content element is unknown', async () => {
const { user, columnNode } = await setup();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -98,7 +98,7 @@ describe(`content element update content (api)`, () => {
};
};

it('should return status 204', async () => {
it('should return status 201', async () => {
const { loggedInClient, richTextElement } = await setup();

const response = await loggedInClient.patch(`${richTextElement.id}/content`, {
Expand All @@ -108,7 +108,7 @@ describe(`content element update content (api)`, () => {
},
});

expect(response.statusCode).toEqual(204);
expect(response.statusCode).toEqual(201);
});

it('should actually change content of the element', async () => {
Expand Down Expand Up @@ -167,7 +167,7 @@ describe(`content element update content (api)`, () => {
expect(result.alternativeText).toEqual('rich text 1 some more text');
});

it('should return status 204 (nothing changed) without dueDate parameter for submission container element', async () => {
it('should return status 201', async () => {
const { loggedInClient, submissionContainerElement } = await setup();
const response = await loggedInClient.patch(`${submissionContainerElement.id}/content`, {
data: {
Expand All @@ -176,7 +176,7 @@ describe(`content element update content (api)`, () => {
},
});

expect(response.statusCode).toEqual(204);
expect(response.statusCode).toEqual(201);
});

it('should not change dueDate when not proviced in submission container element without dueDate', async () => {
Expand Down
15 changes: 9 additions & 6 deletions apps/server/src/modules/board/controller/card.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ import {
CreateContentElementBodyParams,
ExternalToolElementResponse,
FileElementResponse,
LinkElementResponse,
MoveCardBodyParams,
RenameBodyParams,
RichTextElementResponse,
Expand Down Expand Up @@ -116,19 +117,21 @@ export class CardController {

@ApiOperation({ summary: 'Create a new element on a card.' })
@ApiExtraModels(
RichTextElementResponse,
ExternalToolElementResponse,
FileElementResponse,
SubmissionContainerElementResponse,
ExternalToolElementResponse
LinkElementResponse,
RichTextElementResponse,
SubmissionContainerElementResponse
)
@ApiResponse({
status: 201,
schema: {
oneOf: [
{ $ref: getSchemaPath(RichTextElementResponse) },
{ $ref: getSchemaPath(ExternalToolElementResponse) },
{ $ref: getSchemaPath(FileElementResponse) },
{ $ref: getSchemaPath(LinkElementResponse) },
{ $ref: getSchemaPath(RichTextElementResponse) },
{ $ref: getSchemaPath(SubmissionContainerElementResponse) },
{ $ref: getSchemaPath(ExternalToolElementResponse) },
],
},
})
Expand All @@ -137,7 +140,7 @@ export class CardController {
@ApiResponse({ status: 404, type: NotFoundException })
@Post(':cardId/elements')
async createElement(
@Param() urlParams: CardUrlParams, // TODO add type-property ?
@Param() urlParams: CardUrlParams,
@Body() bodyParams: CreateContentElementBodyParams,
@CurrentUser() currentUser: ICurrentUser
): Promise<AnyContentElementResponse> {
Expand Down
22 changes: 18 additions & 4 deletions apps/server/src/modules/board/controller/dto/card/card.response.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,23 @@
import { ApiExtraModels, ApiProperty, ApiPropertyOptional, getSchemaPath } from '@nestjs/swagger';
import { DecodeHtmlEntities } from '@shared/controller';
import { AnyContentElementResponse, FileElementResponse, SubmissionContainerElementResponse } from '../element';
import { RichTextElementResponse } from '../element/rich-text-element.response';
import {
AnyContentElementResponse,
ExternalToolElementResponse,
FileElementResponse,
LinkElementResponse,
RichTextElementResponse,
SubmissionContainerElementResponse,
} from '../element';
import { TimestampsResponse } from '../timestamps.response';
import { VisibilitySettingsResponse } from './visibility-settings.response';

@ApiExtraModels(RichTextElementResponse)
@ApiExtraModels(
ExternalToolElementResponse,
FileElementResponse,
LinkElementResponse,
RichTextElementResponse,
SubmissionContainerElementResponse
)
export class CardResponse {
constructor({ id, title, height, elements, visibilitySettings, timestamps }: CardResponse) {
this.id = id;
Expand All @@ -32,8 +44,10 @@ export class CardResponse {
type: 'array',
items: {
oneOf: [
{ $ref: getSchemaPath(RichTextElementResponse) },
{ $ref: getSchemaPath(ExternalToolElementResponse) },
{ $ref: getSchemaPath(FileElementResponse) },
{ $ref: getSchemaPath(LinkElementResponse) },
{ $ref: getSchemaPath(RichTextElementResponse) },
{ $ref: getSchemaPath(SubmissionContainerElementResponse) },
],
},
Expand Down
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
import { ExternalToolElementResponse } from './external-tool-element.response';
import { FileElementResponse } from './file-element.response';
import { LinkElementResponse } from './link-element.response';
import { RichTextElementResponse } from './rich-text-element.response';
import { SubmissionContainerElementResponse } from './submission-container-element.response';

export type AnyContentElementResponse =
| FileElementResponse
| LinkElementResponse
| RichTextElementResponse
| SubmissionContainerElementResponse
| ExternalToolElementResponse;
5 changes: 3 additions & 2 deletions apps/server/src/modules/board/controller/dto/element/index.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
export * from './any-content-element.response';
export * from './create-content-element.body.params';
export * from './update-element-content.body.params';
export * from './external-tool-element.response';
export * from './file-element.response';
export * from './link-element.response';
export * from './rich-text-element.response';
export * from './submission-container-element.response';
export * from './external-tool-element.response';
export * from './update-element-content.body.params';
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { ContentElementType } from '@shared/domain';
import { TimestampsResponse } from '../timestamps.response';

export class LinkElementContent {
constructor({ url, title, description, imageUrl }: LinkElementContent) {
this.url = url;
this.title = title;
this.description = description;
this.imageUrl = imageUrl;
}

@ApiProperty()
url: string;

@ApiProperty()
title: string;

@ApiPropertyOptional()
description?: string;

@ApiPropertyOptional()
imageUrl?: string;
}

export class LinkElementResponse {
constructor({ id, content, timestamps, type }: LinkElementResponse) {
this.id = id;
this.content = content;
this.timestamps = timestamps;
this.type = type;
}

@ApiProperty({ pattern: '[a-f0-9]{24}' })
id: string;

@ApiProperty({ enum: ContentElementType, enumName: 'ContentElementType' })
type: ContentElementType.LINK;

@ApiProperty()
content: LinkElementContent;

@ApiProperty()
timestamps: TimestampsResponse;
}
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,20 @@ export class FileElementContentBody extends ElementContentBody {
@ApiProperty()
content!: FileContentBody;
}
export class LinkContentBody {
@IsString()
@ApiProperty({})
url!: string;
}

export class LinkElementContentBody extends ElementContentBody {
@ApiProperty({ type: ContentElementType.LINK })
type!: ContentElementType.LINK;

@ValidateNested()
@ApiProperty({})
content!: LinkContentBody;
}

export class RichTextContentBody {
@IsString()
Expand Down Expand Up @@ -89,6 +103,7 @@ export class ExternalToolElementContentBody extends ElementContentBody {

export type AnyElementContentBody =
| FileContentBody
| LinkContentBody
| RichTextContentBody
| SubmissionContainerContentBody
| ExternalToolContentBody;
Expand All @@ -100,6 +115,7 @@ export class UpdateElementContentBodyParams {
property: 'type',
subTypes: [
{ value: FileElementContentBody, name: ContentElementType.FILE },
{ value: LinkElementContentBody, name: ContentElementType.LINK },
{ value: RichTextElementContentBody, name: ContentElementType.RICH_TEXT },
{ value: SubmissionContainerElementContentBody, name: ContentElementType.SUBMISSION_CONTAINER },
{ value: ExternalToolElementContentBody, name: ContentElementType.EXTERNAL_TOOL },
Expand All @@ -110,13 +126,15 @@ export class UpdateElementContentBodyParams {
@ApiProperty({
oneOf: [
{ $ref: getSchemaPath(FileElementContentBody) },
{ $ref: getSchemaPath(LinkElementContentBody) },
{ $ref: getSchemaPath(RichTextElementContentBody) },
{ $ref: getSchemaPath(SubmissionContainerElementContentBody) },
{ $ref: getSchemaPath(ExternalToolElementContentBody) },
],
})
data!:
| FileElementContentBody
| LinkElementContentBody
| RichTextElementContentBody
| SubmissionContainerElementContentBody
| ExternalToolElementContentBody;
Expand Down
39 changes: 32 additions & 7 deletions apps/server/src/modules/board/controller/element.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,24 +10,31 @@ import {
Post,
Put,
} from '@nestjs/common';
import { ApiBody, ApiExtraModels, ApiOperation, ApiResponse, ApiTags } from '@nestjs/swagger';
import { ApiBody, ApiExtraModels, ApiOperation, ApiResponse, ApiTags, getSchemaPath } from '@nestjs/swagger';
import { ApiValidationError } from '@shared/common';
import { ICurrentUser } from '@src/modules/authentication';
import { Authenticate, CurrentUser } from '@src/modules/authentication/decorator/auth.decorator';
import { CardUc } from '../uc';
import { ElementUc } from '../uc/element.uc';
import {
AnyContentElementResponse,
ContentElementUrlParams,
CreateSubmissionItemBodyParams,
ExternalToolElementContentBody,
ExternalToolElementResponse,
FileElementContentBody,
FileElementResponse,
LinkElementContentBody,
LinkElementResponse,
MoveContentElementBody,
RichTextElementContentBody,
RichTextElementResponse,
SubmissionContainerElementContentBody,
SubmissionContainerElementResponse,
SubmissionItemResponse,
UpdateElementContentBodyParams,
} from './dto';
import { SubmissionItemResponseMapper } from './mapper';
import { ContentElementResponseFactory, SubmissionItemResponseMapper } from './mapper';

@ApiTags('Board Element')
@Authenticate('jwt')
Expand Down Expand Up @@ -60,20 +67,38 @@ export class ElementController {
FileElementContentBody,
RichTextElementContentBody,
SubmissionContainerElementContentBody,
ExternalToolElementContentBody
ExternalToolElementContentBody,
LinkElementContentBody
)
@ApiResponse({ status: 204 })
@ApiResponse({
status: 201,
schema: {
oneOf: [
{ $ref: getSchemaPath(ExternalToolElementResponse) },
{ $ref: getSchemaPath(FileElementResponse) },
{ $ref: getSchemaPath(LinkElementResponse) },
{ $ref: getSchemaPath(RichTextElementResponse) },
{ $ref: getSchemaPath(SubmissionContainerElementResponse) },
],
},
})
@ApiResponse({ status: 400, type: ApiValidationError })
@ApiResponse({ status: 403, type: ForbiddenException })
@ApiResponse({ status: 404, type: NotFoundException })
@HttpCode(204)
@HttpCode(201)
@Patch(':contentElementId/content')
async updateElement(
@Param() urlParams: ContentElementUrlParams,
@Body() bodyParams: UpdateElementContentBodyParams,
@CurrentUser() currentUser: ICurrentUser
): Promise<void> {
await this.elementUc.updateElementContent(currentUser.userId, urlParams.contentElementId, bodyParams.data.content);
): Promise<AnyContentElementResponse> {
const element = await this.elementUc.updateElementContent(
currentUser.userId,
urlParams.contentElementId,
bodyParams.data.content
);
const response = ContentElementResponseFactory.mapToResponse(element);
return response;
}

@ApiOperation({ summary: 'Delete a single content element.' })
Expand Down
Loading

0 comments on commit a8f7cda

Please sign in to comment.