diff --git a/public/collabora/doc.docx b/public/collabora/doc.docx new file mode 100755 index 0000000000..e101ac5aaa Binary files /dev/null and b/public/collabora/doc.docx differ diff --git a/public/collabora/presentation.pptx b/public/collabora/presentation.pptx new file mode 100755 index 0000000000..aafa3549d7 Binary files /dev/null and b/public/collabora/presentation.pptx differ diff --git a/public/collabora/spreadsheet.xlsx b/public/collabora/spreadsheet.xlsx new file mode 100755 index 0000000000..04278abc30 Binary files /dev/null and b/public/collabora/spreadsheet.xlsx differ diff --git a/src/locales/de.ts b/src/locales/de.ts index 152aee4e1e..338b3e95a1 100644 --- a/src/locales/de.ts +++ b/src/locales/de.ts @@ -508,6 +508,8 @@ export default { "components.cardElement.fileElement.emptyAlt": "Hier ist ein Bild mit folgendem Namen", "components.cardElement.fileElement.pdfAlt": "Vorschaubild für ", "components.cardElement.fileElement.collaboraFile": "Dokument", + "components.cardElement.fileElement.collaboraFile.types": "Dokumententyp", + "components.cardElement.fileElement.collaboraFile.untitled": "Unbenanntes Dokument", "components.cardElement.fileElement.previewError": "Laden der Vorschau fehlgeschlagen.", "components.cardElement.fileElement.reloadStatus": "Status aktualisieren", "components.cardElement.fileElement.videoFormatError": @@ -547,6 +549,7 @@ export default { "components.editor.fonts.colors.indigo": "Indigo", "components.editor.fonts.colors.darkPurple": "Dunkelviolett", "components.editor.fonts.colors.pink": "Rosa", + "components.elementTypeSelection.messageError": "Element Nachricht ist nicht valide.", "components.elementTypeSelection.dialog.title": "Element hinzufügen", "components.elementTypeSelection.elements.externalToolElement.subtitle": "Externe Tools", "components.elementTypeSelection.elements.fileElement.subtitle": "Datei", @@ -557,6 +560,10 @@ export default { "components.elementTypeSelection.elements.textElement.subtitle": "Text", "components.elementTypeSelection.elements.videoConferenceElement.subtitle": "Videokonferenz", "components.elementTypeSelection.elements.h5pElement.subtitle": "Interaktives Lernelement", + "components.elementTypeSelection.elements.collabora.subtitle": "Dokument erstellen", + "components.elementTypeSelection.elements.collabora.option.text": ".docx (Text)", + "components.elementTypeSelection.elements.collabora.option.spreadsheet": ".xlsx (Tabelle)", + "components.elementTypeSelection.elements.collabora.option.presentation": ".pptx (Präsentation)", "components.externalTools.status.deactivated": "Deaktiviert", "components.externalTools.status.incomplete": "Unvollständig", "components.externalTools.status.latest": "Aktuell", diff --git a/src/locales/en.ts b/src/locales/en.ts index 29ac63af1f..1d3d2a3111 100644 --- a/src/locales/en.ts +++ b/src/locales/en.ts @@ -500,6 +500,8 @@ export default { "components.cardElement.fileElement.emptyAlt": "Here is an image with the following name", "components.cardElement.fileElement.pdfAlt": "Preview image for ", "components.cardElement.fileElement.collaboraFile": "Document", + "components.cardElement.fileElement.collaboraFile.types": "Document type", + "components.cardElement.fileElement.collaboraFile.untitled": "Untitled document", "components.cardElement.fileElement.previewError": "Failed to load the preview.", "components.cardElement.fileElement.reloadStatus": "Update status", "components.cardElement.fileElement.videoFormatError": @@ -539,6 +541,7 @@ export default { "components.editor.fonts.colors.indigo": "Indigo", "components.editor.fonts.colors.darkPurple": "Dark Purple", "components.editor.fonts.colors.pink": "Pink", + "components.elementTypeSelection.messageError": "Element message is not valid.", "components.elementTypeSelection.dialog.title": "Add element", "components.elementTypeSelection.elements.externalToolElement.subtitle": "External tools", "components.elementTypeSelection.elements.fileElement.subtitle": "File", @@ -550,6 +553,10 @@ export default { "components.elementTypeSelection.elements.textElement.subtitle": "Text", "components.elementTypeSelection.elements.videoConferenceElement.subtitle": "Video conference", "components.elementTypeSelection.elements.h5pElement.subtitle": "Interactive learning element", + "components.elementTypeSelection.elements.collabora.subtitle": "Create document", + "components.elementTypeSelection.elements.collabora.option.text": ".docx (text)", + "components.elementTypeSelection.elements.collabora.option.spreadsheet": ".xlsx (spreadsheet)", + "components.elementTypeSelection.elements.collabora.option.presentation": ".pptx (presentation)", "components.externalTools.status.deactivated": "Deactivated", "components.externalTools.status.incomplete": "Configuration incomplete", "components.externalTools.status.latest": "Latest", diff --git a/src/locales/es.ts b/src/locales/es.ts index 557deab4cb..102712f8c8 100644 --- a/src/locales/es.ts +++ b/src/locales/es.ts @@ -509,6 +509,8 @@ export default { "components.cardElement.fileElement.emptyAlt": "Aquí tenéis una imagen con el siguiente nombre", "components.cardElement.fileElement.pdfAlt": "Imagen de vista previa para ", "components.cardElement.fileElement.collaboraFile": "Archivo", + "components.cardElement.fileElement.collaboraFile.types": "Tipo de documento", + "components.cardElement.fileElement.collaboraFile.untitled": "Documento sin título", "components.cardElement.fileElement.previewError": "No se ha podido cargar la vista previa.", "components.cardElement.fileElement.reloadStatus": "Estado de actualización", "components.cardElement.fileElement.videoFormatError": @@ -548,6 +550,7 @@ export default { "components.editor.fonts.colors.indigo": "Índigo", "components.editor.fonts.colors.darkPurple": "Púrpura Oscuro", "components.editor.fonts.colors.pink": "Rosa", + "components.elementTypeSelection.messageError": "El mensaje del elemento no es válido.", "components.elementTypeSelection.dialog.title": "Añadir elemento", "components.elementTypeSelection.elements.externalToolElement.subtitle": "Herramientas externas", "components.elementTypeSelection.elements.fileElement.subtitle": "Archivo", @@ -559,6 +562,10 @@ export default { "components.elementTypeSelection.elements.textElement.subtitle": "Texto", "components.elementTypeSelection.elements.videoConferenceElement.subtitle": "Videoconferencia", "components.elementTypeSelection.elements.h5pElement.subtitle": "Elemento de aprendizaje interactivo", + "components.elementTypeSelection.elements.collabora.subtitle": "Crear documento", + "components.elementTypeSelection.elements.collabora.option.text": ".docx (Texto)", + "components.elementTypeSelection.elements.collabora.option.spreadsheet": ".xlsx (Hoja de cálculo)", + "components.elementTypeSelection.elements.collabora.option.presentation": ".pptx (Presentación)", "components.externalTools.status.deactivated": "Desactivado", "components.externalTools.status.incomplete": "Configuración incompleta", "components.externalTools.status.latest": "Actual", diff --git a/src/locales/uk.ts b/src/locales/uk.ts index 939336276a..aa91fa950e 100644 --- a/src/locales/uk.ts +++ b/src/locales/uk.ts @@ -503,6 +503,8 @@ export default { "components.cardElement.fileElement.emptyAlt": "Ось зображення з такою назвою", "components.cardElement.fileElement.pdfAlt": "попередній перегляд зображення для ", "components.cardElement.fileElement.collaboraFile": "Документ", + "components.cardElement.fileElement.collaboraFile.types": "Тип документа", + "components.cardElement.fileElement.collaboraFile.untitled": "Без назви документ", "components.cardElement.fileElement.previewError": "Не вдалося завантажити попередній перегляд.", "components.cardElement.fileElement.reloadStatus": "Статус оновлення", "components.cardElement.fileElement.videoFormatError": @@ -542,6 +544,7 @@ export default { "components.editor.fonts.colors.indigo": "Індиго", "components.editor.fonts.colors.darkPurple": "Темно-фіолетовий", "components.editor.fonts.colors.pink": "Рожевий", + "components.elementTypeSelection.messageError": "Повідомлення елемента недійсне.", "components.elementTypeSelection.dialog.title": "Додати елемент", "components.elementTypeSelection.elements.externalToolElement.subtitle": "Зовнішні інструменти", "components.elementTypeSelection.elements.fileElement.subtitle": "Файл", @@ -553,6 +556,10 @@ export default { "components.elementTypeSelection.elements.textElement.subtitle": "Текст", "components.elementTypeSelection.elements.videoConferenceElement.subtitle": "Відеоконференція", "components.elementTypeSelection.elements.h5pElement.subtitle": "Інтерактивний елемент навчання", + "components.elementTypeSelection.elements.collabora.subtitle": "Створити документ", + "components.elementTypeSelection.elements.collabora.option.text": ".docx (Документ)", + "components.elementTypeSelection.elements.collabora.option.spreadsheet": ".xlsx (Таблиця)", + "components.elementTypeSelection.elements.collabora.option.presentation": ".pptx (Презентація)", "components.externalTools.status.deactivated": "Деактивовано", "components.externalTools.status.incomplete": "Конфігурація не завершена", "components.externalTools.status.latest": "Останній", diff --git a/src/modules/data/board/cardActions/cardSocketApi.composable.ts b/src/modules/data/board/cardActions/cardSocketApi.composable.ts index 77f95ebc6e..e781ba7140 100644 --- a/src/modules/data/board/cardActions/cardSocketApi.composable.ts +++ b/src/modules/data/board/cardActions/cardSocketApi.composable.ts @@ -14,12 +14,17 @@ import { } from "./cardActionPayload.types"; import * as CardActions from "./cardActions"; import { handle, on, PermittedStoreActions } from "@/types/board/ActionFactory"; +import { AnyContentElement } from "@/types/board/ContentElement"; +import { AnyContentElementSchema } from "@/types/board/ContentElement.schema"; +import { notifyError } from "@data-app"; import { useDebounceFn } from "@vueuse/core"; import { chunk } from "lodash-es"; import { storeToRefs } from "pinia"; +import { useI18n } from "vue-i18n"; export const useCardSocketApi = () => { const cardStore = useCardStore(); + const { t } = useI18n(); const WAIT_AFTER_LAST_CALL_IN_MS = 30; const MAX_WAIT_BEFORE_FIRST_CALL_IN_MS = 200; @@ -72,7 +77,7 @@ export const useCardSocketApi = () => { ); }; - const { emitOnSocket, disconnectSocket } = useSocketConnection(dispatch); + const { emitOnSocket, disconnectSocket, emitWithAck } = useSocketConnection(dispatch); const disconnectSocketRequest = () => { disconnectSocket(); @@ -93,8 +98,15 @@ export const useCardSocketApi = () => { { maxWait: MAX_WAIT_BEFORE_FIRST_CALL_IN_MS } ); - const createElementRequest = async (payload: CreateElementRequestPayload) => { - emitOnSocket("create-element-request", payload); + const createElementRequest = async (payload: CreateElementRequestPayload): Promise => { + const response = (await emitWithAck("create-element-request", payload)) as unknown; + + try { + const anyContentElement = AnyContentElementSchema.parse(response); + return anyContentElement; + } catch { + notifyError(t("components.elementTypeSelection.messageError")); + } }; const deleteElementRequest = async (payload: DeleteElementRequestPayload) => { diff --git a/src/modules/data/board/cardActions/cardSocketApi.composable.unit.ts b/src/modules/data/board/cardActions/cardSocketApi.composable.unit.ts index d79ff07197..4be201d450 100644 --- a/src/modules/data/board/cardActions/cardSocketApi.composable.unit.ts +++ b/src/modules/data/board/cardActions/cardSocketApi.composable.unit.ts @@ -336,7 +336,7 @@ describe("useCardSocketApi", () => { createElementRequest(payload); - expect(socketMock.emitOnSocket).toHaveBeenCalledWith("create-element-request", payload); + expect(socketMock.emitWithAck).toHaveBeenCalledWith("create-element-request", payload); }); }); diff --git a/src/modules/data/file/FileStorageApi.composable.ts b/src/modules/data/file/FileStorageApi.composable.ts index 2a498bdd0f..6e55c25819 100644 --- a/src/modules/data/file/FileStorageApi.composable.ts +++ b/src/modules/data/file/FileStorageApi.composable.ts @@ -58,14 +58,19 @@ export const useFileStorageApi = () => { } }; - const uploadFromUrl = async (imageUrl: string, parentId: string, parentType: FileRecordParent): Promise => { + const uploadFromUrl = async ( + imageUrl: string, + parentId: string, + parentType: FileRecordParent, + fileName?: string + ): Promise => { try { const { pathname } = new URL(imageUrl); - const fileName = pathname.substring(pathname.lastIndexOf("/") + 1); + fileName = fileName ?? pathname.substring(pathname.lastIndexOf("/") + 1); const schoolId = useAppStore().school?.id as string; const fileUrlParams: FileUrlParams = { url: imageUrl, - fileName, + fileName: fileName === "" ? "file" : fileName, headers: { "User-Agent": "Embed Request User Agent" }, }; const response = await fileApi.uploadFromUrl( diff --git a/src/modules/feature/board-file-element/upload/FileUpload.vue b/src/modules/feature/board-file-element/upload/FileUpload.vue index 0d99f5f792..5bbece42c7 100644 --- a/src/modules/feature/board-file-element/upload/FileUpload.vue +++ b/src/modules/feature/board-file-element/upload/FileUpload.vue @@ -17,7 +17,7 @@ diff --git a/src/modules/feature/board/shared/AddElementDialog.composable.ts b/src/modules/feature/board/shared/AddElementDialog.composable.ts index f72e6a1e42..0ae720b948 100644 --- a/src/modules/feature/board/shared/AddElementDialog.composable.ts +++ b/src/modules/feature/board/shared/AddElementDialog.composable.ts @@ -1,9 +1,12 @@ +import { useAddCollaboraFile } from "./add-collabora-file.composable"; import { ElementTypeSelectionOptions, useSharedElementTypeSelection } from "./SharedElementTypeSelection.composable"; import { BoardFeature, ContentElementType, PreferredToolResponse } from "@/serverApi/v3"; +import { AnyContentElement } from "@/types/board/ContentElement"; import { notifyInfo } from "@data-app"; import { type CreateElementRequestPayload, useBoardFeatures, useBoardPermissions, useCardStore } from "@data-board"; import { useEnvConfig } from "@data-env"; import { + mdiFileDocumentOutline, mdiFolderOpenOutline, mdiFormatText, mdiLightbulbOnOutline, @@ -17,7 +20,7 @@ import { import { computed, watch } from "vue"; import { useI18n } from "vue-i18n"; -type CreateElementRequestFn = (payload: CreateElementRequestPayload) => void; +type CreateElementRequestFn = (payload: CreateElementRequestPayload) => Promise; export const useAddElementDialog = (createElementRequestFn: CreateElementRequestFn, cardId: string) => { const { isFeatureEnabled } = useBoardFeatures(); @@ -32,6 +35,9 @@ export const useAddElementDialog = (createElementRequestFn: CreateElementRequest const { isDialogOpen, isDialogLoading, closeDialog, staticElementTypeOptions, dynamicElementTypeOptions } = useSharedElementTypeSelection(); + const { openCollaboraFileDialog, setCardId, setCreateElementRequestFn } = useAddCollaboraFile(); + setCreateElementRequestFn(createElementRequestFn); + const onElementClick = async (elementType: ContentElementType) => { closeDialog(); @@ -46,6 +52,12 @@ export const useAddElementDialog = (createElementRequestFn: CreateElementRequest showNotificationByElementType(elementType); }; + const onOfficeFileClick = async () => { + setCardId(cardId); + closeDialog(); + openCollaboraFileDialog(); + }; + const showNotificationByElementType = (elementType: ContentElementType) => { const translationKeyCollaborativeTextEditor = "components.cardElement.collaborativeTextEditorElement.alert.info.visible"; @@ -157,6 +169,15 @@ export const useAddElementDialog = (createElementRequestFn: CreateElementRequest }); } + if (envConfig.value.FEATURE_COLUMN_BOARD_COLLABORA_ENABLED) { + options.push({ + icon: mdiFileDocumentOutline, + label: t("components.elementTypeSelection.elements.collabora.subtitle"), + action: () => onOfficeFileClick(), + testId: "create-element-file-with-collabora", + }); + } + return options; }; diff --git a/src/modules/feature/board/shared/AddElementDialog.composable.unit.ts b/src/modules/feature/board/shared/AddElementDialog.composable.unit.ts index f78a9b581a..9fc40a52b1 100644 --- a/src/modules/feature/board/shared/AddElementDialog.composable.unit.ts +++ b/src/modules/feature/board/shared/AddElementDialog.composable.unit.ts @@ -1,3 +1,4 @@ +import { setupCollaboraFileSelectionMock } from "../test-utils/add-collabora-file-mock"; import { setupSharedElementTypeSelectionMock } from "../test-utils/sharedElementTypeSelectionMock"; import { useAddElementDialog } from "./AddElementDialog.composable"; import { ElementTypeSelectionOptions } from "./SharedElementTypeSelection.composable"; @@ -16,6 +17,7 @@ import { ref } from "vue"; vi.mock("vue-router"); vi.mock("./SharedElementTypeSelection.composable"); +vi.mock("./add-collabora-file.composable"); vi.mock("@data-board/BoardPermissions.composable"); const mockedUseBoardPermissions = vi.mocked(useBoardPermissions); @@ -62,6 +64,7 @@ describe("ElementTypeSelection Composable", () => { const cardId = "cardId"; setupSharedElementTypeSelectionMock(); + setupCollaboraFileSelectionMock(); const addElementMock = vi.fn(); const elementType = ContentElementType.RichText; @@ -191,6 +194,8 @@ describe("ElementTypeSelection Composable", () => { const { isDialogOpen, isDialogLoading, staticElementTypeOptions, dynamicElementTypeOptions } = setupSharedElementTypeSelectionMock(); + setupCollaboraFileSelectionMock(); + createTestEnvStore({ FEATURE_COLUMN_BOARD_SUBMISSIONS_ENABLED: true, FEATURE_COLUMN_BOARD_EXTERNAL_TOOLS_ENABLED: true, @@ -200,6 +205,7 @@ describe("ElementTypeSelection Composable", () => { FEATURE_COLUMN_BOARD_VIDEOCONFERENCE_ENABLED: true, FEATURE_COLUMN_BOARD_FILE_FOLDER_ENABLED: true, FEATURE_COLUMN_BOARD_H5P_ENABLED: true, + FEATURE_COLUMN_BOARD_COLLABORA_ENABLED: true, }); const { askType } = useAddElementDialog(addElementMock, "cardId"); @@ -226,7 +232,7 @@ describe("ElementTypeSelection Composable", () => { askType(); - expect(staticElementTypeOptions.value.length).toBe(9); + expect(staticElementTypeOptions.value.length).toBe(10); }); describe("when preferred tools have finished loading", () => { @@ -333,6 +339,7 @@ describe("ElementTypeSelection Composable", () => { FEATURE_COLUMN_BOARD_VIDEOCONFERENCE_ENABLED: true, FEATURE_COLUMN_BOARD_FILE_FOLDER_ENABLED: true, FEATURE_COLUMN_BOARD_H5P_ENABLED: true, + FEATURE_COLUMN_BOARD_COLLABORA_ENABLED: true, }; const defaultHasManageVideoConferencePermission = false; @@ -348,21 +355,27 @@ describe("ElementTypeSelection Composable", () => { const { staticElementTypeOptions } = setupSharedElementTypeSelectionMock({ closeDialogMock, }); + const openCollaboraFileDialogMock = vi.fn(); + const setCardIdMock = vi.fn(); + const { collaboraFileSelectionOptions } = setupCollaboraFileSelectionMock({ + setCardIdMock, + openCollaboraFileDialogMock, + }); mockedUseBoardPermissions.mockReturnValue({ hasManageVideoConferencePermission: ref(hasManageVideoConferencePermission), } as BoardPermissionChecks); mockedPiniaStoreTyping(useCardStore); - - mockedInjectStrict.mockImplementation(() => ({ - getEnv: mergedEnv, - })); + createTestEnvStore(mergedEnv); return { elementTypeOptions: staticElementTypeOptions, + collaboraFileSelectionOptions, addElementMock, closeDialogMock, + openCollaboraFileDialogMock, + setCardIdMock, cardId, }; }; @@ -658,6 +671,44 @@ describe("ElementTypeSelection Composable", () => { expect(closeDialogMock).toHaveBeenCalledTimes(1); }); }); + + describe("when the collabora file element is clicked", () => { + it("should set isDialogOpen to false", () => { + const { elementTypeOptions, addElementMock, cardId, closeDialogMock } = setup(); + const { askType } = useAddElementDialog(addElementMock, cardId); + + askType(); + + const option = elementTypeOptions.value.find((opt) => opt.testId === "create-element-file-with-collabora"); + option?.action(); + + expect(closeDialogMock).toHaveBeenCalledTimes(1); + }); + + it("should set cardId", () => { + const { elementTypeOptions, addElementMock, cardId, setCardIdMock } = setup(); + const { askType } = useAddElementDialog(addElementMock, cardId); + + askType(); + + const option = elementTypeOptions.value.find((opt) => opt.testId === "create-element-file-with-collabora"); + option?.action(); + + expect(setCardIdMock).toHaveBeenCalledTimes(1); + }); + + it("should set isCollaboraFileDialogOpen to true", () => { + const { elementTypeOptions, addElementMock, cardId, openCollaboraFileDialogMock } = setup(); + const { askType } = useAddElementDialog(addElementMock, cardId); + + askType(); + + const option = elementTypeOptions.value.find((opt) => opt.testId === "create-element-file-with-collabora"); + option?.action(); + + expect(openCollaboraFileDialogMock).toHaveBeenCalledTimes(1); + }); + }); }); describe("dynamicElementTypeOptions actions", () => { diff --git a/src/modules/feature/board/shared/add-collabora-file.composable.ts b/src/modules/feature/board/shared/add-collabora-file.composable.ts new file mode 100644 index 0000000000..bc6dfc95dc --- /dev/null +++ b/src/modules/feature/board/shared/add-collabora-file.composable.ts @@ -0,0 +1,142 @@ +import { FileRecordParentType } from "@/fileStorageApi/v3"; +import { ContentElementType } from "@/serverApi/v3"; +import { AnyContentElement } from "@/types/board/ContentElement"; +import { FileElementContentSchema } from "@/types/board/ContentElement.schema"; +import { getFileExtension } from "@/utils/fileHelper"; +import { type CreateElementRequestPayload, useCardStore } from "@data-board"; +import { useFileStorageApi } from "@data-file"; +import { useSharedFileSelect } from "@util-board"; +import { createSharedComposable } from "@vueuse/core"; +import { ref } from "vue"; +import { useI18n } from "vue-i18n"; + +export enum CollaboraFileType { + Text, + Spreadsheet, + Presentation, +} + +type CreateElementRequestFn = (payload: CreateElementRequestPayload) => Promise; + +export const useAddCollaboraFile = createSharedComposable(() => { + const { t } = useI18n(); + const { disableFileSelectOnMount, resetFileSelectOnMountEnabled } = useSharedFileSelect(); + const { uploadFromUrl } = useFileStorageApi(); + const cardStore = useCardStore(); + + const cardId = ref(""); + const createElementRequestFn = ref(() => Promise.resolve(undefined)); + const isCollaboraFileDialogOpen = ref(false); + + const closeCollaboraFileDialog = () => { + isCollaboraFileDialogOpen.value = false; + }; + + const openCollaboraFileDialog = () => { + isCollaboraFileDialogOpen.value = true; + }; + + const setCardId = (id: string) => { + cardId.value = id; + }; + + const setCreateElementRequestFn = (fn: CreateElementRequestFn) => { + createElementRequestFn.value = fn; + }; + + const updateFileElementCaption = async (element: AnyContentElement, caption: string) => { + const elementContent = FileElementContentSchema.parse(element.content); + + elementContent.caption = caption; + element.content = elementContent; + await cardStore.updateElementRequest({ element }); + }; + + const getAssetUrl = (collaboraFileType: CollaboraFileType): string | undefined => { + const base = `${window.location.origin}/collabora`; + + if (collaboraFileType === CollaboraFileType.Text) { + return `${base}/doc.docx`; + } + if (collaboraFileType === CollaboraFileType.Spreadsheet) { + return `${base}/spreadsheet.xlsx`; + } + if (collaboraFileType === CollaboraFileType.Presentation) { + return `${base}/presentation.pptx`; + } + }; + + const createFileElementWithCollaboraFile = async (type: CollaboraFileType, fileName: string, caption: string) => { + const element = await createElementRequestFn.value({ + type: ContentElementType.File, + cardId: cardId.value, + }); + if (!element) { + return; + } + initializeFileElementWithCollaboraFile(cardId.value, element, type, fileName, caption); + }; + + const initializeFileElementWithCollaboraFile = async ( + cardId: string, + element: AnyContentElement, + collaboraFileType: CollaboraFileType, + fileName: string, + caption: string + ) => { + const assetUrl = getAssetUrl(collaboraFileType); + if (!assetUrl) { + return; + } + const fileExtension = getFileExtension(assetUrl); + if (!fileExtension) { + return; + } + + try { + disableFileSelectOnMount(); + await uploadFromUrl(assetUrl, element.id, FileRecordParentType.BOARDNODES, fileName + "." + fileExtension); + + if (caption && caption.trim().length > 0) { + await updateFileElementCaption(element, caption.trim()); + } + } catch { + await cardStore.deleteElementRequest({ elementId: element.id, cardId }); + } finally { + resetFileSelectOnMountEnabled(); + closeCollaboraFileDialog(); + } + }; + + const collaboraFileSelectionOptions = [ + { + id: "1", + label: t("components.elementTypeSelection.elements.collabora.option.text"), + action: async (fileName: string, caption: string) => + createFileElementWithCollaboraFile(CollaboraFileType.Text, fileName, caption), + }, + { + id: "2", + label: t("components.elementTypeSelection.elements.collabora.option.spreadsheet"), + action: async (fileName: string, caption: string) => + createFileElementWithCollaboraFile(CollaboraFileType.Spreadsheet, fileName, caption), + }, + { + id: "3", + label: t("components.elementTypeSelection.elements.collabora.option.presentation"), + action: async (fileName: string, caption: string) => + createFileElementWithCollaboraFile(CollaboraFileType.Presentation, fileName, caption), + }, + ]; + + return { + openCollaboraFileDialog, + closeCollaboraFileDialog, + isCollaboraFileDialogOpen, + collaboraFileSelectionOptions, + getAssetUrl, + setCardId, + cardId, + setCreateElementRequestFn, + }; +}); diff --git a/src/modules/feature/board/shared/add-collabora-file.composable.unit.ts b/src/modules/feature/board/shared/add-collabora-file.composable.unit.ts new file mode 100644 index 0000000000..473879674f --- /dev/null +++ b/src/modules/feature/board/shared/add-collabora-file.composable.unit.ts @@ -0,0 +1,162 @@ +// eslint-disable-next-line @typescript-eslint/no-restricted-imports +import { setupFileSelectMock } from "../../../util/board/test-utils/file-select-mock"; +import { CollaboraFileType, useAddCollaboraFile } from "./add-collabora-file.composable"; +// import { AnyContentElement, ContentElementType } from "@/types/board/ContentElement"; +import { createTestingPinia } from "@pinia/testing"; +import { setActivePinia } from "pinia"; + +vi.mock("vue-i18n", () => ({ + useI18n: vi.fn().mockReturnValue({ + t: vi.fn().mockImplementation((key: string) => key), + n: vi.fn().mockImplementation((key: string) => key), + }), +})); + +vi.mock("../../../util/board/file-select.composable"); +const disableFileSelectOnMountMock = vi.fn(); +const resetFileSelectOnMountEnabledMock = vi.fn(); +setupFileSelectMock({ + disableFileSelectOnMountMock, + resetFileSelectOnMountEnabledMock, +}); + +describe("AddCollaboraFileComposable", () => { + beforeEach(() => { + setActivePinia(createTestingPinia()); + }); + + describe("openCollaboraFileDialog", () => { + it("should set isCollaboraFileDialogOpen to true", () => { + const { openCollaboraFileDialog, isCollaboraFileDialogOpen } = useAddCollaboraFile(); + + isCollaboraFileDialogOpen.value = false; + expect(isCollaboraFileDialogOpen.value).toBe(false); + + openCollaboraFileDialog(); + + expect(isCollaboraFileDialogOpen.value).toBe(true); + }); + }); + + describe("closeCollaboraFileDialog", () => { + it("should set isCollaboraFileDialogOpen to false", () => { + const { closeCollaboraFileDialog, isCollaboraFileDialogOpen } = useAddCollaboraFile(); + + isCollaboraFileDialogOpen.value = true; + expect(isCollaboraFileDialogOpen.value).toBe(true); + + closeCollaboraFileDialog(); + + expect(isCollaboraFileDialogOpen.value).toBe(false); + }); + }); + + describe("setCardId", () => { + it("should set cardId", () => { + const { setCardId, cardId, isCollaboraFileDialogOpen } = useAddCollaboraFile(); + + isCollaboraFileDialogOpen.value = true; + expect(isCollaboraFileDialogOpen.value).toBe(true); + + setCardId("test-card-id"); + + expect(cardId.value).toBe("test-card-id"); + }); + }); + + describe("getAssetUrl", () => { + it("returns correct URL for collabora types", () => { + const { getAssetUrl } = useAddCollaboraFile(); + const origin = window.location.origin; + + expect(getAssetUrl(CollaboraFileType.Text)).toBe(`${origin}/collabora/doc.docx`); + expect(getAssetUrl(CollaboraFileType.Spreadsheet)).toBe(`${origin}/collabora/spreadsheet.xlsx`); + expect(getAssetUrl(CollaboraFileType.Presentation)).toBe(`${origin}/collabora/presentation.pptx`); + }); + }); + + // describe("initializeFileElementWithCollaboraFile", () => { + // it("should disable file select on mount", async () => { + // const { initializeFileElementWithCollaboraFile } = useAddCollaboraFile(); + // const cardId = "test-card-id"; + // const element = { + // id: "new-element-id", + // type: ContentElementType.File, + // content: {}, + // timestamps: {}, + // } as AnyContentElement; + // const collaboraFileType = CollaboraFileType.Text; + // const fileName = "test-file"; + // const caption = "test-caption"; + // await initializeFileElementWithCollaboraFile(cardId, element, collaboraFileType, fileName, caption); + // expect(disableFileSelectOnMountMock).toHaveBeenCalled(); + // }); + // it("should reset file select after initialization", async () => { + // useAddCollaboraFile(); + // const cardId = "test-card-id"; + // const element = { + // id: "new-element-id", + // type: ContentElementType.File, + // content: {}, + // timestamps: {}, + // } as AnyContentElement; + // const collaboraFileType = CollaboraFileType.Text; + // const fileName = "test-file"; + // const caption = "test-caption"; + // await initializeFileElementWithCollaboraFile(cardId, element, collaboraFileType, fileName, caption); + // expect(resetFileSelectOnMountEnabledMock).toHaveBeenCalled(); + // }); + // it("should close the dialog after initialization", async () => { + // const { openCollaboraFileDialog, isCollaboraFileDialogOpen } = useAddCollaboraFile(); + // const cardId = "test-card-id"; + // const element = { + // id: "new-element-id", + // type: ContentElementType.File, + // content: {}, + // timestamps: {}, + // } as AnyContentElement; + // const collaboraFileType = CollaboraFileType.Text; + // const fileName = "test-file"; + // const caption = "test-caption"; + // openCollaboraFileDialog(); + // expect(isCollaboraFileDialogOpen.value).toBe(true); + // await initializeFileElementWithCollaboraFile(cardId, element, collaboraFileType, fileName, caption); + // expect(isCollaboraFileDialogOpen.value).toBe(false); + // }); + // }); +}); + +// describe("when the collabora file action is called", () => { +// // it("should call add element function with right argument for all collabora options", async () => { +// // const { addElementMock, collaboraFileSelectionOptions, cardId } = setup(); +// // const { askCollaboraFileType } = useAddElementDialog(addElementMock, cardId); +// // askCollaboraFileType(); +// // for (const option of collaboraFileSelectionOptions.value) { +// // await option.action("test-office-file", "Some caption"); +// // expect(addElementMock).toHaveBeenLastCalledWith({ +// // type: ContentElementType.File, +// // cardId, +// // }); +// // } +// // expect(addElementMock).toHaveBeenCalledTimes(collaboraFileSelectionOptions.value.length); +// // }); +// // it("should call the initialize element function for all collabora options", async () => { +// // const { collaboraFileSelectionOptions, initializeFileElementWithCollaboraFileMock, cardId } = setup(); +// // const addElementMock = vi.fn(() => +// // Promise.resolve({ +// // id: "new-element-id", +// // type: ContentElementType.File, +// // content: {}, +// // timestamps: {}, +// // } as AnyContentElement) +// // ); +// // const { askCollaboraFileType } = useAddElementDialog(addElementMock, cardId); +// // askCollaboraFileType(); +// // for (const option of collaboraFileSelectionOptions.value) { +// // await option.action("test-office-file", "Some caption"); +// // } +// // expect(initializeFileElementWithCollaboraFileMock).toHaveBeenCalledTimes( +// // collaboraFileSelectionOptions.value.length +// // ); +// // }); +// }); diff --git a/src/modules/feature/board/test-utils/AddElementDialogMock.ts b/src/modules/feature/board/test-utils/AddElementDialogMock.ts index 6651727de4..c46eca94f4 100644 --- a/src/modules/feature/board/test-utils/AddElementDialogMock.ts +++ b/src/modules/feature/board/test-utils/AddElementDialogMock.ts @@ -42,12 +42,14 @@ export const setupAddElementDialogMock = (props: Props = {}) => { }[] > = ref([]); const askTypeMock = props.askTypeMock || vi.fn(); + const askCollaboraFileTypeMock = vi.fn(); const onFileSelectMock = vi.fn(); const isFilePickerOpenMock = ref(false); const isDialogOpenMock = ref(false); const mocks = { askType: askTypeMock, + askCollaboraFileType: askCollaboraFileTypeMock, isDialogOpen: isDialogOpenMock, staticElementTypeOptions: staticElementTypeOptionsMock, dynamicElementTypeOptions: dynamicElementTypeOptionsMock, diff --git a/src/modules/feature/board/test-utils/add-collabora-file-mock.ts b/src/modules/feature/board/test-utils/add-collabora-file-mock.ts new file mode 100644 index 0000000000..a051ffaf47 --- /dev/null +++ b/src/modules/feature/board/test-utils/add-collabora-file-mock.ts @@ -0,0 +1,44 @@ +import { useAddCollaboraFile } from "../shared/add-collabora-file.composable"; +import { collaboraFileSelectionOptionsFactory } from "../test-utils/collabora-file-selection-options.factory"; +import { Mock } from "vitest"; +import { ref } from "vue"; + +interface Props { + openCollaboraFileDialogMock?: Mock; + setCardIdMock?: Mock; +} +interface CollaboraFileSelectionOptions { + id: string; + label: string; + action: (fileName: string, caption: string) => Promise; +} + +export const setupCollaboraFileSelectionMock = (props: Props = {}) => { + const { setCardIdMock, openCollaboraFileDialogMock } = props; + const mockedCollaboraFileSelection = vi.mocked(useAddCollaboraFile); + + const openCollaboraFileDialog = openCollaboraFileDialogMock ?? vi.fn(); + const closeCollaboraFileDialog = vi.fn(); + const isCollaboraFileDialogOpen = ref(false); + const collaboraFileSelectionOptions: Array = + collaboraFileSelectionOptionsFactory.createCollaboraFileSelectionOptionsList(); + const getAssetUrl = vi.fn(); + const setCardId = setCardIdMock ?? vi.fn(); + const setCreateElementRequestFn = vi.fn(); + const cardId = ref(""); + + const mocks = { + openCollaboraFileDialog, + closeCollaboraFileDialog, + collaboraFileSelectionOptions, + isCollaboraFileDialogOpen, + getAssetUrl, + setCardId, + setCreateElementRequestFn, + cardId, + }; + + mockedCollaboraFileSelection.mockReturnValue(mocks); + + return mocks; +}; diff --git a/src/modules/feature/board/test-utils/collabora-file-selection-options.factory.ts b/src/modules/feature/board/test-utils/collabora-file-selection-options.factory.ts new file mode 100644 index 0000000000..d8d06d15a3 --- /dev/null +++ b/src/modules/feature/board/test-utils/collabora-file-selection-options.factory.ts @@ -0,0 +1,21 @@ +import { vi } from "vitest"; + +export const collaboraFileSelectionOptionsFactory = { + createCollaboraFileSelectionOptionsList: () => [ + { + id: "1", + label: "Text Document", + action: vi.fn(), + }, + { + id: "2", + label: "Table Document", + action: vi.fn(), + }, + { + id: "3", + label: "Presentation Document", + action: vi.fn(), + }, + ], +}; diff --git a/src/modules/util/board/file-select.composable.ts b/src/modules/util/board/file-select.composable.ts new file mode 100644 index 0000000000..d908b05607 --- /dev/null +++ b/src/modules/util/board/file-select.composable.ts @@ -0,0 +1,22 @@ +import { createSharedComposable } from "@vueuse/core"; +import { Ref, ref } from "vue"; + +const useFileSelect = () => { + const isFileSelectOnMountEnabled: Ref = ref(true); + + const resetFileSelectOnMountEnabled = () => { + isFileSelectOnMountEnabled.value = true; + }; + + const disableFileSelectOnMount = () => { + isFileSelectOnMountEnabled.value = false; + }; + + return { + isFileSelectOnMountEnabled, + resetFileSelectOnMountEnabled, + disableFileSelectOnMount, + }; +}; + +export const useSharedFileSelect = createSharedComposable(useFileSelect); diff --git a/src/modules/util/board/file-select.composable.unit.ts b/src/modules/util/board/file-select.composable.unit.ts new file mode 100644 index 0000000000..9ffb7d6a6a --- /dev/null +++ b/src/modules/util/board/file-select.composable.unit.ts @@ -0,0 +1,28 @@ +import { useSharedFileSelect } from "./file-select.composable"; + +describe("SharedFileSelectComposable", () => { + describe("isFileSelectOnMountEnabled", () => { + it("should be true as default", () => { + const { isFileSelectOnMountEnabled } = useSharedFileSelect(); + expect(isFileSelectOnMountEnabled.value).toBe(true); + }); + }); + + describe("resetTriggerFileSelect", () => { + it("should set 'isFileSelectOnMountEnabled' to be true again", () => { + const { isFileSelectOnMountEnabled, resetFileSelectOnMountEnabled } = useSharedFileSelect(); + isFileSelectOnMountEnabled.value = false; + resetFileSelectOnMountEnabled(); + expect(isFileSelectOnMountEnabled.value).toBe(true); + }); + }); + + describe("disableFileSelectOnMount", () => { + it("should set 'isFileSelectOnMountEnabled' to false", () => { + const { isFileSelectOnMountEnabled, disableFileSelectOnMount } = useSharedFileSelect(); + isFileSelectOnMountEnabled.value = true; + disableFileSelectOnMount(); + expect(isFileSelectOnMountEnabled.value).toBe(false); + }); + }); +}); diff --git a/src/modules/util/board/index.ts b/src/modules/util/board/index.ts index a400b277aa..80486b8159 100644 --- a/src/modules/util/board/index.ts +++ b/src/modules/util/board/index.ts @@ -8,6 +8,7 @@ import { } from "./editMode.composable"; import { useElementFocus } from "./elementFocus.composable"; import { extractDataAttribute } from "./extractDataAttribute.util"; +import { useSharedFileSelect } from "./file-select.composable"; import { useInlineEditInteractionHandler } from "./InlineEditInteractionHandler.composable"; import { useSharedLastCreatedElement } from "./LastCreatedElement.composable"; import { useShareBoardLink } from "./shareBoardLink.composable"; @@ -23,5 +24,6 @@ export { useMediaBoardEditMode, useShareBoardLink, useSharedEditMode, + useSharedFileSelect, useSharedLastCreatedElement, }; diff --git a/src/modules/util/board/test-utils/file-select-mock.ts b/src/modules/util/board/test-utils/file-select-mock.ts new file mode 100644 index 0000000000..81d140dcbd --- /dev/null +++ b/src/modules/util/board/test-utils/file-select-mock.ts @@ -0,0 +1,27 @@ +import { useSharedFileSelect } from "../file-select.composable"; +import { Mock } from "vitest"; +import { ref } from "vue"; + +interface Props { + resetFileSelectOnMountEnabledMock?: Mock; + disableFileSelectOnMountMock?: Mock; +} + +export const setupFileSelectMock = (props: Props = {}) => { + const { resetFileSelectOnMountEnabledMock, disableFileSelectOnMountMock } = props; + const mockedSharedFileSelect = vi.mocked(useSharedFileSelect); + + const isFileSelectOnMountEnabled = ref(true); + const resetFileSelectOnMountEnabled = resetFileSelectOnMountEnabledMock ?? vi.fn(); + const disableFileSelectOnMount = disableFileSelectOnMountMock ?? vi.fn(); + + const mocks = { + isFileSelectOnMountEnabled, + resetFileSelectOnMountEnabled, + disableFileSelectOnMount, + }; + + mockedSharedFileSelect.mockReturnValue(mocks); + + return mocks; +}; diff --git a/src/router/vue-client-route.mjs b/src/router/vue-client-route.mjs index 05a7cc4914..c176a792cd 100644 --- a/src/router/vue-client-route.mjs +++ b/src/router/vue-client-route.mjs @@ -9,6 +9,9 @@ const uuid = "[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a- const vueRoutes = [ `^/favicon.png$`, + `^/collabora/doc.docx$`, + `^/collabora/presentation.pptx$`, + `^/collabora/spreadsheet.xlsx$`, `^/_nuxt/*`, `^/runtime.config.json`, `^/activation/${activationCode}/?$`, diff --git a/src/types/board/ContentElement.schema.ts b/src/types/board/ContentElement.schema.ts new file mode 100644 index 0000000000..629c3240fc --- /dev/null +++ b/src/types/board/ContentElement.schema.ts @@ -0,0 +1,52 @@ +import { ContentElementType } from "./ContentElement"; +import { z } from "zod"; + +const ExternalToolElementContentSchema = z.object({ + contextExternalToolId: z.string().nullable(), +}); + +export const FileElementContentSchema = z.object({ + caption: z.string(), + alternativeText: z.string(), +}); + +const FileFolderElementContentSchema = z.object({ + title: z.string(), +}); + +const H5pElementContentSchema = z.object({ + contentId: z.string().nullable(), +}); + +const LinkElementContentSchema = z.object({ + url: z.string(), + title: z.string(), + description: z.string().optional(), +}); + +const RichTextElementContentSchema = z.object({ + text: z.string(), + inputFormat: z.string(), +}); + +const SubmissionContainerElementContentSchema = z.object({ + dueDate: z.string(), +}); + +export const AnyContentElementSchema = z.object({ + id: z.string(), + type: z.enum(ContentElementType), + timestamps: z.object({ + createdAt: z.string(), + lastUpdatedAt: z.string(), + }), + content: z.union([ + ExternalToolElementContentSchema, + FileElementContentSchema, + FileFolderElementContentSchema, + H5pElementContentSchema, + LinkElementContentSchema, + RichTextElementContentSchema, + SubmissionContainerElementContentSchema, + ]), +});