diff --git a/src/assets/img/collaborativeEditor.svg b/src/assets/img/collaborativeEditor.svg
new file mode 100644
index 0000000000..62e9ae705f
--- /dev/null
+++ b/src/assets/img/collaborativeEditor.svg
@@ -0,0 +1,109 @@
+
diff --git a/src/assets/img/index.d.ts b/src/assets/img/index.d.ts
index 1294355bfd..c391661c57 100644
--- a/src/assets/img/index.d.ts
+++ b/src/assets/img/index.d.ts
@@ -6,3 +6,8 @@ declare module "@/assets/img/image-not-available.svg" {
const value: string;
export default value;
}
+
+declare module "@/assets/img/collaborativeEditor.svg" {
+ const value: string;
+ export default value;
+}
diff --git a/src/locales/de.ts b/src/locales/de.ts
index 925c14d99a..ffdda2d4ce 100644
--- a/src/locales/de.ts
+++ b/src/locales/de.ts
@@ -313,6 +313,7 @@ export default {
"components.cardElement.deleteElement": "löschen",
"components.cardElement.dragElement": "verschieben",
"components.cardElement.drawingElement": "Whiteboard",
+ "components.cardElement.collaborativeTextEditorElement": "Texteditor",
"components.cardElement.fileElement.altDescription":
"Eine kurze Beschreibung hilft Personen, die das Bild nicht sehen können.",
"components.cardElement.fileElement.alternativeText": "Alternativtext",
@@ -367,6 +368,8 @@ export default {
"components.elementTypeSelection.elements.externalToolElement.subtitle":
"Externe Tools",
"components.elementTypeSelection.elements.fileElement.subtitle": "Datei",
+ "components.elementTypeSelection.elements.collaborativeTextEditor.subtitle":
+ "Texteditor",
"components.elementTypeSelection.elements.linkElement.subtitle": "Link",
"components.elementTypeSelection.elements.submissionElement.subtitle":
"Abgabe",
diff --git a/src/locales/en.ts b/src/locales/en.ts
index 1384ef89ea..65e86ceeb8 100644
--- a/src/locales/en.ts
+++ b/src/locales/en.ts
@@ -310,6 +310,7 @@ export default {
"components.cardElement.deleteElement": "Delete element",
"components.cardElement.dragElement": "Move element",
"components.cardElement.drawingElement": "Whiteboard",
+ "components.cardElement.collaborativeTextEditorElement": "Texteditor",
"components.cardElement.fileElement.altDescription":
"A short description helps people who cannot see the picture.",
"components.cardElement.fileElement.alternativeText": "Alternative Text",
@@ -363,6 +364,8 @@ export default {
"components.elementTypeSelection.elements.externalToolElement.subtitle":
"External tools",
"components.elementTypeSelection.elements.fileElement.subtitle": "File",
+ "components.elementTypeSelection.elements.collaborativeTextEditor.subtitle":
+ "Texteditor",
"components.elementTypeSelection.elements.linkElement.subtitle": "Link",
"components.elementTypeSelection.elements.submissionElement.subtitle":
"Submission",
diff --git a/src/locales/es.ts b/src/locales/es.ts
index c61c9ad4f3..fc1ce01fc2 100644
--- a/src/locales/es.ts
+++ b/src/locales/es.ts
@@ -316,6 +316,7 @@ export default {
"components.cardElement.deleteElement": "Suprimir elemento",
"components.cardElement.dragElement": "Mover elemento",
"components.cardElement.drawingElement": "Pizarra",
+ "components.cardElement.collaborativeTextEditorElement": "Texteditor",
"components.cardElement.fileElement.altDescription":
"Una breve descripción ayuda a las personas que no pueden ver la imagen.",
"components.cardElement.fileElement.alternativeText": "Texto alternativo",
@@ -369,7 +370,10 @@ export default {
"components.elementTypeSelection.dialog.title": "Añadir elemento",
"components.elementTypeSelection.elements.externalToolElement.subtitle":
"Herramientas externas",
- "components.elementTypeSelection.elements.fileElement.subtitle": "Archivo",
+ "components.elementTypeSelection.elements.fileElement.subtitle":
+ "Editor de texto",
+ "components.elementTypeSelection.elements.collaborativeTextEditor.subtitle":
+ "Text Editor",
"components.elementTypeSelection.elements.linkElement.subtitle": "Enlace",
"components.elementTypeSelection.elements.submissionElement.subtitle":
"Envíos",
diff --git a/src/locales/uk.ts b/src/locales/uk.ts
index a1a2f54450..1a9f04ae3f 100644
--- a/src/locales/uk.ts
+++ b/src/locales/uk.ts
@@ -317,6 +317,7 @@ export default {
"components.cardElement.deleteElement": "Видалити елемент",
"components.cardElement.dragElement": "Перемістити елемент",
"components.cardElement.drawingElement": "Дошка",
+ "components.cardElement.collaborativeTextEditorElement": "Texteditor",
"components.cardElement.fileElement.altDescription":
"Короткий опис допомагає людям, які не бачать зображення.",
"components.cardElement.fileElement.alternativeText": "альтернативний текст",
@@ -370,6 +371,8 @@ export default {
"components.elementTypeSelection.elements.externalToolElement.subtitle":
"Зовнішні інструменти",
"components.elementTypeSelection.elements.fileElement.subtitle": "Файл",
+ "components.elementTypeSelection.elements.collaborativeTextEditor.subtitle":
+ "Текстовий редактор",
"components.elementTypeSelection.elements.linkElement.subtitle": "Посилання",
"components.elementTypeSelection.elements.submissionElement.subtitle":
"Подання",
diff --git a/src/modules/feature/board-collaborative-text-editor-element/CollaborativeTextEditorElement.unit.ts b/src/modules/feature/board-collaborative-text-editor-element/CollaborativeTextEditorElement.unit.ts
new file mode 100644
index 0000000000..bd21177ca2
--- /dev/null
+++ b/src/modules/feature/board-collaborative-text-editor-element/CollaborativeTextEditorElement.unit.ts
@@ -0,0 +1,217 @@
+import { CollaborativeTextEditorParentType } from "@/serverApi/v3";
+import NotifierModule from "@/store/notifier";
+import { NOTIFIER_MODULE_KEY } from "@/utils/inject";
+import { createModuleMocks } from "@/utils/mock-store-module";
+import { collaborativeTextEditorElementResponseFactory } from "@@/tests/test-utils";
+import {
+ createTestingI18n,
+ createTestingVuetify,
+} from "@@/tests/test-utils/setup";
+import { CollaborativeTextEditorElement } from "@feature-board-collaborative-text-editor-element";
+import { createMock } from "@golevelup/ts-jest";
+import { ContentElementBar } from "@ui-board";
+import { mount } from "@vue/test-utils";
+import { nextTick } from "vue";
+import CollaborativeTextEditorElementMenu from "./components/CollaborativeTextEditorElementMenu.vue";
+import { setupCollaborativeTextEditorApiMock } from "./test-utils/collaborativeTextEditorApiMock";
+
+// Mocks
+jest.mock("@data-board", () => ({
+ useBoardFocusHandler: jest.fn(),
+ useContentElementState: jest.fn(() => ({ modelValue: {} })),
+ useDeleteConfirmationDialog: jest.fn(),
+}));
+jest.mock("@feature-board");
+jest.mock("./composables/CollaborativeTextEditorApi.composable");
+
+describe("CollaborativeTextEditorElement", () => {
+ const notifierModule = createModuleMocks(NotifierModule);
+
+ const setup = (props: { isEditMode: boolean; getUrlHasError?: boolean }) => {
+ document.body.setAttribute("data-app", "true");
+
+ const element = collaborativeTextEditorElementResponseFactory.build();
+
+ const resolvedValue = props.getUrlHasError
+ ? undefined
+ : `${CollaborativeTextEditorParentType.ContentElement}/${element.id}`;
+ const getUrlMock = jest.fn().mockResolvedValueOnce(resolvedValue);
+
+ const { getUrl } = setupCollaborativeTextEditorApiMock({
+ getUrlMock,
+ });
+
+ const wrapper = mount(CollaborativeTextEditorElement, {
+ global: {
+ plugins: [createTestingVuetify(), createTestingI18n()],
+ provide: {
+ [NOTIFIER_MODULE_KEY.valueOf()]: notifierModule,
+ },
+ },
+ propsData: { ...props, element: element },
+ });
+
+ const windowMock = createMock();
+ jest.spyOn(window, "open").mockImplementation(() => windowMock);
+
+ return {
+ wrapper,
+ isEditMode: true,
+ element: element,
+ getUrl,
+ windowMock,
+ };
+ };
+
+ afterEach(() => {
+ jest.resetAllMocks();
+ });
+
+ describe("when component is not in edit-mode", () => {
+ it("should render the element corrrectly", () => {
+ const { wrapper } = setup({
+ isEditMode: false,
+ });
+ expect(
+ wrapper.findComponent(CollaborativeTextEditorElement).isVisible()
+ ).toBe(true);
+ });
+
+ it("should render the ContentElementBar", () => {
+ const { wrapper } = setup({
+ isEditMode: false,
+ });
+ expect(wrapper.findComponent(ContentElementBar).exists()).toBe(true);
+ });
+
+ it("should not render the BoardMenu", () => {
+ const { wrapper } = setup({
+ isEditMode: false,
+ });
+
+ const boardMenu = wrapper.findComponent(
+ CollaborativeTextEditorElementMenu
+ );
+
+ expect(boardMenu.exists()).toBe(false);
+ });
+
+ describe("when element is clicked", () => {
+ describe("when getUrl returns successful", () => {
+ it("should call getUrl", async () => {
+ const { wrapper, element, getUrl } = setup({
+ isEditMode: false,
+ });
+
+ const card = wrapper.findComponent({
+ ref: "collaborativetextEditorElement",
+ });
+
+ await card.trigger("click");
+
+ expect(getUrl).toHaveBeenCalledTimes(1);
+ expect(getUrl).toHaveBeenCalledWith(
+ element.id,
+ CollaborativeTextEditorParentType.ContentElement
+ );
+ });
+
+ it("should open collaborative text editor in new tab", async () => {
+ const { wrapper, element, windowMock } = setup({
+ isEditMode: false,
+ });
+
+ const card = wrapper.findComponent({
+ ref: "collaborativetextEditorElement",
+ });
+
+ await card.trigger("click");
+
+ expect(window.open).toHaveBeenCalledTimes(1);
+ expect(windowMock.location).toBe(
+ `${CollaborativeTextEditorParentType.ContentElement}/${element.id}`
+ );
+ });
+ });
+
+ describe("when getUrl returns undefined", () => {
+ it("should not open new tab", async () => {
+ const { wrapper, windowMock, element } = setup({
+ isEditMode: false,
+ getUrlHasError: true,
+ });
+
+ const card = wrapper.findComponent({
+ ref: "collaborativetextEditorElement",
+ });
+
+ await card.trigger("click");
+
+ expect(window.open).toHaveBeenCalledTimes(1);
+ expect(windowMock.location).not.toBe(
+ `${CollaborativeTextEditorParentType.ContentElement}/${element.id}`
+ );
+ });
+ });
+ });
+ });
+
+ describe("when component is in edit-mode", () => {
+ it("should render BoardMenu element", async () => {
+ const { wrapper } = setup({
+ isEditMode: true,
+ });
+
+ const boardMenu = wrapper.findComponent(
+ CollaborativeTextEditorElementMenu
+ );
+ expect(boardMenu.exists()).toBe(true);
+ });
+
+ describe("when move down is emitted by CollaborativeTextEditorElementMenu", () => {
+ it('should emit "move-down:edit" collaborative text editor', async () => {
+ const { wrapper } = setup({
+ isEditMode: true,
+ });
+
+ const boardMenu = wrapper.findComponent(
+ CollaborativeTextEditorElementMenu
+ );
+ boardMenu.vm.$emit("move-down:element");
+
+ expect(wrapper.emitted("move-down:edit")).toBeTruthy();
+ });
+ });
+
+ describe("when move up is clicked", () => {
+ it('should emit "move-up" collaborative text editor', async () => {
+ const { wrapper } = setup({
+ isEditMode: true,
+ });
+
+ const boardMenu = wrapper.findComponent(
+ CollaborativeTextEditorElementMenu
+ );
+ boardMenu.vm.$emit("move-up:element");
+
+ expect(wrapper.emitted("move-up:edit")).toBeTruthy();
+ });
+ });
+
+ describe("when delete is clicked", () => {
+ it('should emit "delete" collaborative text editor', async () => {
+ const { wrapper } = setup({
+ isEditMode: true,
+ });
+
+ const boardMenu = wrapper.findComponent(
+ CollaborativeTextEditorElementMenu
+ );
+ boardMenu.vm.$emit("delete:element", Promise.resolve(true));
+ await nextTick();
+
+ expect(wrapper.emitted("delete:element")).toBeTruthy();
+ });
+ });
+ });
+});
diff --git a/src/modules/feature/board-collaborative-text-editor-element/CollaborativeTextEditorElement.vue b/src/modules/feature/board-collaborative-text-editor-element/CollaborativeTextEditorElement.vue
new file mode 100644
index 0000000000..8850eaec26
--- /dev/null
+++ b/src/modules/feature/board-collaborative-text-editor-element/CollaborativeTextEditorElement.vue
@@ -0,0 +1,100 @@
+
+
+
+
+
+
+
+ {{ $t("components.cardElement.collaborativeTextEditorElement") }}
+
+
+
+
+
+
+
+
+
diff --git a/src/modules/feature/board-collaborative-text-editor-element/components/CollaborativeTextEditorElementMenu.unit.ts b/src/modules/feature/board-collaborative-text-editor-element/components/CollaborativeTextEditorElementMenu.unit.ts
new file mode 100644
index 0000000000..62f6daf21d
--- /dev/null
+++ b/src/modules/feature/board-collaborative-text-editor-element/components/CollaborativeTextEditorElementMenu.unit.ts
@@ -0,0 +1,129 @@
+import setupDeleteConfirmationComposableMock from "@@/tests/test-utils/composable-mocks/setupDeleteConfirmationComposableMock";
+import {
+ createTestingI18n,
+ createTestingVuetify,
+} from "@@/tests/test-utils/setup";
+import {
+ BoardMenuActionDelete,
+ BoardMenuActionMoveDown,
+ BoardMenuActionMoveUp,
+} from "@ui-board";
+import { useDeleteConfirmationDialog } from "@ui-confirmation-dialog";
+import { shallowMount } from "@vue/test-utils";
+import { ref } from "vue";
+import CollaborativeTextEditorElementMenu from "./CollaborativeTextEditorElementMenu.vue";
+
+// Mocks
+jest.mock("@ui-confirmation-dialog");
+
+const mockedUseDeleteConfirmationDialog = jest.mocked(
+ useDeleteConfirmationDialog
+);
+
+describe("CollaborativeTextEditorElementMenu", () => {
+ const getWrapper = (propsData: {
+ isFirstElement: boolean;
+ isLastElement: boolean;
+ hasMultipleElements: boolean;
+ }) => {
+ document.body.setAttribute("data-app", "true");
+
+ const askDeleteConfirmationMock = async () => await Promise.resolve(true);
+
+ setupDeleteConfirmationComposableMock({
+ askDeleteConfirmationMock,
+ });
+
+ mockedUseDeleteConfirmationDialog.mockReturnValue({
+ askDeleteConfirmation: askDeleteConfirmationMock,
+ isDeleteDialogOpen: ref(false),
+ });
+
+ const wrapper = shallowMount(CollaborativeTextEditorElementMenu, {
+ global: {
+ plugins: [createTestingVuetify(), createTestingI18n()],
+ },
+ props: propsData,
+ });
+
+ return {
+ wrapper,
+ };
+ };
+
+ afterEach(() => {
+ jest.resetAllMocks();
+ });
+
+ const setup = () => {
+ const { wrapper } = getWrapper({
+ hasMultipleElements: true,
+ isFirstElement: false,
+ isLastElement: false,
+ });
+
+ return {
+ wrapper,
+ };
+ };
+
+ describe("Move Up Button", () => {
+ it("should have a menu option to move element up", () => {
+ const { wrapper } = setup();
+
+ const menuItem = wrapper.findComponent(BoardMenuActionMoveUp);
+
+ expect(menuItem.exists()).toEqual(true);
+ });
+
+ it("should emit the move-up event on click", async () => {
+ const { wrapper } = setup();
+
+ const menuItem = wrapper.findComponent(BoardMenuActionMoveUp);
+
+ await menuItem.trigger("click");
+
+ expect(wrapper.emitted("move-up:element")).toBeDefined();
+ });
+ });
+
+ describe("Move Down Button", () => {
+ it("should have a menu option to move element down", () => {
+ const { wrapper } = setup();
+
+ const menuItem = wrapper.findComponent(BoardMenuActionMoveDown);
+
+ expect(menuItem.exists()).toEqual(true);
+ });
+
+ it("should emit the move-down event on click", async () => {
+ const { wrapper } = setup();
+
+ const menuItem = wrapper.findComponent(BoardMenuActionMoveDown);
+
+ await menuItem.trigger("click");
+
+ expect(wrapper.emitted("move-down:element")).toBeDefined();
+ });
+ });
+
+ describe("Delete Button", () => {
+ it("should have a menu option to delete", () => {
+ const { wrapper } = setup();
+
+ const menuItem = wrapper.findComponent(BoardMenuActionDelete);
+
+ expect(menuItem.exists()).toEqual(true);
+ });
+
+ it("should emit the delete event on click", async () => {
+ const { wrapper } = setup();
+
+ const menuItem = wrapper.findComponent(BoardMenuActionDelete);
+
+ await menuItem.trigger("click");
+
+ expect(wrapper.emitted("delete:element")).toBeDefined();
+ });
+ });
+});
diff --git a/src/modules/feature/board-collaborative-text-editor-element/components/CollaborativeTextEditorElementMenu.vue b/src/modules/feature/board-collaborative-text-editor-element/components/CollaborativeTextEditorElementMenu.vue
new file mode 100644
index 0000000000..f0f36e250b
--- /dev/null
+++ b/src/modules/feature/board-collaborative-text-editor-element/components/CollaborativeTextEditorElementMenu.vue
@@ -0,0 +1,45 @@
+
+
+
+
+
+
+
+
+
diff --git a/src/modules/feature/board-collaborative-text-editor-element/composables/CollaborativeTextEditorApi.composable.ts b/src/modules/feature/board-collaborative-text-editor-element/composables/CollaborativeTextEditorApi.composable.ts
new file mode 100644
index 0000000000..3691642fc0
--- /dev/null
+++ b/src/modules/feature/board-collaborative-text-editor-element/composables/CollaborativeTextEditorApi.composable.ts
@@ -0,0 +1,67 @@
+import {
+ CollaborativeTextEditorApiFactory,
+ CollaborativeTextEditorApiInterface,
+ CollaborativeTextEditorParentType,
+} from "@/serverApi/v3";
+import { $axios, mapAxiosErrorToResponseError } from "@/utils/api";
+import { createTestableGlobaleState } from "@/utils/create-global-state";
+import { useCollaborativeTextEditorNotifier } from "./CollaborativeTextEditorNotifications.composable";
+
+export enum ErrorType {
+ Unauthorized = "Unauthorized",
+ Forbidden = "Forbidden",
+}
+
+const collaborativeTextEditorApi = () => {
+ const collaborativeTextEditorApi: CollaborativeTextEditorApiInterface =
+ CollaborativeTextEditorApiFactory(undefined, "/v3", $axios);
+
+ const { showForbiddenError, showUnauthorizedError, showInternalServerError } =
+ useCollaborativeTextEditorNotifier();
+
+ const getUrl = async (
+ parentId: string,
+ parentType: CollaborativeTextEditorParentType
+ ): Promise => {
+ try {
+ const response =
+ await collaborativeTextEditorApi.collaborativeTextEditorControllerGetOrCreateCollaborativeTextEditorForParent(
+ parentId,
+ parentType
+ );
+
+ return response.data.url;
+ } catch (error) {
+ showError(error);
+ }
+ };
+
+ const showError = (error: unknown) => {
+ const responseError = mapAxiosErrorToResponseError(error);
+ const { message } = responseError;
+
+ showMessageByType(message);
+ };
+
+ const showMessageByType = (message: ErrorType | string) => {
+ switch (message) {
+ case ErrorType.Unauthorized:
+ showUnauthorizedError();
+ break;
+ case ErrorType.Forbidden:
+ showForbiddenError();
+ break;
+ default:
+ showInternalServerError();
+ break;
+ }
+ };
+
+ return {
+ getUrl,
+ };
+};
+
+export const useCollaborativeTextEditorApi = createTestableGlobaleState(
+ collaborativeTextEditorApi
+);
diff --git a/src/modules/feature/board-collaborative-text-editor-element/composables/CollaborativeTextEditorApi.composable.unit.ts b/src/modules/feature/board-collaborative-text-editor-element/composables/CollaborativeTextEditorApi.composable.unit.ts
new file mode 100644
index 0000000000..c36d589069
--- /dev/null
+++ b/src/modules/feature/board-collaborative-text-editor-element/composables/CollaborativeTextEditorApi.composable.unit.ts
@@ -0,0 +1,177 @@
+import * as serverApi from "@/serverApi/v3/api";
+import { mapAxiosErrorToResponseError } from "@/utils/api";
+import {
+ apiResponseErrorFactory,
+ axiosErrorFactory,
+} from "@@/tests/test-utils";
+import { ObjectIdMock } from "@@/tests/test-utils/ObjectIdMock";
+import { createMock } from "@golevelup/ts-jest";
+import { AxiosResponse } from "axios";
+import { setupCollaborativeTextEditorNotifier } from "../test-utils/collaborativeTextEditorNotifier";
+import {
+ ErrorType,
+ useCollaborativeTextEditorApi,
+} from "./CollaborativeTextEditorApi.composable";
+
+jest.mock("./CollaborativeTextEditorNotifications.composable");
+
+jest.mock("@/utils/api");
+const mockedMapAxiosErrorToResponseError = jest.mocked(
+ mapAxiosErrorToResponseError
+);
+
+jest.mock(
+ "@/utils/create-global-state",
+ () => ({
+ createTestableGlobaleState: (composable) => composable,
+ })
+);
+
+const setupErrorResponse = (message = "NOT_FOUND", code = 404) => {
+ const expectedPayload = apiResponseErrorFactory.build({
+ message,
+ code,
+ });
+ const responseError = axiosErrorFactory.build({
+ response: { data: expectedPayload },
+ });
+
+ return {
+ responseError,
+ expectedPayload,
+ };
+};
+
+describe("CollaborativeTextEditorApi Composable", () => {
+ afterEach(() => {
+ jest.resetAllMocks();
+ });
+
+ describe("getUrl", () => {
+ describe("when collaborativeTextEditorControllerGetOrCreateCollaborativeTextEditorForParent returns successful", () => {
+ const setup = () => {
+ const parentId = ObjectIdMock();
+ const parentType =
+ serverApi.CollaborativeTextEditorParentType.ContentElement;
+
+ const response = createMock<
+ AxiosResponse
+ >({
+ data: { url: `${parentType}/${parentId}` },
+ });
+
+ const collaborativeTextEditorApi =
+ createMock();
+ jest
+ .spyOn(serverApi, "CollaborativeTextEditorApiFactory")
+ .mockReturnValue(collaborativeTextEditorApi);
+ collaborativeTextEditorApi.collaborativeTextEditorControllerGetOrCreateCollaborativeTextEditorForParent.mockResolvedValueOnce(
+ response
+ );
+
+ setupCollaborativeTextEditorNotifier();
+
+ return {
+ parentId,
+ parentType,
+ response,
+ collaborativeTextEditorApi,
+ };
+ };
+
+ it("should call collaborativeTextEditorApi.collaborativeTextEditorControllerGetOrCreateCollaborativeTextEditorForParent", async () => {
+ const { parentId, parentType, collaborativeTextEditorApi } = setup();
+ const { getUrl } = useCollaborativeTextEditorApi();
+
+ await getUrl(parentId, parentType);
+
+ expect(
+ collaborativeTextEditorApi.collaborativeTextEditorControllerGetOrCreateCollaborativeTextEditorForParent
+ ).toHaveBeenCalledTimes(1);
+ expect(
+ collaborativeTextEditorApi.collaborativeTextEditorControllerGetOrCreateCollaborativeTextEditorForParent
+ ).toHaveBeenCalledWith(parentId, parentType);
+ });
+
+ it("should return url", async () => {
+ const { parentId, parentType, response } = setup();
+ const { getUrl } = useCollaborativeTextEditorApi();
+
+ const url = await getUrl(parentId, parentType);
+
+ expect(url).toEqual(response.data.url);
+ });
+ });
+
+ describe("when collaborativeTextEditorControllerGetOrCreateCollaborativeTextEditorForParent returns error", () => {
+ const setup = (message?: string) => {
+ const parentId = ObjectIdMock();
+ const parentType =
+ serverApi.CollaborativeTextEditorParentType.ContentElement;
+
+ const { responseError, expectedPayload } = setupErrorResponse(message);
+ mockedMapAxiosErrorToResponseError.mockReturnValueOnce(expectedPayload);
+
+ const collaborativeTextEditorApi =
+ createMock();
+ jest
+ .spyOn(serverApi, "CollaborativeTextEditorApiFactory")
+ .mockReturnValue(collaborativeTextEditorApi);
+ collaborativeTextEditorApi.collaborativeTextEditorControllerGetOrCreateCollaborativeTextEditorForParent.mockRejectedValue(
+ responseError
+ );
+
+ const {
+ showInternalServerError,
+ showUnauthorizedError,
+ showForbiddenError,
+ } = setupCollaborativeTextEditorNotifier();
+
+ return {
+ parentId,
+ parentType,
+ showInternalServerError,
+ showUnauthorizedError,
+ showForbiddenError,
+ };
+ };
+
+ it("should call showUnauthorizedError and pass error", async () => {
+ const { parentId, parentType, showUnauthorizedError } = setup(
+ ErrorType.Unauthorized
+ );
+
+ const { getUrl } = useCollaborativeTextEditorApi();
+
+ const result = await getUrl(parentId, parentType);
+
+ expect(result).toBeUndefined();
+ expect(showUnauthorizedError).toBeCalledTimes(1);
+ });
+
+ it("should call showForbiddenError and pass error", async () => {
+ const { parentId, parentType, showForbiddenError } = setup(
+ ErrorType.Forbidden
+ );
+
+ const { getUrl } = useCollaborativeTextEditorApi();
+
+ const result = await getUrl(parentId, parentType);
+
+ expect(result).toBeUndefined();
+ expect(showForbiddenError).toBeCalledTimes(1);
+ });
+
+ it("should call showInternalServerError and pass error", async () => {
+ const { parentId, parentType, showInternalServerError } = setup();
+
+ const { getUrl } = useCollaborativeTextEditorApi();
+
+ const result = await getUrl(parentId, parentType);
+
+ expect(result).toBeUndefined();
+ expect(showInternalServerError).toBeCalledTimes(1);
+ });
+ });
+ });
+});
diff --git a/src/modules/feature/board-collaborative-text-editor-element/composables/CollaborativeTextEditorNotifications.composable.ts b/src/modules/feature/board-collaborative-text-editor-element/composables/CollaborativeTextEditorNotifications.composable.ts
new file mode 100644
index 0000000000..fe3c64e0cd
--- /dev/null
+++ b/src/modules/feature/board-collaborative-text-editor-element/composables/CollaborativeTextEditorNotifications.composable.ts
@@ -0,0 +1,41 @@
+import { injectStrict, NOTIFIER_MODULE_KEY } from "@/utils/inject";
+import { useI18n } from "vue-i18n";
+
+export const useCollaborativeTextEditorNotifier = () => {
+ const { t } = useI18n();
+ const notifierModule = injectStrict(NOTIFIER_MODULE_KEY);
+
+ const showFailure = (text: string | undefined) => {
+ notifierModule.show({
+ text,
+ status: "error",
+ timeout: 5000,
+ });
+ };
+
+ const showForbiddenError = () => {
+ const message = t("error.403");
+
+ showFailure(message);
+ };
+
+ const showUnauthorizedError = () => {
+ const message = t("error.401");
+
+ showFailure(message);
+ };
+
+ const showInternalServerError = () => {
+ const message = t("components.board.notifications.errors.notCreated", {
+ type: t("components.cardElement.collaborativeTextEditorElement"),
+ });
+
+ showFailure(message);
+ };
+
+ return {
+ showForbiddenError,
+ showUnauthorizedError,
+ showInternalServerError,
+ };
+};
diff --git a/src/modules/feature/board-collaborative-text-editor-element/composables/CollaborativeTextEditorNotifications.composable.unit.ts b/src/modules/feature/board-collaborative-text-editor-element/composables/CollaborativeTextEditorNotifications.composable.unit.ts
new file mode 100644
index 0000000000..fab8bf3616
--- /dev/null
+++ b/src/modules/feature/board-collaborative-text-editor-element/composables/CollaborativeTextEditorNotifications.composable.unit.ts
@@ -0,0 +1,134 @@
+import NotifierModule from "@/store/notifier";
+import { NOTIFIER_MODULE_KEY } from "@/utils/inject";
+import { createModuleMocks } from "@/utils/mock-store-module";
+import { mountComposable } from "@@/tests/test-utils/mountComposable";
+import { useI18n } from "vue-i18n";
+import { useCollaborativeTextEditorNotifier } from "./CollaborativeTextEditorNotifications.composable";
+
+jest.mock("vue-i18n", () => {
+ return {
+ ...jest.requireActual("vue-i18n"),
+ useI18n: jest.fn().mockReturnValue({
+ t: jest
+ .fn()
+ .mockImplementation(
+ (key: string, dynamic?: object): string =>
+ key + (dynamic ? ` ${JSON.stringify(dynamic)}` : "")
+ ),
+ n: jest.fn().mockImplementation((key: string) => key),
+ }),
+ };
+});
+
+const mockI18nModule = jest.mocked(useI18n());
+
+const notifierModule = createModuleMocks(NotifierModule);
+
+const setupMountComposable = () => {
+ return mountComposable(() => useCollaborativeTextEditorNotifier(), {
+ global: {
+ provide: {
+ [NOTIFIER_MODULE_KEY as symbol]: notifierModule,
+ },
+ },
+ });
+};
+
+describe("CollaborativeTextEditorNotifications.composable", () => {
+ beforeEach(() => {
+ jest.clearAllMocks();
+ });
+
+ describe("when showForbiddenError called", () => {
+ const setup = () => {
+ const i18nKey = "error.403";
+
+ return { i18nKey };
+ };
+
+ it("should call i18n.t with correct props", () => {
+ const { showForbiddenError } = setupMountComposable();
+ const { i18nKey } = setup();
+
+ showForbiddenError();
+
+ expect(mockI18nModule.t).toBeCalledWith(i18nKey);
+ });
+
+ it("should call showFailure with correct props", () => {
+ const { showForbiddenError } = setupMountComposable();
+ const { i18nKey } = setup();
+
+ showForbiddenError();
+
+ expect(notifierModule.show).toBeCalledWith({
+ status: "error",
+ text: i18nKey,
+ timeout: 5000,
+ });
+ });
+ });
+
+ describe("when showUnauthorizedError called", () => {
+ const setup = () => {
+ const i18nKey = "error.401";
+
+ return { i18nKey };
+ };
+
+ it("should call i18n.t with correct props", () => {
+ const { showUnauthorizedError } = setupMountComposable();
+ const { i18nKey } = setup();
+
+ showUnauthorizedError();
+
+ expect(mockI18nModule.t).toBeCalledWith(i18nKey);
+ });
+
+ it("should call showFailure with correct props", () => {
+ const { showUnauthorizedError } = setupMountComposable();
+ const { i18nKey } = setup();
+
+ showUnauthorizedError();
+
+ expect(notifierModule.show).toBeCalledWith({
+ status: "error",
+ text: i18nKey,
+ timeout: 5000,
+ });
+ });
+ });
+
+ describe("when showInternalServerError called", () => {
+ const setup = () => {
+ const i18nKey = "components.board.notifications.errors.notCreated";
+ const type = "components.cardElement.collaborativeTextEditorElement";
+
+ return { i18nKey, type };
+ };
+
+ it("should call i18n.t with correct props", () => {
+ const { showInternalServerError } = setupMountComposable();
+ const { i18nKey, type } = setup();
+
+ showInternalServerError();
+
+ expect(mockI18nModule.t).toBeCalledWith(i18nKey, { type });
+ });
+
+ it("should call showFailure with correct props", () => {
+ const { showInternalServerError } = setupMountComposable();
+ const { i18nKey, type } = setup();
+
+ const text = i18nKey + ` ${JSON.stringify({ type })}`;
+
+ showInternalServerError();
+
+ expect(notifierModule.show).toBeCalledWith({
+ status: "error",
+ text,
+ timeout: 5000,
+ });
+ });
+ });
+});
diff --git a/src/modules/feature/board-collaborative-text-editor-element/index.ts b/src/modules/feature/board-collaborative-text-editor-element/index.ts
new file mode 100644
index 0000000000..0c3fd36d43
--- /dev/null
+++ b/src/modules/feature/board-collaborative-text-editor-element/index.ts
@@ -0,0 +1,3 @@
+import CollaborativeTextEditorElement from "./CollaborativeTextEditorElement.vue";
+
+export { CollaborativeTextEditorElement };
diff --git a/src/modules/feature/board-collaborative-text-editor-element/test-utils/collaborativeTextEditorApiMock.ts b/src/modules/feature/board-collaborative-text-editor-element/test-utils/collaborativeTextEditorApiMock.ts
new file mode 100644
index 0000000000..15979a9307
--- /dev/null
+++ b/src/modules/feature/board-collaborative-text-editor-element/test-utils/collaborativeTextEditorApiMock.ts
@@ -0,0 +1,23 @@
+import { jest } from "@jest/globals";
+import { useCollaborativeTextEditorApi } from "../composables/CollaborativeTextEditorApi.composable";
+
+interface Props {
+ getUrlMock?: jest.Mock;
+}
+
+export const setupCollaborativeTextEditorApiMock = (props: Props = {}) => {
+ const { getUrlMock } = props;
+ const mockedCollaborativeTextEditorApi = jest.mocked(
+ useCollaborativeTextEditorApi
+ );
+
+ const getUrl = getUrlMock ?? jest.fn();
+
+ const mocks = {
+ getUrl,
+ };
+
+ mockedCollaborativeTextEditorApi.mockReturnValue(mocks);
+
+ return mocks;
+};
diff --git a/src/modules/feature/board-collaborative-text-editor-element/test-utils/collaborativeTextEditorNotifier.ts b/src/modules/feature/board-collaborative-text-editor-element/test-utils/collaborativeTextEditorNotifier.ts
new file mode 100644
index 0000000000..a19b01e21c
--- /dev/null
+++ b/src/modules/feature/board-collaborative-text-editor-element/test-utils/collaborativeTextEditorNotifier.ts
@@ -0,0 +1,32 @@
+import { jest } from "@jest/globals";
+import { useCollaborativeTextEditorNotifier } from "../composables/CollaborativeTextEditorNotifications.composable";
+
+interface Props {
+ showForbiddenErrorMock?: () => void;
+ showUnauthorizedErrorMock?: () => void;
+ showInternalServerErrorMock?: () => void;
+}
+
+export const setupCollaborativeTextEditorNotifier = (props: Props = {}) => {
+ const {
+ showForbiddenErrorMock,
+ showUnauthorizedErrorMock,
+ showInternalServerErrorMock,
+ } = props;
+
+ const mockedSelectedFile = jest.mocked(useCollaborativeTextEditorNotifier);
+
+ const showForbiddenError = showForbiddenErrorMock ?? jest.fn();
+ const showUnauthorizedError = showUnauthorizedErrorMock ?? jest.fn();
+ const showInternalServerError = showInternalServerErrorMock ?? jest.fn();
+
+ const mocks = {
+ showForbiddenError,
+ showUnauthorizedError,
+ showInternalServerError,
+ };
+
+ mockedSelectedFile.mockReturnValue(mocks);
+
+ return mocks;
+};
diff --git a/src/modules/feature/board/card/ContentElementList.unit.ts b/src/modules/feature/board/card/ContentElementList.unit.ts
index 56c29b528c..c29a163801 100644
--- a/src/modules/feature/board/card/ContentElementList.unit.ts
+++ b/src/modules/feature/board/card/ContentElementList.unit.ts
@@ -1,9 +1,14 @@
import { ContentElementType } from "@/serverApi/v3";
-import EnvConfigModule from "@/store/env-config";
import { ConfigResponse } from "@/serverApi/v3/api";
+import EnvConfigModule from "@/store/env-config";
import { AnyContentElement } from "@/types/board/ContentElement";
import { ENV_CONFIG_MODULE_KEY } from "@/utils/inject";
import { createModuleMocks } from "@/utils/mock-store-module";
+import {
+ createTestingI18n,
+ createTestingVuetify,
+} from "@@/tests/test-utils/setup";
+import { CollaborativeTextEditorElement } from "@feature-board-collaborative-text-editor-element";
import { DrawingContentElement } from "@feature-board-drawing-element";
import { ExternalToolElement } from "@feature-board-external-tool-element";
import { FileContentElement } from "@feature-board-file-element";
@@ -13,10 +18,6 @@ import { RichTextContentElement } from "@feature-board-text-element";
import { createMock } from "@golevelup/ts-jest";
import { shallowMount } from "@vue/test-utils";
import ContentElementList from "./ContentElementList.vue";
-import {
- createTestingI18n,
- createTestingVuetify,
-} from "@@/tests/test-utils/setup";
describe("ContentElementList", () => {
const setup = (props: {
@@ -83,6 +84,10 @@ describe("ContentElementList", () => {
elementType: ContentElementType.Drawing,
component: DrawingContentElement,
},
+ {
+ elementType: ContentElementType.CollaborativeTextEditor,
+ component: CollaborativeTextEditorElement,
+ },
];
it.each(elementComponents)(
diff --git a/src/modules/feature/board/card/ContentElementList.vue b/src/modules/feature/board/card/ContentElementList.vue
index 950ad51ed8..289b8e17e1 100644
--- a/src/modules/feature/board/card/ContentElementList.vue
+++ b/src/modules/feature/board/card/ContentElementList.vue
@@ -61,6 +61,18 @@
@move-up:edit="onMoveElementUp(index, element)"
@delete:element="onDeleteElement"
/>
+
@@ -68,23 +80,25 @@