From c565cc4d00c223b803f16d7c1fba4c360fc2ee6b Mon Sep 17 00:00:00 2001 From: hoeppner-dataport Date: Fri, 20 Oct 2023 11:10:51 +0200 Subject: [PATCH 01/40] initial implementation of client side handling --- .../composables/FileStorageApi.composable.ts | 26 +++ .../LinkContentElement.vue | 54 ++++- src/serverApi/v3/api.ts | 195 +++++++++++++++++- .../api-mocks/fileStorageApiMock.ts | 5 +- 4 files changed, 272 insertions(+), 8 deletions(-) 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..d0f4167469 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 imageUrlObject = new URL(imageUrl); + const fileName = imageUrlObject.pathname.replace(/^.*?([^/]+)\/?$/, "$1"); + const schoolId = authModule.getUser?.schoolId as string; + const fileUrlParams: FileUrlParams = { + url: imageUrl, + fileName, + headers: `User-Agent: Embed Request User Agent Schulcloud Verbund Software`, + }; + 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-link-element/LinkContentElement.vue b/src/components/feature-board-link-element/LinkContentElement.vue index 19ef62652b..0e5835750f 100644 --- a/src/components/feature-board-link-element/LinkContentElement.vue +++ b/src/components/feature-board-link-element/LinkContentElement.vue @@ -15,10 +15,20 @@ @@ -87,5 +106,10 @@ a { position: absolute; right: 4px; top: 4px; + z-index: 100; +} +.hidden { + transition: opacity 200ms; + opacity: 0; } From 938bed6447cb1cacf9ddcec6676f315dcfc0bf49 Mon Sep 17 00:00:00 2001 From: hoeppner-dataport Date: Fri, 20 Oct 2023 16:20:32 +0200 Subject: [PATCH 05/40] fix: sonar cloud regex security concern --- .../shared/composables/FileStorageApi.composable.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) 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 d0f4167469..28306f01fe 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 @@ -10,6 +10,7 @@ import { authModule } from "@/store/store-accessor"; import { $axios, mapAxiosErrorToResponseError } from "@/utils/api"; import { ref } from "vue"; import { useFileStorageNotifier } from "./FileStorageNotifications.composable"; +import { basename } from "path"; export enum ErrorType { FILE_IS_BLOCKED = "FILE_IS_BLOCKED", @@ -69,7 +70,7 @@ export const useFileStorageApi = ( const uploadFromUrl = async (imageUrl: string): Promise => { try { const imageUrlObject = new URL(imageUrl); - const fileName = imageUrlObject.pathname.replace(/^.*?([^/]+)\/?$/, "$1"); + const fileName = basename(imageUrlObject.pathname) ?? "no-file-name"; const schoolId = authModule.getUser?.schoolId as string; const fileUrlParams: FileUrlParams = { url: imageUrl, From e6cd5121385cc2bff5bd569bf821dec366495678 Mon Sep 17 00:00:00 2001 From: hoeppner-dataport Date: Fri, 20 Oct 2023 16:27:58 +0200 Subject: [PATCH 06/40] fix: problem with basename being not available in js --- .../shared/composables/FileStorageApi.composable.ts | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) 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 28306f01fe..bb1ad746b8 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 @@ -10,7 +10,6 @@ import { authModule } from "@/store/store-accessor"; import { $axios, mapAxiosErrorToResponseError } from "@/utils/api"; import { ref } from "vue"; import { useFileStorageNotifier } from "./FileStorageNotifications.composable"; -import { basename } from "path"; export enum ErrorType { FILE_IS_BLOCKED = "FILE_IS_BLOCKED", @@ -69,8 +68,8 @@ export const useFileStorageApi = ( const uploadFromUrl = async (imageUrl: string): Promise => { try { - const imageUrlObject = new URL(imageUrl); - const fileName = basename(imageUrlObject.pathname) ?? "no-file-name"; + const { pathname } = new URL(imageUrl); + const fileName = pathname.substring(pathname.lastIndexOf("/") + 1); const schoolId = authModule.getUser?.schoolId as string; const fileUrlParams: FileUrlParams = { url: imageUrl, From d91ded87cfa4457f267430946469e23aea9e3f07 Mon Sep 17 00:00:00 2001 From: hoeppner-dataport Date: Fri, 20 Oct 2023 16:55:18 +0200 Subject: [PATCH 07/40] chore: increase font coverage --- .../composables/FileStorageApi.composable.ts | 2 +- .../FileStorageApi.composable.unit.ts | 98 +++++++++++++++++++ 2 files changed, 99 insertions(+), 1 deletion(-) 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 bb1ad746b8..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 @@ -74,7 +74,7 @@ export const useFileStorageApi = ( const fileUrlParams: FileUrlParams = { url: imageUrl, fileName, - headers: `User-Agent: Embed Request User Agent Schulcloud Verbund Software`, + headers: `User-Agent: Embed Request User Agent`, }; const response = await fileApi.uploadFromUrl( schoolId, 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 = () => { From 45e76afbf85e7a8c198790a8580cd583d388db25 Mon Sep 17 00:00:00 2001 From: hoeppner-dataport Date: Mon, 23 Oct 2023 09:53:37 +0200 Subject: [PATCH 08/40] chore: fix focus handler --- .../LinkContentElement.vue | 9 +++++++-- .../LinkContentElementDisplay.vue | 16 ++++++---------- 2 files changed, 13 insertions(+), 12 deletions(-) diff --git a/src/components/feature-board-link-element/LinkContentElement.vue b/src/components/feature-board-link-element/LinkContentElement.vue index b2a2d14d66..29002b6a94 100644 --- a/src/components/feature-board-link-element/LinkContentElement.vue +++ b/src/components/feature-board-link-element/LinkContentElement.vue @@ -35,8 +35,8 @@ import { LinkElementResponse, MetaTagExtractorApiFactory, } from "@/serverApi/v3"; -import { useContentElementState } from "@data-board"; -import { defineComponent, toRef } from "vue"; +import { useBoardFocusHandler, useContentElementState } from "@data-board"; +import { defineComponent, ref, toRef } from "vue"; import { PropType } from "vue/types/umd"; import { useFileStorageApi } from "@feature-board-file-element"; import { FileRecordParentType } from "@/fileStorageApi/v3"; @@ -78,8 +78,12 @@ export default defineComponent({ "move-up:edit", ], setup(props, { emit }) { + const linkContentElement = ref(null); const element = toRef(props, "element"); const metaTagApi = MetaTagExtractorApiFactory(undefined, "/v3", $axios); + + useBoardFocusHandler(element.value.id, linkContentElement); + const { fetchFile, fileRecord, uploadFromUrl } = useFileStorageApi( element.value.id, FileRecordParentType.BOARDNODES @@ -140,6 +144,7 @@ export default defineComponent({ return { computedElement, isLoading, + linkContentElement, modelValue, onCreateUrl, onKeydownArrow, diff --git a/src/components/feature-board-link-element/LinkContentElementDisplay.vue b/src/components/feature-board-link-element/LinkContentElementDisplay.vue index fbcca9da27..0c102b26e2 100644 --- a/src/components/feature-board-link-element/LinkContentElementDisplay.vue +++ b/src/components/feature-board-link-element/LinkContentElementDisplay.vue @@ -10,7 +10,7 @@ :loading="isLoading ? 'primary' : false" :hover="isHovered" > -
- {{ urlWithoutProtocol }} + {{ hostname }}
@@ -67,29 +67,25 @@ export default defineComponent({ }, setup(props) { const sanitizedUrl = computed(() => sanitizeUrl(props.url)); - const urlWithoutProtocol: ComputedRef = computed(() => { + const hostname: ComputedRef = computed(() => { try { const urlObject = new URL(props.url); return urlObject.hostname; } catch (e) { - console.error(`valid url expected, but got this: ${props.url}`); + return ""; } - return props.url; }); const linkContentElementDisplay = ref(null); const isHovered = useElementHover(linkContentElementDisplay); const boardMenuClasses = computed(() => { - if (props.isEditMode && isHovered.value === true) { - return ""; - } - return "hidden"; + return isHovered.value === false ? "hidden" : ""; }); return { mdiLink, sanitizedUrl, - urlWithoutProtocol, + hostname, linkContentElementDisplay, boardMenuClasses, isHovered, From 5be2cfff747c994761a23e45c3dd16b700dfc178 Mon Sep 17 00:00:00 2001 From: hoeppner-dataport Date: Mon, 23 Oct 2023 10:02:56 +0200 Subject: [PATCH 09/40] refactor isLoading to be connected to imageUpload --- .../LinkContentElement.vue | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/src/components/feature-board-link-element/LinkContentElement.vue b/src/components/feature-board-link-element/LinkContentElement.vue index 29002b6a94..85d07afc37 100644 --- a/src/components/feature-board-link-element/LinkContentElement.vue +++ b/src/components/feature-board-link-element/LinkContentElement.vue @@ -79,6 +79,7 @@ export default defineComponent({ ], setup(props, { emit }) { const linkContentElement = ref(null); + const isLoading = ref(false); const element = toRef(props, "element"); const metaTagApi = MetaTagExtractorApiFactory(undefined, "/v3", $axios); @@ -89,12 +90,12 @@ export default defineComponent({ FileRecordParentType.BOARDNODES ); - const { modelValue, computedElement, isLoading } = useContentElementState( - props, - { autoSaveDebounce: 100 } - ); + const { modelValue, computedElement } = useContentElementState(props, { + autoSaveDebounce: 100, + }); // can be removed after refactoring of upload+virusScan-workflow + // WIP: move into composable const updateFileRecord = (increase = 100, base = 1000, retries = 0) => { setTimeout(() => { fetchFile() @@ -106,17 +107,19 @@ export default defineComponent({ modelValue.value.imageUrl = convertDownloadToPreviewUrl( fileRecord.value.url ); + isLoading.value = false; } else if (retries < 10) { updateFileRecord(base + increase, base, retries++); } }) .catch(() => { - console.log("unabled to load fileRecord"); + isLoading.value = false; }); }, base + increase); }; const onCreateUrl = async (url: string) => { + isLoading.value = true; const res = await metaTagApi.metaTagExtractorControllerGetData({ url }); const { title, description, imageUrl } = res.data; modelValue.value.url = url; From eff074cbbc792a64ac6e0a1659239092c5293052 Mon Sep 17 00:00:00 2001 From: hoeppner-dataport Date: Mon, 23 Oct 2023 11:47:17 +0200 Subject: [PATCH 10/40] chore: refactored filechecks to be composable --- .../{ => components}/LinkContentElement.vue | 32 ++------- .../LinkContentElementCreate.vue | 0 .../LinkContentElementDisplay.vue | 0 .../trackImageUploadStatus.composable.ts | 66 +++++++++++++++++++ .../feature-board-link-element/index.ts | 2 +- 5 files changed, 74 insertions(+), 26 deletions(-) rename src/components/feature-board-link-element/{ => components}/LinkContentElement.vue (81%) rename src/components/feature-board-link-element/{ => components}/LinkContentElementCreate.vue (100%) rename src/components/feature-board-link-element/{ => components}/LinkContentElementDisplay.vue (100%) create mode 100644 src/components/feature-board-link-element/composables/trackImageUploadStatus.composable.ts diff --git a/src/components/feature-board-link-element/LinkContentElement.vue b/src/components/feature-board-link-element/components/LinkContentElement.vue similarity index 81% rename from src/components/feature-board-link-element/LinkContentElement.vue rename to src/components/feature-board-link-element/components/LinkContentElement.vue index 85d07afc37..eb43d5c30b 100644 --- a/src/components/feature-board-link-element/LinkContentElement.vue +++ b/src/components/feature-board-link-element/components/LinkContentElement.vue @@ -53,6 +53,7 @@ import { BoardMenuActionMoveDown, BoardMenuActionMoveUp, } from "@ui-board"; +import { useTrackImageUploadStatus } from "../composables/trackImageUploadStatus.composable"; export default defineComponent({ name: "LinkElementContent", @@ -85,7 +86,7 @@ export default defineComponent({ useBoardFocusHandler(element.value.id, linkContentElement); - const { fetchFile, fileRecord, uploadFromUrl } = useFileStorageApi( + const { uploadFromUrl } = useFileStorageApi( element.value.id, FileRecordParentType.BOARDNODES ); @@ -94,29 +95,9 @@ export default defineComponent({ autoSaveDebounce: 100, }); - // can be removed after refactoring of upload+virusScan-workflow - // WIP: move into composable - const updateFileRecord = (increase = 100, base = 1000, retries = 0) => { - setTimeout(() => { - fetchFile() - .then(() => { - if ( - fileRecord?.value?.previewStatus && - isPreviewPossible(fileRecord.value?.previewStatus) - ) { - modelValue.value.imageUrl = convertDownloadToPreviewUrl( - fileRecord.value.url - ); - isLoading.value = false; - } else if (retries < 10) { - updateFileRecord(base + increase, base, retries++); - } - }) - .catch(() => { - isLoading.value = false; - }); - }, base + increase); - }; + const { trackImageUploadStatus } = useTrackImageUploadStatus( + element.value.id + ); const onCreateUrl = async (url: string) => { isLoading.value = true; @@ -127,8 +108,9 @@ export default defineComponent({ modelValue.value.description = description; if (imageUrl) { await uploadFromUrl(imageUrl); - updateFileRecord(); + modelValue.value.imageUrl = await trackImageUploadStatus(); } + isLoading.value = false; }; const onKeydownArrow = (event: KeyboardEvent) => { diff --git a/src/components/feature-board-link-element/LinkContentElementCreate.vue b/src/components/feature-board-link-element/components/LinkContentElementCreate.vue similarity index 100% rename from src/components/feature-board-link-element/LinkContentElementCreate.vue rename to src/components/feature-board-link-element/components/LinkContentElementCreate.vue diff --git a/src/components/feature-board-link-element/LinkContentElementDisplay.vue b/src/components/feature-board-link-element/components/LinkContentElementDisplay.vue similarity index 100% rename from src/components/feature-board-link-element/LinkContentElementDisplay.vue rename to src/components/feature-board-link-element/components/LinkContentElementDisplay.vue diff --git a/src/components/feature-board-link-element/composables/trackImageUploadStatus.composable.ts b/src/components/feature-board-link-element/composables/trackImageUploadStatus.composable.ts new file mode 100644 index 0000000000..112c3cc317 --- /dev/null +++ b/src/components/feature-board-link-element/composables/trackImageUploadStatus.composable.ts @@ -0,0 +1,66 @@ +import { FileRecordParentType } from "@/fileStorageApi/v3"; +import { + convertDownloadToPreviewUrl, + isPreviewPossible, +} from "@/utils/fileHelper"; +import { useFileStorageApi } from "@feature-board-file-element"; +import { ref } from "vue"; + +type Options = { + baseDuration: number; + increaseDuration: number; + maxRetries: number; +}; + +export const useTrackImageUploadStatus = ( + elementId: string, + options: Options = { + baseDuration: 1000, + increaseDuration: 100, + maxRetries: 10, + } +) => { + const retries = ref(0); + + const { fetchFile, fileRecord } = useFileStorageApi( + elementId, + FileRecordParentType.BOARDNODES + ); + + const sleep = () => { + return new Promise((resolve) => + setTimeout( + resolve, + options.baseDuration + retries.value * options.increaseDuration + ) + ); + }; + + const isImagePreviewable = async () => { + while (retries.value < options.maxRetries) { + await sleep(); + await fetchFile(); + + if ( + fileRecord?.value && + isPreviewPossible(fileRecord.value?.previewStatus) + ) { + return true; + } + retries.value++; + } + return false; + }; + + const trackImageUploadStatus = async (): Promise => { + const hasPreview = await isImagePreviewable(); + if (hasPreview && fileRecord.value) { + const imageUrl = convertDownloadToPreviewUrl(fileRecord.value.url); + return imageUrl; + } + }; + + return { + trackImageUploadStatus, + }; +}; 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 }; From bcc6facaa82d4df8c0f60be0334aeac67087f057 Mon Sep 17 00:00:00 2001 From: hoeppner-dataport Date: Mon, 23 Oct 2023 16:42:35 +0200 Subject: [PATCH 11/40] chore: fix language strings --- src/locales/de.json | 2 +- src/locales/en.json | 1 + src/locales/es.json | 1 + src/locales/uk.json | 3 ++- 4 files changed, 5 insertions(+), 2 deletions(-) diff --git a/src/locales/de.json b/src/locales/de.json index 6d9d3b5ced..ade8eb1289 100644 --- a/src/locales/de.json +++ b/src/locales/de.json @@ -1032,7 +1032,7 @@ "feature-board-file-element.placeholder.uploadFile": "Datei hochladen", "feature-board-external-tool-element.placeholder.selectTool": "Tool auswählen...", "feature-board-external-tool-element.dialog.title": "Auswahl & Einstellungen", - "util-validators-invalid-url": "Bitte geben Sie eine gültige URL ein.", + "util-validators-invalid-url": "Dies ist keine gültige URL.", "page-class-members.title.info": "importiert aus einem externen System", "page-class-members.systemInfoText": "Daten der Klasse werden mit {systemName} synchronisiert. Die Klassenliste kann vorübergehend veraltet sein, bis sie mit dem neusten Stand in {systemName} abgeglichen wird. Die Daten werden nach jeder Anmeldung eines Klassenmitglieds in der Niedersächsischen Bildungscloud aktualisiert.", "page-class-members.classMembersInfoBox.title": "Schüler:innen sind noch nicht in der Niedersächsischen Bildungscloud?", diff --git a/src/locales/en.json b/src/locales/en.json index 50ac2a7d9b..3e43a82d1c 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -1033,6 +1033,7 @@ "feature-board-file-element.placeholder.uploadFile": "Upload file", "feature-board-external-tool-element.placeholder.selectTool": "Select Tool...", "feature-board-external-tool-element.dialog.title": "Selection & Settings", + "util-validators-invalid-url": "This is not a valid URL.", "page-class-members.title.info": "imported from an external system", "page-class-members.systemInfoText": "Class data is synchronized with {systemName}. The class list may be temporarily out of date until it is updated with the latest version in {systemName}. The data is updated every time a class member registers in the Niedersächsischen Bildungscloud.", "page-class-members.classMembersInfoBox.title": "Students are not yet in the Niedersächsischen Bildungscloud?", diff --git a/src/locales/es.json b/src/locales/es.json index 457b3a0a72..b53fac2973 100644 --- a/src/locales/es.json +++ b/src/locales/es.json @@ -1029,6 +1029,7 @@ "feature-board-file-element.placeholder.uploadFile": "Cargar archivo", "feature-board-external-tool-element.placeholder.selectTool": "Herramienta de selección...", "feature-board-external-tool-element.dialog.title": "Selección y configuración", + "util-validators-invalid-url": "Esta URL no es válida.", "page-class-members.title.info": "importado desde un sistema externo", "page-class-members.systemInfoText": "Los datos de la clase se sincronizan con {systemName}. La lista de clases puede estar temporalmente desactualizada hasta que se actualice con la última versión en {systemName}. Los datos se actualizan cada vez que un miembro del grupo se registra en Niedersächsischen Bildungscloud.", "page-class-members.classMembersInfoBox.title": "¿Los estudiantes aún no están en la Niedersächsischen Bildungscloud?", diff --git a/src/locales/uk.json b/src/locales/uk.json index 7d3716ccac..776f35b310 100644 --- a/src/locales/uk.json +++ b/src/locales/uk.json @@ -187,7 +187,7 @@ "components.base.showPassword": "Показати пароль", "components.elementTypeSelection.dialog.title": "Додати елемент", "components.elementTypeSelection.elements.fileElement.subtitle": "Файл", - "components.elementTypeSelection.elements.linkElement.subtitle": "посилання", + "components.elementTypeSelection.elements.linkElement.subtitle": "Посилання", "components.elementTypeSelection.elements.submissionElement.subtitle": "Подання", "components.elementTypeSelection.elements.textElement.subtitle": "Текст", "components.elementTypeSelection.elements.externalToolElement.subtitle": "Більше інструментів", @@ -1059,6 +1059,7 @@ "feature-board-file-element.placeholder.uploadFile": "Cargar archivo", "feature-board-external-tool-element.placeholder.selectTool": "Виберіть інструмент...", "feature-board-external-tool-element.dialog.title": "Вибір і налаштування", + "util-validators-invalid-url": "Esta URL no es válida.", "page-class-members.title.info": "імпортовані із зовнішньої системи", "page-class-members.systemInfoText": "Дані класу синхронізуються з {systemName}. Список класів може бути тимчасово застарілим, поки його не буде оновлено останньою версією в {systemName}. Дані оновлюються кожного разу, коли учасник класу реєструється в Niedersächsischen Bildungscloud.", "page-class-members.classMembersInfoBox.title": "Студенти ще не в Niedersächsischen Bildungscloud?", From 9f7dd3623fbe624e7af8e32d418da8146fd3f316 Mon Sep 17 00:00:00 2001 From: hoeppner-dataport Date: Mon, 23 Oct 2023 16:45:30 +0200 Subject: [PATCH 12/40] validate on submit --- .../components/LinkContentElementCreate.vue | 37 +++++++++++++++---- 1 file changed, 30 insertions(+), 7 deletions(-) diff --git a/src/components/feature-board-link-element/components/LinkContentElementCreate.vue b/src/components/feature-board-link-element/components/LinkContentElementCreate.vue index 4ef76606d9..3e14410353 100644 --- a/src/components/feature-board-link-element/components/LinkContentElementCreate.vue +++ b/src/components/feature-board-link-element/components/LinkContentElementCreate.vue @@ -1,14 +1,19 @@ @@ -42,6 +50,7 @@ import { } from "@ui-board"; import { useMetaTagExtractorApi } from "../composables/MetaTagExtractorApi.composable"; import { ensureProtocolIncluded } from "../util/url.util"; +import { useImageUrlAccessor } from "../composables/ImageUrlAccessor.composable"; export default defineComponent({ name: "LinkElementContent", @@ -77,24 +86,32 @@ export default defineComponent({ autoSaveDebounce: 100, }); - const { getData } = useMetaTagExtractorApi(element.value.id); + const { getData } = useMetaTagExtractorApi(); + + const { getPreviewImageUrl, uploadFromUrl } = useImageUrlAccessor( + element.value.id + ); const onCreateUrl = async (originalUrl: string) => { isLoading.value = true; - // WIP: handle invalid URL exception - const validUrl = ensureProtocolIncluded(originalUrl); - modelValue.value.url = validUrl; try { + const validUrl = ensureProtocolIncluded(originalUrl); + modelValue.value.url = validUrl; + const { title, description, imageUrl } = await getData(validUrl); modelValue.value.title = title; modelValue.value.description = description; - modelValue.value.imageUrl = imageUrl; + + if (imageUrl) { + await uploadFromUrl(imageUrl); + modelValue.value.imageUrl = await getPreviewImageUrl(); + } } catch (error) { modelValue.value.url = ""; + } finally { + isLoading.value = false; } - - isLoading.value = false; }; const onKeydownArrow = (event: KeyboardEvent) => { @@ -124,4 +141,3 @@ export default defineComponent({ }, }); -../composables/imageUrlAccessor.composable diff --git a/src/components/feature-board-link-element/components/LinkContentElementCreate.vue b/src/components/feature-board-link-element/components/LinkContentElementCreate.vue index 3e14410353..08dd335244 100644 --- a/src/components/feature-board-link-element/components/LinkContentElementCreate.vue +++ b/src/components/feature-board-link-element/components/LinkContentElementCreate.vue @@ -6,22 +6,30 @@ :lazy-validation="true" validate-on="submit" > - - - +
+ + + + +
+ +
+
@@ -41,7 +49,7 @@ type VuetifyFormApi = { }; export default defineComponent({ - name: "LinkContentElementEdit", + name: "LinkContentElementCreate", components: {}, emits: ["create:url"], setup(props, { emit }) { @@ -79,8 +87,14 @@ export default defineComponent({ const onKeydown = () => (isValidationActive.value = false); + const onKeydownEnter = (event: KeyboardEvent) => { + event.stopPropagation(); + onSubmit(url.value); + }; + return { onKeydown, + onKeydownEnter, onSubmit, onKeydownSubmit, form, diff --git a/src/components/feature-board-link-element/components/LinkContentElementDisplay.vue b/src/components/feature-board-link-element/components/LinkContentElementDisplay.vue index e14daa10bf..516efd241c 100644 --- a/src/components/feature-board-link-element/components/LinkContentElementDisplay.vue +++ b/src/components/feature-board-link-element/components/LinkContentElementDisplay.vue @@ -96,8 +96,4 @@ a { top: 4px; z-index: 100; } -.hidden { - transition: opacity 200ms; - opacity: 0; -} diff --git a/src/components/feature-board-link-element/composables/imageUrlAccessor.composable.ts b/src/components/feature-board-link-element/composables/ImageUrlAccessor.composable.ts similarity index 93% rename from src/components/feature-board-link-element/composables/imageUrlAccessor.composable.ts rename to src/components/feature-board-link-element/composables/ImageUrlAccessor.composable.ts index 1bd3703017..85d3037a99 100644 --- a/src/components/feature-board-link-element/composables/imageUrlAccessor.composable.ts +++ b/src/components/feature-board-link-element/composables/ImageUrlAccessor.composable.ts @@ -22,7 +22,7 @@ export const useImageUrlAccessor = ( ) => { const retries = ref(0); - const { fetchFile, fileRecord } = useFileStorageApi( + const { fetchFile, fileRecord, uploadFromUrl } = useFileStorageApi( elementId, FileRecordParentType.BOARDNODES ); @@ -62,5 +62,6 @@ export const useImageUrlAccessor = ( return { getPreviewImageUrl, + uploadFromUrl, }; }; diff --git a/src/components/feature-board-link-element/composables/MetaTagExtractorApi.composable.ts b/src/components/feature-board-link-element/composables/MetaTagExtractorApi.composable.ts index a41dcce36f..9fc7304271 100644 --- a/src/components/feature-board-link-element/composables/MetaTagExtractorApi.composable.ts +++ b/src/components/feature-board-link-element/composables/MetaTagExtractorApi.composable.ts @@ -1,8 +1,5 @@ -import { useImageUrlAccessor } from "./imageUrlAccessor.composable"; -import { FileRecordParentType } from "@/fileStorageApi/v3"; import { MetaTagExtractorApiFactory } from "@/serverApi/v3"; import { $axios } from "@/utils/api"; -import { useFileStorageApi } from "@feature-board-file-element"; type Result = { url: string; @@ -11,35 +8,23 @@ type Result = { imageUrl?: string; }; -export const useMetaTagExtractorApi = (elementId: string) => { +export const useMetaTagExtractorApi = () => { const metaTagApi = MetaTagExtractorApiFactory(undefined, "/v3", $axios); - const { uploadFromUrl } = useFileStorageApi( - elementId, - FileRecordParentType.BOARDNODES - ); - - const { getPreviewImageUrl } = useImageUrlAccessor(elementId); - const getData = async (url: string): Promise => { - // WIP: handle server not reachable exception - const res = await metaTagApi.metaTagExtractorControllerGetData({ - url, - }); - const { title, description, imageUrl } = res.data; - - const result: Result = { - url, - title, - description, - }; - - if (imageUrl) { - await uploadFromUrl(imageUrl); - result.imageUrl = await getPreviewImageUrl(); + try { + const res = await metaTagApi.metaTagExtractorControllerGetData({ + url, + }); + + return res.data; + } catch (e) { + return { + url, + title: "", + description: "", + }; } - - return result; }; return { From de70b1b49ca567ef1fd161a0b2685d2e364020a6 Mon Sep 17 00:00:00 2001 From: hoeppner-dataport Date: Tue, 24 Oct 2023 12:16:16 +0200 Subject: [PATCH 16/40] fix: move down when several elements are new (see BC-5632) --- .../feature-board/card/ContentElement.vue | 16 ++++++++++------ .../ui-board/BoardMenuActionMoveDown.vue | 12 ++++++++---- .../ui-board/BoardMenuActionMoveUp.vue | 12 ++++++++---- .../util-board/board-injection-tokens.ts | 11 +++++------ 4 files changed, 31 insertions(+), 20 deletions(-) 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/util-board/board-injection-tokens.ts b/src/components/util-board/board-injection-tokens.ts index 29901878a8..6867466409 100644 --- a/src/components/util-board/board-injection-tokens.ts +++ b/src/components/util-board/board-injection-tokens.ts @@ -1,13 +1,12 @@ -import { InjectionKey } from "vue"; +import { InjectionKey, Ref } from "vue"; -export const BOARD_CARD_HAS_MULTIPLE_ELEMENTS: InjectionKey = Symbol( - "BoardCardHasMultipleElements" -); +export const BOARD_CARD_HAS_MULTIPLE_ELEMENTS: InjectionKey> = + Symbol("BoardCardHasMultipleElements"); -export const BOARD_CARD_IS_FIRST_ELEMENT: InjectionKey = Symbol( +export const BOARD_CARD_IS_FIRST_ELEMENT: InjectionKey> = Symbol( "BoardCardIsFirstElement" ); -export const BOARD_CARD_IS_LAST_ELEMENT: InjectionKey = Symbol( +export const BOARD_CARD_IS_LAST_ELEMENT: InjectionKey> = Symbol( "BoardCardIsLastElement" ); From 8aba636e9635c309d25804f747030a62fcd4c417 Mon Sep 17 00:00:00 2001 From: hoeppner-dataport Date: Tue, 24 Oct 2023 13:18:44 +0200 Subject: [PATCH 17/40] chore: fix naming --- .../composables/MetaTagExtractorApi.composable.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/components/feature-board-link-element/composables/MetaTagExtractorApi.composable.ts b/src/components/feature-board-link-element/composables/MetaTagExtractorApi.composable.ts index 9fc7304271..70aa2c535c 100644 --- a/src/components/feature-board-link-element/composables/MetaTagExtractorApi.composable.ts +++ b/src/components/feature-board-link-element/composables/MetaTagExtractorApi.composable.ts @@ -1,7 +1,7 @@ import { MetaTagExtractorApiFactory } from "@/serverApi/v3"; import { $axios } from "@/utils/api"; -type Result = { +type MetaTagResult = { url: string; title: string; description: string; @@ -11,7 +11,7 @@ type Result = { export const useMetaTagExtractorApi = () => { const metaTagApi = MetaTagExtractorApiFactory(undefined, "/v3", $axios); - const getData = async (url: string): Promise => { + const getData = async (url: string): Promise => { try { const res = await metaTagApi.metaTagExtractorControllerGetData({ url, From 3afe947ff1b3560353e5b3afc577bcea94a83e83 Mon Sep 17 00:00:00 2001 From: hoeppner-dataport Date: Tue, 24 Oct 2023 17:39:20 +0200 Subject: [PATCH 18/40] use unified header component --- .../components/LinkContentElementDisplay.vue | 32 ++++++++----------- .../ContentElementTitleIcon.vue | 8 +++-- 2 files changed, 18 insertions(+), 22 deletions(-) diff --git a/src/components/feature-board-link-element/components/LinkContentElementDisplay.vue b/src/components/feature-board-link-element/components/LinkContentElementDisplay.vue index 516efd241c..bc6348f13a 100644 --- a/src/components/feature-board-link-element/components/LinkContentElementDisplay.vue +++ b/src/components/feature-board-link-element/components/LinkContentElementDisplay.vue @@ -9,29 +9,22 @@ tabindex="0" :loading="isLoading ? 'primary' : false" > - - -
- - {{ mdiLink }} - -
+ + + + + @@ -40,10 +33,11 @@ import { ComputedRef, computed, defineComponent, ref } from "vue"; import { mdiLink } from "@mdi/js"; import { sanitizeUrl } from "@braintree/sanitize-url"; +import { ContentElementBar, ContentElementTitle } from "@ui-board"; export default defineComponent({ name: "LinkContentElementDisplay", - components: {}, + components: { ContentElementBar, ContentElementTitle }, props: { url: { type: String, 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 @@ + + From c8dc1f4c802aaa23f3c475e4dbcdad2f658de9b2 Mon Sep 17 00:00:00 2001 From: hoeppner-dataport Date: Tue, 24 Oct 2023 18:29:23 +0200 Subject: [PATCH 22/40] fix: put menu back at the right place --- .../components/LinkContentElementDisplay.vue | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/src/components/feature-board-link-element/components/LinkContentElementDisplay.vue b/src/components/feature-board-link-element/components/LinkContentElementDisplay.vue index bc6348f13a..19f555e922 100644 --- a/src/components/feature-board-link-element/components/LinkContentElementDisplay.vue +++ b/src/components/feature-board-link-element/components/LinkContentElementDisplay.vue @@ -9,6 +9,10 @@ tabindex="0" :loading="isLoading ? 'primary' : false" > + + @@ -18,9 +22,6 @@ {{ title }} - From c3c3121b8088ba7c94756d530901dc5337bfa7cf Mon Sep 17 00:00:00 2001 From: hoeppner-dataport Date: Tue, 24 Oct 2023 18:38:12 +0200 Subject: [PATCH 23/40] fix: pixel magic for menu positioning --- .../components/LinkContentElementCreate.vue | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/src/components/feature-board-link-element/components/LinkContentElementCreate.vue b/src/components/feature-board-link-element/components/LinkContentElementCreate.vue index 87d43d17b2..0ffd3213c8 100644 --- a/src/components/feature-board-link-element/components/LinkContentElementCreate.vue +++ b/src/components/feature-board-link-element/components/LinkContentElementCreate.vue @@ -27,7 +27,7 @@ -
+
@@ -107,9 +107,12 @@ export default defineComponent({ }); - From 3af2a97bbedf9b1d909ff32011f95b5ec46c3f29 Mon Sep 17 00:00:00 2001 From: hoeppner-dataport Date: Wed, 1 Nov 2023 14:24:17 +0100 Subject: [PATCH 24/40] chore: test url.util --- .../util/url.util.unit.ts | 21 +++++++++++++++++++ 1 file changed, 21 insertions(+) create mode 100644 src/components/feature-board-link-element/util/url.util.unit.ts 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..8f4b95dc5a --- /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 = "ftp://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); + }); + }); + }); +}); From aaca8c0ed7c07302012d0170c381fda8fb2a633d Mon Sep 17 00:00:00 2001 From: hoeppner-dataport Date: Thu, 2 Nov 2023 13:02:57 +0100 Subject: [PATCH 25/40] chore: add tests --- .../components/LinkContentElement.unit.ts | 210 ++++++++++++++++++ .../factory/linkElementContentFactory.ts | 9 + .../factory/linkElementResponseFactory.ts | 13 ++ 3 files changed, 232 insertions(+) create mode 100644 src/components/feature-board-link-element/components/LinkContentElement.unit.ts create mode 100644 tests/test-utils/factory/linkElementContentFactory.ts create mode 100644 tests/test-utils/factory/linkElementResponseFactory.ts 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..c18c94f3d6 --- /dev/null +++ b/src/components/feature-board-link-element/components/LinkContentElement.unit.ts @@ -0,0 +1,210 @@ +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 | undefined; + 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.getData).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.getData).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.getData.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/tests/test-utils/factory/linkElementContentFactory.ts b/tests/test-utils/factory/linkElementContentFactory.ts new file mode 100644 index 0000000000..0abfc8ed4e --- /dev/null +++ b/tests/test-utils/factory/linkElementContentFactory.ts @@ -0,0 +1,9 @@ +import { Factory } from "fishery"; +import { LinkElementContent } from "@/serverApi/v3"; + +export const linkElementContentFactory = Factory.define( + ({ sequence }) => ({ + title: `name${sequence}`, + url: "https://de.wikipedia.org/wiki/Meeresschildkr%C3%B6ten", + }) +); diff --git a/tests/test-utils/factory/linkElementResponseFactory.ts b/tests/test-utils/factory/linkElementResponseFactory.ts new file mode 100644 index 0000000000..ab1340237e --- /dev/null +++ b/tests/test-utils/factory/linkElementResponseFactory.ts @@ -0,0 +1,13 @@ +import { Factory } from "fishery"; +import { ContentElementType, LinkElementResponse } from "@/serverApi/v3"; +import { linkElementContentFactory } from "@@/tests/test-utils/factory/linkElementContentFactory"; +import { timestampsResponseFactory } from "@@/tests/test-utils"; + +export const linkElementResponseFactory = Factory.define( + ({ sequence }) => ({ + id: `fileElementResponse${sequence}`, + type: ContentElementType.Link, + content: linkElementContentFactory.build(), + timestamps: timestampsResponseFactory.build(), + }) +); From 935eb0fa909eee5832bccda17d65ae4513eef965 Mon Sep 17 00:00:00 2001 From: hoeppner-dataport Date: Fri, 3 Nov 2023 09:25:57 +0100 Subject: [PATCH 26/40] chore: add tests for LinkContentElementCreate --- .../LinkContentElementCreate.unit.ts | 159 ++++++++++++++++++ .../components/LinkContentElementCreate.vue | 2 +- 2 files changed, 160 insertions(+), 1 deletion(-) create mode 100644 src/components/feature-board-link-element/components/LinkContentElementCreate.unit.ts 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..e5b0bacb21 --- /dev/null +++ b/src/components/feature-board-link-element/components/LinkContentElementCreate.unit.ts @@ -0,0 +1,159 @@ +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", () => { + beforeEach(() => {}); + + 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 submit = async () => { + const button = wrapper.find("button"); + await button.trigger("click"); + + const textAreaComponent = wrapper.findComponent({ name: "v-textarea" }); + textAreaComponent.vm.$emit( + "keydown", + new KeyboardEvent("keydown", { + key: "Enter", + keyCode: 13, + }) + ); + }; + + 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, submit, hasEmitted, areRulesActive }; + }; + + describe("when valid url was entered", () => { + describe("when enter is pressed", () => { + it("should not show error-message", async () => { + const { wrapper, insertUrl, submit } = setup(); + + insertUrl(VALID_URL); + await submit(); + await nextTick(); + + const alerts = wrapper.find('[role="alert"]'); + + expect(alerts.exists()).toBe(false); + }); + + it("should emit create:url event", async () => { + const { insertUrl, submit, hasEmitted } = setup(); + + insertUrl(VALID_URL); + await submit(); + + 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, submit } = setup(); + + insertUrl(INVALID_URL); + await submit(); + + 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, submit } = setup(); + + insertUrl(INVALID_URL); + await submit(); + + 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, submit } = setup(); + + await submit(); + + 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, submit } = setup(); + + await submit(); + + 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 index 0ffd3213c8..b1a5b2bc9f 100644 --- a/src/components/feature-board-link-element/components/LinkContentElementCreate.vue +++ b/src/components/feature-board-link-element/components/LinkContentElementCreate.vue @@ -20,7 +20,7 @@ class="text" >