diff --git a/src/components/data-board/BoardApi.composable.ts b/src/components/data-board/BoardApi.composable.ts index a6b93c4f1b..5e088a5339 100644 --- a/src/components/data-board/BoardApi.composable.ts +++ b/src/components/data-board/BoardApi.composable.ts @@ -9,12 +9,12 @@ import { ContentElementType, CreateCardBodyParamsRequiredEmptyElementsEnum, CreateContentElementBodyParams, - ExternalToolElementResponse, - FileElementContent, - LinkElementContent, - RichTextElementContent, + ExternalToolElementContentBody, + FileElementContentBody, + LinkElementContentBody, + RichTextElementContentBody, RoomsApiFactory, - SubmissionContainerElementContent, + SubmissionContainerElementContentBody, } from "@/serverApi/v3"; import { AnyContentElement } from "@/types/board/ContentElement"; import { $axios, mapAxiosErrorToResponseError } from "@/utils/api"; @@ -73,51 +73,42 @@ export const useBoardApi = () => { const generateDataProp = (element: AnyContentElement) => { if (element.type === ContentElementType.RichText) { return { - content: element.content as RichTextElementContent, + content: element.content, type: element.type, - }; + } as RichTextElementContentBody; } if (element.type === ContentElementType.File) { return { - content: element.content as FileElementContent, + content: element.content, type: ContentElementType.File, - }; + } as FileElementContentBody; } if (element.type === ContentElementType.SubmissionContainer) { return { - content: element.content as SubmissionContainerElementContent, + content: element.content, type: ContentElementType.SubmissionContainer, - }; + } as SubmissionContainerElementContentBody; } - if (isExternalToolElement(element)) { + if (element.type === ContentElementType.Link) { return { - content: { - contextExternalToolId: - element.content.contextExternalToolId ?? undefined, - }, - type: ContentElementType.ExternalTool, - }; + content: element.content, + type: ContentElementType.Link, + } as LinkElementContentBody; } - if (element.type === ContentElementType.Link) { + if (element.type === ContentElementType.ExternalTool) { return { - content: element.content as LinkElementContent, - type: ContentElementType.Link, - }; + content: element.content, + type: ContentElementType.ExternalTool, + } as ExternalToolElementContentBody; } throw new Error("element.type mapping is undefined for updateElementCall"); }; - const isExternalToolElement = ( - element: AnyContentElement - ): element is ExternalToolElementResponse => { - return element.type === ContentElementType.ExternalTool; - }; - const createElementCall = async ( cardId: string, params: CreateContentElementBodyParams diff --git a/src/components/feature-board-file-element/shared/composables/FileStorageApi.composable.ts b/src/components/feature-board-file-element/shared/composables/FileStorageApi.composable.ts index aeae0c93b8..5a2b242f2e 100644 --- a/src/components/feature-board-file-element/shared/composables/FileStorageApi.composable.ts +++ b/src/components/feature-board-file-element/shared/composables/FileStorageApi.composable.ts @@ -3,6 +3,7 @@ import { FileApiInterface, FileRecordParentType, FileRecordResponse, + FileUrlParams, RenameFileParams, } from "@/fileStorageApi/v3"; import { authModule } from "@/store/store-accessor"; @@ -65,6 +66,30 @@ export const useFileStorageApi = ( } }; + const uploadFromUrl = async (imageUrl: string): Promise => { + try { + const { pathname } = new URL(imageUrl); + const fileName = pathname.substring(pathname.lastIndexOf("/") + 1); + const schoolId = authModule.getUser?.schoolId as string; + const fileUrlParams: FileUrlParams = { + url: imageUrl, + fileName, + headers: `User-Agent: Embed Request User Agent`, + }; + const response = await fileApi.uploadFromUrl( + schoolId, + parentId, + parentType, + fileUrlParams + ); + + fileRecord.value = response.data; + } catch (error) { + showError(error); + throw error; + } + }; + const rename = async ( fileRecordId: FileRecordResponse["id"], params: RenameFileParams @@ -110,6 +135,7 @@ export const useFileStorageApi = ( fetchFile, rename, upload, + uploadFromUrl, fileRecord, }; }; diff --git a/src/components/feature-board-file-element/shared/composables/FileStorageApi.composable.unit.ts b/src/components/feature-board-file-element/shared/composables/FileStorageApi.composable.unit.ts index 5fd145e27b..867f6524c4 100644 --- a/src/components/feature-board-file-element/shared/composables/FileStorageApi.composable.unit.ts +++ b/src/components/feature-board-file-element/shared/composables/FileStorageApi.composable.unit.ts @@ -246,6 +246,104 @@ describe("FileStorageApi Composable", () => { }); }); + describe("uploadFromUrl", () => { + describe("when file api uploads file successfully", () => { + const setup = () => { + const fileName = "example-picture.jpg"; + const imageUrl = `https://www.example.com/${fileName}`; + const parentId = ObjectIdMock(); + const parentType = FileRecordParentType.BOARDNODES; + const fileRecordResponse = fileRecordResponseFactory.build({ + parentId, + parentType, + name: fileName, + }); + const response = { + data: fileRecordResponse, + }; + + const uploadFromUrlMock = jest.fn().mockResolvedValueOnce(response); + const { fileApiFactory } = setupFileStorageFactoryMock({ + uploadFromUrlMock, + }); + setupFileStorageNotifier(); + + return { + parentId, + parentType, + fileApiFactory, + fileRecordResponse, + fileName, + imageUrl, + }; + }; + + it("should call FileApiFactory.uploadFromUrl", async () => { + const { parentId, parentType, fileApiFactory, fileName, imageUrl } = + setup(); + const { uploadFromUrl } = useFileStorageApi(parentId, parentType); + + await uploadFromUrl(imageUrl); + + expect(fileApiFactory.uploadFromUrl).toBeCalledWith( + "schoolId", + parentId, + parentType, + expect.objectContaining({ + url: imageUrl, + fileName, + }) + ); + }); + + it("should set filerecord", async () => { + const { parentId, parentType, imageUrl, fileRecordResponse } = setup(); + const { uploadFromUrl, fileRecord } = useFileStorageApi( + parentId, + parentType + ); + + await uploadFromUrl(imageUrl); + + expect(fileRecord.value).toBe(fileRecordResponse); + }); + }); + + describe("when file api returns error", () => { + const setup = () => { + const parentId = ObjectIdMock(); + const parentType = FileRecordParentType.BOARDNODES; + const file = new File([""], "filename"); + + const { responseError, expectedPayload } = setupErrorResponse( + ErrorType.FILE_TOO_BIG + ); + + mockedMapAxiosErrorToResponseError.mockReturnValue(expectedPayload); + + const uploadFromUrlMock = jest.fn().mockRejectedValue(responseError); + setupFileStorageFactoryMock({ uploadFromUrlMock }); + setupFileStorageNotifier(); + + return { + parentId, + parentType, + file, + responseError, + }; + }; + + it("should call showFileTooBigError and pass error", async () => { + const { parentId, parentType, responseError } = setup(); + const { uploadFromUrl } = useFileStorageApi(parentId, parentType); + + await expect(uploadFromUrl("abc:/not-an-url")).rejects.toBe( + responseError + ); + }); + }); + }); + describe("rename", () => { describe("when file api rename file successfully", () => { const setup = () => { diff --git a/src/components/feature-board-link-element/LinkContentElement.vue b/src/components/feature-board-link-element/LinkContentElement.vue deleted file mode 100644 index 19ef62652b..0000000000 --- a/src/components/feature-board-link-element/LinkContentElement.vue +++ /dev/null @@ -1,65 +0,0 @@ - - - diff --git a/src/components/feature-board-link-element/LinkContentElementDisplay.vue b/src/components/feature-board-link-element/LinkContentElementDisplay.vue deleted file mode 100644 index 9152f9cfed..0000000000 --- a/src/components/feature-board-link-element/LinkContentElementDisplay.vue +++ /dev/null @@ -1,83 +0,0 @@ - - - - - diff --git a/src/components/feature-board-link-element/LinkContentElementEdit.vue b/src/components/feature-board-link-element/LinkContentElementEdit.vue deleted file mode 100644 index aee3a0093f..0000000000 --- a/src/components/feature-board-link-element/LinkContentElementEdit.vue +++ /dev/null @@ -1,70 +0,0 @@ - - - diff --git a/src/components/feature-board-link-element/components/LinkContentElement.unit.ts b/src/components/feature-board-link-element/components/LinkContentElement.unit.ts new file mode 100644 index 0000000000..e1dd367764 --- /dev/null +++ b/src/components/feature-board-link-element/components/LinkContentElement.unit.ts @@ -0,0 +1,212 @@ +import { AnyContentElement } from "@/types/board/ContentElement"; +import { + ENV_CONFIG_MODULE_KEY, + I18N_KEY, + NOTIFIER_MODULE_KEY, +} from "@/utils/inject"; +import { i18nMock } from "@@/tests/test-utils"; +import createComponentMocks from "@@/tests/test-utils/componentMocks"; +import { linkElementResponseFactory } from "@@/tests/test-utils/factory/linkElementResponseFactory"; +import { useBoardFocusHandler, useContentElementState } from "@data-board"; +import { LinkContentElement } from "@feature-board-link-element"; +import { createMock, DeepMocked } from "@golevelup/ts-jest"; +import { MountOptions, shallowMount } from "@vue/test-utils"; +import { LinkElementContent, MetaTagExtractorResponse } from "@/serverApi/v3"; +import { useMetaTagExtractorApi } from "../composables/MetaTagExtractorApi.composable"; +import Vue, { computed, ref } from "vue"; +import NotifierModule from "@/store/notifier"; +import { createModuleMocks } from "@/utils/mock-store-module"; +import EnvConfigModule from "@/store/env-config"; +import { Envs } from "@/store/types/env-config"; +import LinkContentElementCreate from "./LinkContentElementCreate.vue"; +import { linkElementContentFactory } from "@@/tests/test-utils/factory/linkElementContentFactory"; +import { usePreviewGenerator } from "../composables/PreviewGenerator.composable"; + +jest.mock("@data-board/ContentElementState.composable"); + +jest.mock("@data-board/BoardFocusHandler.composable"); +jest.mock("../composables/MetaTagExtractorApi.composable"); +jest.mock("../composables/PreviewGenerator.composable"); +const mockedUseContentElementState = jest.mocked(useContentElementState); + +let defaultElement = linkElementResponseFactory.build(); +const mockedEnvConfigModule = createModuleMocks(EnvConfigModule, { + getEnv: createMock({ + FEATURE_COLUMN_BOARD_SUBMISSIONS_ENABLED: true, + FEATURE_COLUMN_BOARD_LINK_ELEMENT_ENABLED: true, + FEATURE_COLUMN_BOARD_EXTERNAL_TOOLS_ENABLED: true, + }), +}); + +describe("LinkContentElement", () => { + let useBoardFocusHandlerMock: DeepMocked< + ReturnType + >; + let useMetaTagExtractorApiMock: DeepMocked< + ReturnType + >; + let usePreviewGeneratorMock: DeepMocked< + ReturnType + >; + + beforeEach(() => { + useMetaTagExtractorApiMock = + createMock>(); + usePreviewGeneratorMock = + createMock>(); + + jest.mocked(useBoardFocusHandler).mockReturnValue(useBoardFocusHandlerMock); + jest + .mocked(useMetaTagExtractorApi) + .mockReturnValue(useMetaTagExtractorApiMock); + jest.mocked(usePreviewGenerator).mockReturnValue(usePreviewGeneratorMock); + + defaultElement = linkElementResponseFactory.build(); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + const getWrapper = (props: { + element: AnyContentElement; + isEditMode: boolean; + }) => { + const notifierModule = createModuleMocks(NotifierModule); + const wrapper = shallowMount(LinkContentElement as MountOptions, { + ...createComponentMocks({ i18n: true }), + provide: { + [I18N_KEY.valueOf()]: i18nMock, + [NOTIFIER_MODULE_KEY.valueOf()]: notifierModule, + [ENV_CONFIG_MODULE_KEY.valueOf()]: mockedEnvConfigModule, + }, + propsData: { ...props }, + }); + + return { wrapper }; + }; + + const setup = ( + options: { + content?: LinkElementContent; + isEditMode: boolean; + } = { content: undefined, isEditMode: true } + ) => { + const element = { + ...defaultElement, + content: + options.content ?? linkElementContentFactory.build({ url: undefined }), + }; + document.body.setAttribute("data-app", "true"); + + mockedUseContentElementState.mockReturnValue({ + modelValue: ref(element.content), + computedElement: computed(() => element), + isLoading: ref(false), + }); + + const { wrapper } = getWrapper({ + element, + isEditMode: options.isEditMode ?? false, + }); + + return { + element, + wrapper, + }; + }; + + describe("onCreateUrl", () => { + it("should request meta tags for the given url", async () => { + const { wrapper } = setup({ isEditMode: true }); + + const component = wrapper.getComponent(LinkContentElementCreate); + component.vm.$emit("create:url", "https://abc.de"); + + expect(useMetaTagExtractorApiMock.extractMetaTags).toHaveBeenCalled(); + }); + + describe("when no protocol was provided", () => { + it("should add https-protocol", async () => { + const { wrapper } = setup({ isEditMode: true }); + const url = "abc.de/my-article"; + + const component = wrapper.getComponent(LinkContentElementCreate); + component.vm.$emit("create:url", url); + + const expected = `https://${url}`; + expect(useMetaTagExtractorApiMock.extractMetaTags).toHaveBeenCalledWith( + expected + ); + }); + }); + + describe("when url was provided", () => { + describe("when imageUrl was in metaTags", () => { + it("should create a preview image", async () => { + const { wrapper } = setup({ isEditMode: true }); + const url = "https://abc.de/my-article"; + const fakeMetaTags: MetaTagExtractorResponse = { + url, + title: "my title", + description: "", + imageUrl: "https://abc.de/foto.png", + }; + + useMetaTagExtractorApiMock.extractMetaTags.mockResolvedValue( + fakeMetaTags + ); + + const component = wrapper.getComponent(LinkContentElementCreate); + component.vm.$emit("create:url", url); + await wrapper.vm.$nextTick(); + await wrapper.vm.$nextTick(); + + expect( + usePreviewGeneratorMock.createPreviewImage + ).toHaveBeenCalledWith(fakeMetaTags.imageUrl); + }); + }); + }); + }); + + describe("when arrow key up is pressed", () => { + describe("when component is in edit-mode", () => { + it("should NOT emit 'move-keyboard:edit'", async () => { + const { wrapper } = setup({ + isEditMode: true, + }); + + const card = wrapper.findComponent({ ref: "linkContentElement" }); + card.vm.$emit( + "keydown", + new KeyboardEvent("keydown", { + key: "ArrowUp", + keyCode: 38, + }) + ); + + expect(wrapper.emitted("move-keyboard:edit")).toBeUndefined(); + }); + }); + + describe("when component is in display-mode", () => { + it("should emit 'move-keyboard:edit'", async () => { + const { wrapper } = setup({ + isEditMode: false, + }); + + const card = wrapper.findComponent({ ref: "linkContentElement" }); + card.vm.$emit( + "keydown", + new KeyboardEvent("keydown", { + key: "ArrowUp", + keyCode: 38, + }) + ); + + expect(wrapper.emitted("move-keyboard:edit")).toHaveLength(1); + }); + }); + }); +}); diff --git a/src/components/feature-board-link-element/components/LinkContentElement.vue b/src/components/feature-board-link-element/components/LinkContentElement.vue new file mode 100644 index 0000000000..c71b7b834a --- /dev/null +++ b/src/components/feature-board-link-element/components/LinkContentElement.vue @@ -0,0 +1,146 @@ + + + diff --git a/src/components/feature-board-link-element/components/LinkContentElementCreate.unit.ts b/src/components/feature-board-link-element/components/LinkContentElementCreate.unit.ts new file mode 100644 index 0000000000..80265ed49a --- /dev/null +++ b/src/components/feature-board-link-element/components/LinkContentElementCreate.unit.ts @@ -0,0 +1,167 @@ +import { ENV_CONFIG_MODULE_KEY, I18N_KEY } from "@/utils/inject"; +import { i18nMock } from "@@/tests/test-utils"; +import createComponentMocks from "@@/tests/test-utils/componentMocks"; +import { createMock } from "@golevelup/ts-jest"; +import { mount, MountOptions } from "@vue/test-utils"; +import Vue, { nextTick } from "vue"; +import { createModuleMocks } from "@/utils/mock-store-module"; +import EnvConfigModule from "@/store/env-config"; +import { Envs } from "@/store/types/env-config"; +import LinkContentElementCreate from "./LinkContentElementCreate.vue"; + +const mockedEnvConfigModule = createModuleMocks(EnvConfigModule, { + getEnv: createMock({ + FEATURE_COLUMN_BOARD_SUBMISSIONS_ENABLED: true, + FEATURE_COLUMN_BOARD_LINK_ELEMENT_ENABLED: true, + FEATURE_COLUMN_BOARD_EXTERNAL_TOOLS_ENABLED: true, + }), +}); + +const VALID_URL = "https://www.abc.de/my-article"; +const INVALID_URL = "my-article"; + +describe("LinkContentElementCreate", () => { + afterEach(() => { + jest.clearAllMocks(); + }); + + const setup = () => { + const wrapper = mount(LinkContentElementCreate as MountOptions, { + ...createComponentMocks({ i18n: true }), + provide: { + [I18N_KEY.valueOf()]: i18nMock, + [ENV_CONFIG_MODULE_KEY.valueOf()]: mockedEnvConfigModule, + }, + }); + + const insertUrl = (url: string) => { + const textAreaComponent = wrapper.findComponent({ name: "v-textarea" }); + textAreaComponent.vm.$emit("input", url); + }; + + const submitByClick = async () => { + const button = wrapper.find("button"); + await button.trigger("click"); + await nextTick(); + }; + + const submitByEnter = async () => { + const textAreaComponent = wrapper.findComponent({ name: "v-textarea" }); + textAreaComponent.vm.$emit( + "keydown", + new KeyboardEvent("keydown", { + key: "Enter", + keyCode: 13, + }) + ); + await nextTick(); + }; + + const hasEmitted = (eventName: string): string | false => { + const emitted = wrapper.emitted(eventName); + return emitted ? emitted[0][0] : false; + }; + + const areRulesActive = () => { + const rulesProperty = wrapper + .findComponent({ name: "v-textarea" }) + .props("rules"); + return typeof rulesProperty === "function"; + }; + + return { + wrapper, + insertUrl, + submitByClick, + submitByEnter, + hasEmitted, + areRulesActive, + }; + }; + + describe("when valid url was entered", () => { + describe("when enter is pressed", () => { + it("should not show error-message", async () => { + const { wrapper, insertUrl, submitByClick } = setup(); + + insertUrl(VALID_URL); + await submitByClick(); + + const alerts = wrapper.find('[role="alert"]'); + + expect(alerts.exists()).toBe(false); + }); + + it("should emit create:url event", async () => { + const { insertUrl, submitByEnter, hasEmitted } = setup(); + + insertUrl(VALID_URL); + await submitByEnter(); + + expect(hasEmitted("create:url")).toEqual(VALID_URL); + }); + }); + }); + + describe("when invalid url was entered", () => { + it("should not be validated during input", async () => { + const { wrapper, insertUrl } = setup(); + + insertUrl(INVALID_URL); + await nextTick(); + + const alerts = wrapper.find('[role="alert"]'); + + expect(alerts.exists()).toBe(false); + }); + + describe("when enter is pressed", () => { + it("should show invalid-url-error", async () => { + const { wrapper, insertUrl, submitByEnter } = setup(); + + insertUrl(INVALID_URL); + await submitByEnter(); + + const alerts = wrapper.find('[role="alert"]').text(); + + expect(alerts).toEqual( + expect.stringContaining("util-validators-invalid-url") + ); + }); + + it("should not emit create:url event", async () => { + const { wrapper, insertUrl, submitByEnter } = setup(); + + insertUrl(INVALID_URL); + await submitByEnter(); + + const emitted = wrapper.emitted("create:url"); + expect(emitted).toBeUndefined(); + }); + }); + }); + + describe("when url field is empty", () => { + describe("when submit button is clicked", () => { + it("should show required-error-message", async () => { + const { wrapper, submitByEnter } = setup(); + + await submitByEnter(); + + const alerts = wrapper.find('[role="alert"]').text(); + expect(alerts).toEqual( + expect.stringContaining("common.validation.required2") + ); + }); + + it("should not emit create:url event", async () => { + const { wrapper, submitByClick } = setup(); + + await submitByClick(); + + const emitted = wrapper.emitted("create:url"); + expect(emitted).toBeUndefined(); + }); + }); + }); +}); diff --git a/src/components/feature-board-link-element/components/LinkContentElementCreate.vue b/src/components/feature-board-link-element/components/LinkContentElementCreate.vue new file mode 100644 index 0000000000..b647dc1904 --- /dev/null +++ b/src/components/feature-board-link-element/components/LinkContentElementCreate.vue @@ -0,0 +1,97 @@ + + + + + diff --git a/src/components/feature-board-link-element/components/LinkContentElementDisplay.unit.ts b/src/components/feature-board-link-element/components/LinkContentElementDisplay.unit.ts new file mode 100644 index 0000000000..bc9e61c01a --- /dev/null +++ b/src/components/feature-board-link-element/components/LinkContentElementDisplay.unit.ts @@ -0,0 +1,71 @@ +import LinkContentElementDisplayVue from "./LinkContentElementDisplay.vue"; +import { mount, MountOptions } from "@vue/test-utils"; +import Vue from "vue"; +import createComponentMocks from "@@/tests/test-utils/componentMocks"; +import { I18N_KEY } from "@/utils/inject"; +import { i18nMock } from "@@/tests/test-utils"; + +type Props = { + url: string; + title: string; + imageUrl?: string; + isLoading: boolean; + isEditMode: boolean; +}; + +describe("LinkContentElementDisplay", () => { + const setup = (props: Props) => { + const wrapper = mount(LinkContentElementDisplayVue as MountOptions, { + ...createComponentMocks({ i18n: true }), + provide: { + [I18N_KEY.valueOf()]: i18nMock, + }, + propsData: { ...props }, + }); + + return { wrapper }; + }; + + describe("when valid url was given", () => { + it("should sanitize urls", async () => { + const VALID_UNSANITIZED_URL = + "https://example.com"; + const { wrapper } = setup({ + url: VALID_UNSANITIZED_URL, + title: "", + isLoading: false, + isEditMode: false, + }); + + const expectedUrl = "https://example.com"; + expect(wrapper.html()).toEqual(expect.stringContaining(expectedUrl)); + }); + + it("should sanitize javascript-urls", async () => { + const INVALID_UNSANITIZED_URL = + "javascript" + ":" + "alert(document.domain)"; + const { wrapper } = setup({ + url: INVALID_UNSANITIZED_URL, + title: "", + isLoading: false, + isEditMode: false, + }); + + const expectedUrl = "about:blank"; + expect(wrapper.html()).toEqual(expect.stringContaining(expectedUrl)); + }); + + it("should display the hostname ", async () => { + const INVALID_UNSANITIZED_URL = "https://de.wikipedia.org/dachs"; + const { wrapper } = setup({ + url: INVALID_UNSANITIZED_URL, + title: "", + isLoading: false, + isEditMode: false, + }); + + const expectedUrl = "de.wikipedia.org"; + expect(wrapper.html()).toEqual(expect.stringContaining(expectedUrl)); + }); + }); +}); diff --git a/src/components/feature-board-link-element/components/LinkContentElementDisplay.vue b/src/components/feature-board-link-element/components/LinkContentElementDisplay.vue new file mode 100644 index 0000000000..19f555e922 --- /dev/null +++ b/src/components/feature-board-link-element/components/LinkContentElementDisplay.vue @@ -0,0 +1,94 @@ + + + + + diff --git a/src/components/feature-board-link-element/composables/MetaTagExtractorApi.composable.ts b/src/components/feature-board-link-element/composables/MetaTagExtractorApi.composable.ts new file mode 100644 index 0000000000..2a5c1d295a --- /dev/null +++ b/src/components/feature-board-link-element/composables/MetaTagExtractorApi.composable.ts @@ -0,0 +1,33 @@ +import { MetaTagExtractorApiFactory } from "@/serverApi/v3"; +import { $axios } from "@/utils/api"; + +type MetaTagResult = { + url: string; + title: string; + description: string; + imageUrl?: string; +}; + +export const useMetaTagExtractorApi = () => { + const metaTagApi = MetaTagExtractorApiFactory(undefined, "/v3", $axios); + + const extractMetaTags = async (url: string): Promise => { + try { + const res = await metaTagApi.metaTagExtractorControllerGetData({ + url, + }); + + return res.data; + } catch (e) { + return { + url, + title: "", + description: "", + }; + } + }; + + return { + extractMetaTags, + }; +}; diff --git a/src/components/feature-board-link-element/composables/MetaTagExtractorApi.composable.unit.ts b/src/components/feature-board-link-element/composables/MetaTagExtractorApi.composable.unit.ts new file mode 100644 index 0000000000..3232e8a2bf --- /dev/null +++ b/src/components/feature-board-link-element/composables/MetaTagExtractorApi.composable.unit.ts @@ -0,0 +1,105 @@ +import * as serverApi from "@/serverApi/v3/api"; +import { MetaTagExtractorResponse } from "@/serverApi/v3/api"; +import { createMock, DeepMocked } from "@golevelup/ts-jest"; +import { mount } from "@vue/test-utils"; +import { defineComponent } from "vue"; +import { useMetaTagExtractorApi } from "./MetaTagExtractorApi.composable"; +import { mockApiResponse } from "@@/tests/test-utils"; + +describe("useMetaTagExtractorApi", () => { + let api: DeepMocked; + + beforeEach(() => { + api = createMock(); + + jest.spyOn(serverApi, "MetaTagExtractorApiFactory").mockReturnValue(api); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + const getWrapper = () => { + let composable: ReturnType | undefined; + + const TestComponent = defineComponent({ + template: "
", + setup() { + composable = useMetaTagExtractorApi(); + }, + }); + + const wrapper = mount(TestComponent, {}); + return { wrapper, composable }; + }; + + describe("extractMetaTags", () => { + describe("when meta tags could be extracted", () => { + const setup = () => { + const mockedResponse: MetaTagExtractorResponse = { + url: "", + title: "", + description: "", + imageUrl: "", + }; + + api.metaTagExtractorControllerGetData.mockResolvedValue( + mockApiResponse({ data: mockedResponse }) + ); + + const { wrapper, composable } = getWrapper(); + + return { + wrapper, + mockedResponse, + composable, + }; + }; + + it("should be defined", () => { + const { composable } = setup(); + + expect(composable?.extractMetaTags).toBeDefined(); + }); + + it("should return the data", async () => { + const { composable, mockedResponse } = setup(); + + const url = "https://test.de/my-article"; + const data = await composable?.extractMetaTags(url); + + expect(data).toEqual(mockedResponse); + }); + }); + + describe("when meta tags extraction failed", () => { + const setup = () => { + const mockedResponse: MetaTagExtractorResponse = { + url: "", + title: "", + description: "", + imageUrl: "", + }; + + api.metaTagExtractorControllerGetData.mockRejectedValue(false); + + const { wrapper, composable } = getWrapper(); + + return { + wrapper, + mockedResponse, + composable, + }; + }; + + it("should return default values", async () => { + const { composable } = setup(); + + const url = "https://test.de/my-article"; + const data = await composable?.extractMetaTags(url); + + expect(data).toEqual({ url, title: "", description: "" }); + }); + }); + }); +}); diff --git a/src/components/feature-board-link-element/composables/PreviewGenerator.composable.ts b/src/components/feature-board-link-element/composables/PreviewGenerator.composable.ts new file mode 100644 index 0000000000..7217aa9d6d --- /dev/null +++ b/src/components/feature-board-link-element/composables/PreviewGenerator.composable.ts @@ -0,0 +1,31 @@ +import { FileRecordParentType } from "@/fileStorageApi/v3"; +import { + convertDownloadToPreviewUrl, + isPreviewPossible, +} from "@/utils/fileHelper"; +import { useFileStorageApi } from "@feature-board-file-element"; + +export const usePreviewGenerator = (elementId: string) => { + const { fileRecord, uploadFromUrl } = useFileStorageApi( + elementId, + FileRecordParentType.BOARDNODES + ); + + const createPreviewImage = async ( + externalImageUrl: string + ): Promise => { + await uploadFromUrl(externalImageUrl); + if ( + fileRecord.value?.previewStatus && + isPreviewPossible(fileRecord.value?.previewStatus) + ) { + const imageUrl = convertDownloadToPreviewUrl(fileRecord.value.url); + return imageUrl; + } + }; + + return { + createPreviewImage, + uploadFromUrl, + }; +}; diff --git a/src/components/feature-board-link-element/composables/PreviewGenerator.composable.unit.ts b/src/components/feature-board-link-element/composables/PreviewGenerator.composable.unit.ts new file mode 100644 index 0000000000..22f98a6e85 --- /dev/null +++ b/src/components/feature-board-link-element/composables/PreviewGenerator.composable.unit.ts @@ -0,0 +1,96 @@ +import { mount } from "@vue/test-utils"; +import { defineComponent } from "vue"; +import { usePreviewGenerator } from "./PreviewGenerator.composable"; +import { + fileRecordResponseFactory, + setupFileStorageApiMock, +} from "@@/tests/test-utils"; +import { PreviewStatus } from "@/fileStorageApi/v3"; + +jest.mock("@feature-board-file-element"); + +describe("usePreviewGenerator", () => { + afterEach(() => { + jest.clearAllMocks(); + }); + + const getWrapper = (elementId: string) => { + let composable: ReturnType | undefined; + + const TestComponent = defineComponent({ + template: "
", + setup() { + composable = usePreviewGenerator(elementId); + }, + }); + + const wrapper = mount(TestComponent, {}); + return { wrapper, composable }; + }; + + describe("createPreviewImage", () => { + describe("when meta tags could be extracted", () => { + const setup = () => { + const elementId = "my-custom-mocked-id"; + const uploadFromUrlMock = jest.fn(); + const { fileRecord } = setupFileStorageApiMock({ uploadFromUrlMock }); + + const { wrapper, composable } = getWrapper(elementId); + + return { + wrapper, + composable, + fileRecord, + uploadFromUrlMock, + }; + }; + + it("should be defined", () => { + const { composable } = setup(); + + expect(composable?.createPreviewImage).toBeDefined(); + }); + + it("should upload the external image", async () => { + const { composable, uploadFromUrlMock } = setup(); + + const imageUrl = "https://test.de/my-article/image.jpg"; + await composable?.createPreviewImage(imageUrl); + + expect(uploadFromUrlMock).toHaveBeenCalledWith(imageUrl); + }); + + describe("when image can be uploaded", () => { + it("should return image url for the preview image", async () => { + const { composable, fileRecord } = setup(); + + fileRecord.value = fileRecordResponseFactory.build({ + previewStatus: PreviewStatus.PREVIEW_POSSIBLE, + }); + + const externalImageUrl = "https://test.de/my-article/image.jpg"; + const previewImageUrl = + await composable?.createPreviewImage(externalImageUrl); + + expect(previewImageUrl).toEqual( + expect.stringContaining(fileRecord.value.url) + ); + }); + }); + + describe("when image can not be uploaded", () => { + it("should return nothing", async () => { + const { composable, fileRecord } = setup(); + + fileRecord.value = undefined; + + const externalImageUrl = "https://test.de/my-article/image.jpg"; + const previewImageUrl = + await composable?.createPreviewImage(externalImageUrl); + + expect(previewImageUrl).toBeUndefined(); + }); + }); + }); + }); +}); diff --git a/src/components/feature-board-link-element/index.ts b/src/components/feature-board-link-element/index.ts index a71ecaa69a..d309839e04 100644 --- a/src/components/feature-board-link-element/index.ts +++ b/src/components/feature-board-link-element/index.ts @@ -1,3 +1,3 @@ -import LinkContentElement from "./LinkContentElement.vue"; +import LinkContentElement from "./components/LinkContentElement.vue"; export { LinkContentElement }; diff --git a/src/components/feature-board-link-element/util/url.util.ts b/src/components/feature-board-link-element/util/url.util.ts new file mode 100644 index 0000000000..d487fcb2eb --- /dev/null +++ b/src/components/feature-board-link-element/util/url.util.ts @@ -0,0 +1,6 @@ +export const ensureProtocolIncluded = (url: string) => { + if (!url.includes("://")) { + url = `https://${url}`; + } + return new URL(url).toString(); +}; diff --git a/src/components/feature-board-link-element/util/url.util.unit.ts b/src/components/feature-board-link-element/util/url.util.unit.ts new file mode 100644 index 0000000000..560209686e --- /dev/null +++ b/src/components/feature-board-link-element/util/url.util.unit.ts @@ -0,0 +1,21 @@ +import { ensureProtocolIncluded } from "./url.util"; + +describe("url.util", () => { + describe("ensureProtocolIncluded", () => { + describe("when a protocol is contained", () => { + it("should not change anything", async () => { + const url = "anyprotocol://abc.de/foto.png"; + const result = ensureProtocolIncluded(url); + expect(result).toEqual(url); + }); + }); + + describe("when no protocol is contained", () => { + it("should add https", async () => { + const url = "abc.de/foto.png"; + const result = ensureProtocolIncluded(url); + expect(result.indexOf("https://")).toEqual(0); + }); + }); + }); +}); diff --git a/src/components/feature-board/card/ContentElement.vue b/src/components/feature-board/card/ContentElement.vue index 0ebf0eac88..2e6e8a613d 100644 --- a/src/components/feature-board/card/ContentElement.vue +++ b/src/components/feature-board/card/ContentElement.vue @@ -10,7 +10,7 @@ import { BOARD_CARD_IS_FIRST_ELEMENT, BOARD_CARD_IS_LAST_ELEMENT, } from "@util-board"; -import { defineComponent, provide } from "vue"; +import { computed, defineComponent, provide } from "vue"; export default defineComponent({ name: "BoardMenuActionEdit", @@ -19,12 +19,16 @@ export default defineComponent({ index: { type: Number, required: false }, }, setup(props) { - const hasMultipleElements = props.elementCount > 0; - const isFirstElement = hasMultipleElements && props.index === 0; - const lastIndex = props.elementCount - 1; - const isLastElement = hasMultipleElements && props.index === lastIndex; + const hasManyElements = computed(() => props.elementCount > 0); + const isFirstElement = computed( + () => hasManyElements.value && props.index === 0 + ); + const lastIndex = computed(() => props.elementCount - 1); + const isLastElement = computed( + () => hasManyElements.value && props.index === lastIndex.value + ); - provide(BOARD_CARD_HAS_MULTIPLE_ELEMENTS, hasMultipleElements); + provide(BOARD_CARD_HAS_MULTIPLE_ELEMENTS, hasManyElements); provide(BOARD_CARD_IS_FIRST_ELEMENT, isFirstElement); provide(BOARD_CARD_IS_LAST_ELEMENT, isLastElement); diff --git a/src/components/ui-board/BoardMenuActionMoveDown.vue b/src/components/ui-board/BoardMenuActionMoveDown.vue index f11aedec9b..6d22ee9879 100644 --- a/src/components/ui-board/BoardMenuActionMoveDown.vue +++ b/src/components/ui-board/BoardMenuActionMoveDown.vue @@ -16,7 +16,7 @@ import { BOARD_CARD_HAS_MULTIPLE_ELEMENTS, BOARD_CARD_IS_LAST_ELEMENT, } from "@util-board"; -import { computed, defineComponent } from "vue"; +import { computed, defineComponent, ref } from "vue"; export default defineComponent({ name: "BoardMenuActionMoveDown", @@ -25,9 +25,13 @@ export default defineComponent({ }, emits: ["click"], setup(props, { emit }) { - const hasMultipleElements = injectStrict(BOARD_CARD_HAS_MULTIPLE_ELEMENTS); - const isLastElement = injectStrict(BOARD_CARD_IS_LAST_ELEMENT); - const isVisible = computed(() => hasMultipleElements && !isLastElement); + const hasMultipleElements = ref( + injectStrict(BOARD_CARD_HAS_MULTIPLE_ELEMENTS) + ); + const isLastElement = ref(injectStrict(BOARD_CARD_IS_LAST_ELEMENT)); + const isVisible = computed( + () => hasMultipleElements.value && !isLastElement.value + ); const onClick = ($event: Event) => emit("click", $event); diff --git a/src/components/ui-board/BoardMenuActionMoveUp.vue b/src/components/ui-board/BoardMenuActionMoveUp.vue index c608519c89..d626ca69ec 100644 --- a/src/components/ui-board/BoardMenuActionMoveUp.vue +++ b/src/components/ui-board/BoardMenuActionMoveUp.vue @@ -12,7 +12,7 @@ import { BOARD_CARD_HAS_MULTIPLE_ELEMENTS, BOARD_CARD_IS_FIRST_ELEMENT, } from "@util-board"; -import { computed, defineComponent } from "vue"; +import { computed, defineComponent, ref } from "vue"; export default defineComponent({ name: "BoardMenuActionMoveUp", @@ -21,9 +21,13 @@ export default defineComponent({ }, emits: ["click"], setup(_, { emit }) { - const hasMultipleElements = injectStrict(BOARD_CARD_HAS_MULTIPLE_ELEMENTS); - const isFirstElement = injectStrict(BOARD_CARD_IS_FIRST_ELEMENT); - const isVisible = computed(() => hasMultipleElements && !isFirstElement); + const hasMultipleElements = ref( + injectStrict(BOARD_CARD_HAS_MULTIPLE_ELEMENTS) + ); + const isFirstElement = ref(injectStrict(BOARD_CARD_IS_FIRST_ELEMENT)); + const isVisible = computed( + () => hasMultipleElements.value && !isFirstElement.value + ); const onClick = ($event: Event) => emit("click", $event); diff --git a/src/components/ui-board/content-element/ContentElementTitleIcon.vue b/src/components/ui-board/content-element/ContentElementTitleIcon.vue index 3e0a9890db..c2dab8ca88 100644 --- a/src/components/ui-board/content-element/ContentElementTitleIcon.vue +++ b/src/components/ui-board/content-element/ContentElementTitleIcon.vue @@ -1,7 +1,9 @@