From 0341a18e2b2c10b5658fbb8f63af913c0eb97e81 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marvin=20=C3=96hlerking?= <103562092+MarvinOehlerkingCap@users.noreply.github.com> Date: Thu, 19 Oct 2023 10:04:45 +0200 Subject: [PATCH] N21-1248 Configure External Tools in Boards (#2860) - add configuration dialog for tools in boards --- .../data-board/BoardApi.composable.ts | 15 +- .../data-board/BoardApi.composable.unit.ts | 7 +- .../ContextExternalToolApi.composable.ts | 31 ++ .../ContextExternalToolApi.composable.unit.ts | 70 ++++ ...ernalToolElementDisplayState.composable.ts | 31 ++ ...ToolElementDisplayState.composable.unit.ts | 96 +++++ src/components/data-external-tool/index.ts | 2 + .../ExternalToolConfigurator.vue | 9 +- .../ExternalToolElement.unit.ts | 268 +++++++++--- .../ExternalToolElement.vue | 124 ++++-- ...rnalToolElementConfigurationDialog.unit.ts | 380 ++++++++++++++++++ ...ExternalToolElementConfigurationDialog.vue | 222 ++++++++++ .../feature-board/card/CardHost.vue | 2 +- .../card/ContentElementList.unit.ts | 4 + .../feature-board/card/ContentElementList.vue | 4 +- src/locales/de.json | 1 + src/locales/en.json | 3 +- src/locales/es.json | 3 +- src/locales/uk.json | 3 +- ...ntextExternalToolConfigurator.page.unit.ts | 26 +- .../ContextExternalToolConfigurator.page.vue | 30 +- .../rooms/tools/RoomExternalToolsOverview.vue | 8 +- .../tools/RoomExternalToolsSection.unit.ts | 36 +- .../rooms/tools/RoomExternalToolsSection.vue | 20 +- src/router/routes.ts | 2 +- src/serverApi/v3/api.ts | 302 +++++++++----- src/store/context-external-tools.ts | 65 ++- src/store/context-external-tools.unit.ts | 110 ++++- .../external-tool/context-external-tool.ts | 2 +- src/store/external-tool/index.ts | 1 - .../mapper/common-tool.mapper.ts | 12 +- .../mapper/context-external-tool.mapper.ts | 6 +- .../context-external-tool.mapper.unit.ts | 8 +- .../mapper/external-tool.mapper.ts | 28 +- .../external-tool/tool-context-type.enum.ts | 4 - .../factory/contextExternalToolFactory.ts | 6 +- .../factory/contextExternalToolSaveFactory.ts | 4 +- tests/test-utils/factory/index.ts | 1 + .../factory/toolReferenceResponseFactory.ts | 4 +- tsconfig.json | 1 + vue.config.js | 1 + 41 files changed, 1593 insertions(+), 359 deletions(-) create mode 100644 src/components/data-external-tool/ContextExternalToolApi.composable.ts create mode 100644 src/components/data-external-tool/ContextExternalToolApi.composable.unit.ts create mode 100644 src/components/data-external-tool/ExternalToolElementDisplayState.composable.ts create mode 100644 src/components/data-external-tool/ExternalToolElementDisplayState.composable.unit.ts create mode 100644 src/components/data-external-tool/index.ts create mode 100644 src/components/feature-board-external-tool-element/ExternalToolElementConfigurationDialog.unit.ts create mode 100644 src/components/feature-board-external-tool-element/ExternalToolElementConfigurationDialog.vue delete mode 100644 src/store/external-tool/tool-context-type.enum.ts diff --git a/src/components/data-board/BoardApi.composable.ts b/src/components/data-board/BoardApi.composable.ts index 4b9be20d65..a6b93c4f1b 100644 --- a/src/components/data-board/BoardApi.composable.ts +++ b/src/components/data-board/BoardApi.composable.ts @@ -9,7 +9,7 @@ import { ContentElementType, CreateCardBodyParamsRequiredEmptyElementsEnum, CreateContentElementBodyParams, - ExternalToolElementContent, + ExternalToolElementResponse, FileElementContent, LinkElementContent, RichTextElementContent, @@ -92,9 +92,12 @@ export const useBoardApi = () => { }; } - if (element.type === ContentElementType.ExternalTool) { + if (isExternalToolElement(element)) { return { - content: element.content as ExternalToolElementContent, + content: { + contextExternalToolId: + element.content.contextExternalToolId ?? undefined, + }, type: ContentElementType.ExternalTool, }; } @@ -109,6 +112,12 @@ export const useBoardApi = () => { throw new Error("element.type mapping is undefined for updateElementCall"); }; + const isExternalToolElement = ( + element: AnyContentElement + ): element is ExternalToolElementResponse => { + return element.type === ContentElementType.ExternalTool; + }; + const createElementCall = async ( cardId: string, params: CreateContentElementBodyParams diff --git a/src/components/data-board/BoardApi.composable.unit.ts b/src/components/data-board/BoardApi.composable.unit.ts index 3edc48d1ff..a46025e86a 100644 --- a/src/components/data-board/BoardApi.composable.unit.ts +++ b/src/components/data-board/BoardApi.composable.unit.ts @@ -1,4 +1,7 @@ -import { ContentElementType } from "@/serverApi/v3"; +import { + ContentElementType, + ExternalToolElementResponse, +} from "@/serverApi/v3"; import * as serverApi from "@/serverApi/v3/api"; import { CardResponse } from "@/serverApi/v3/api"; import { ApplicationError } from "@/store/types/application-error"; @@ -187,7 +190,7 @@ describe("BoardApi.composable", () => { it("should call elementControllerUpdateElement api with ExternalToolElement", async () => { const { updateElementCall } = useBoardApi(); - const payload = { + const payload: ExternalToolElementResponse = { id: "external-tool-element-id", type: ContentElementType.ExternalTool, content: { diff --git a/src/components/data-external-tool/ContextExternalToolApi.composable.ts b/src/components/data-external-tool/ContextExternalToolApi.composable.ts new file mode 100644 index 0000000000..b3caf443a3 --- /dev/null +++ b/src/components/data-external-tool/ContextExternalToolApi.composable.ts @@ -0,0 +1,31 @@ +import { + ToolApiFactory, + ToolApiInterface, + ToolReferenceResponse, +} from "@/serverApi/v3"; +import { ExternalToolDisplayData } from "@/store/external-tool"; +import { ExternalToolMapper } from "@/store/external-tool/mapper"; +import { $axios } from "@/utils/api"; +import { AxiosResponse } from "axios"; + +export const useContextExternalToolApi = () => { + const toolApi: ToolApiInterface = ToolApiFactory(undefined, "/v3", $axios); + + const fetchDisplayDataCall = async ( + contextExternalToolId: string + ): Promise => { + const response: AxiosResponse = + await toolApi.toolReferenceControllerGetToolReference( + contextExternalToolId + ); + + const mapped: ExternalToolDisplayData = + ExternalToolMapper.mapToExternalToolDisplayData(response.data); + + return mapped; + }; + + return { + fetchDisplayDataCall, + }; +}; diff --git a/src/components/data-external-tool/ContextExternalToolApi.composable.unit.ts b/src/components/data-external-tool/ContextExternalToolApi.composable.unit.ts new file mode 100644 index 0000000000..6001dd377c --- /dev/null +++ b/src/components/data-external-tool/ContextExternalToolApi.composable.unit.ts @@ -0,0 +1,70 @@ +import * as serverApi from "@/serverApi/v3/api"; +import { ToolReferenceResponse } from "@/serverApi/v3/api"; +import { + ExternalToolDisplayData, + ToolConfigurationStatus, +} from "@/store/external-tool"; +import { + mockApiResponse, + toolReferenceResponseFactory, +} from "@@/tests/test-utils"; +import { createMock, DeepMocked } from "@golevelup/ts-jest"; +import { useContextExternalToolApi } from "./ContextExternalToolApi.composable"; + +describe("ContextExternalToolApi.composable", () => { + let toolApi: DeepMocked; + + beforeEach(() => { + toolApi = createMock(); + + jest.spyOn(serverApi, "ToolApiFactory").mockReturnValue(toolApi); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + describe("fetchDisplayDataCall", () => { + const setup = () => { + const displayData: ToolReferenceResponse = + toolReferenceResponseFactory.build({ logoUrl: "mockLogoUrl" }); + + toolApi.toolReferenceControllerGetToolReference.mockResolvedValue( + mockApiResponse({ data: displayData }) + ); + + return { + displayData, + }; + }; + + it("should call the api for tool references", async () => { + setup(); + + await useContextExternalToolApi().fetchDisplayDataCall( + "contextExternalToolId" + ); + + expect( + toolApi.toolReferenceControllerGetToolReference + ).toHaveBeenCalledWith("contextExternalToolId"); + }); + + it("should return an array of display data", async () => { + const { displayData } = setup(); + + const result: ExternalToolDisplayData = + await useContextExternalToolApi().fetchDisplayDataCall( + "contextExternalToolId" + ); + + expect(result).toEqual({ + contextExternalToolId: displayData.contextToolId, + name: displayData.displayName, + logoUrl: displayData.logoUrl, + status: ToolConfigurationStatus.Latest, + openInNewTab: displayData.openInNewTab, + }); + }); + }); +}); diff --git a/src/components/data-external-tool/ExternalToolElementDisplayState.composable.ts b/src/components/data-external-tool/ExternalToolElementDisplayState.composable.ts new file mode 100644 index 0000000000..c96fb9423c --- /dev/null +++ b/src/components/data-external-tool/ExternalToolElementDisplayState.composable.ts @@ -0,0 +1,31 @@ +import { ref, Ref } from "vue"; +import { ExternalToolDisplayData } from "../../store/external-tool"; +import { useErrorHandler } from "../error-handling/ErrorHandler.composable"; +import { useContextExternalToolApi } from "./index"; + +export const useExternalToolElementDisplayState = () => { + const { handleError } = useErrorHandler(); + const { fetchDisplayDataCall } = useContextExternalToolApi(); + const displayData: Ref = ref(); + const isLoading: Ref = ref(false); + + const fetchDisplayData = async ( + contextExternalToolId: string + ): Promise => { + isLoading.value = true; + + try { + displayData.value = await fetchDisplayDataCall(contextExternalToolId); + } catch (error) { + handleError(error); + } + + isLoading.value = false; + }; + + return { + isLoading, + displayData, + fetchDisplayData, + }; +}; diff --git a/src/components/data-external-tool/ExternalToolElementDisplayState.composable.unit.ts b/src/components/data-external-tool/ExternalToolElementDisplayState.composable.unit.ts new file mode 100644 index 0000000000..1fe5ff37ed --- /dev/null +++ b/src/components/data-external-tool/ExternalToolElementDisplayState.composable.unit.ts @@ -0,0 +1,96 @@ +import { ExternalToolDisplayData } from "@/store/external-tool"; +import { externalToolDisplayDataFactory } from "@@/tests/test-utils"; +import { createMock, DeepMocked } from "@golevelup/ts-jest"; +import { useErrorHandler } from "../error-handling/ErrorHandler.composable"; +import { useExternalToolElementDisplayState } from "./ExternalToolElementDisplayState.composable"; +import { useContextExternalToolApi } from "./index"; + +jest.mock("@data-external-tool"); +jest.mock("@/components/error-handling/ErrorHandler.composable"); + +describe("ExternalToolElementDisplayState.composable", () => { + let useContextExternalToolApiMock: DeepMocked< + ReturnType + >; + let useErrorHandlerMock: DeepMocked>; + + beforeEach(() => { + useContextExternalToolApiMock = + createMock>(); + useErrorHandlerMock = createMock>(); + + jest + .mocked(useContextExternalToolApi) + .mockReturnValue(useContextExternalToolApiMock); + jest.mocked(useErrorHandler).mockReturnValue(useErrorHandlerMock); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + describe("when no data is loaded", () => { + it("should not have data", async () => { + const { displayData } = useExternalToolElementDisplayState(); + + expect(displayData.value).toBeUndefined(); + }); + }); + + describe("when data is loaded", () => { + const setup = () => { + const displayDataMock: ExternalToolDisplayData = + externalToolDisplayDataFactory.build(); + + useContextExternalToolApiMock.fetchDisplayDataCall.mockResolvedValue( + displayDataMock + ); + + return { + displayDataMock, + ...useExternalToolElementDisplayState(), + }; + }; + + it("should call the api for display data of the card", async () => { + const { fetchDisplayData } = setup(); + + await fetchDisplayData("contextId"); + + expect( + useContextExternalToolApiMock.fetchDisplayDataCall + ).toHaveBeenCalledWith("contextId"); + }); + + it("should set the display data in the state", async () => { + const { fetchDisplayData, displayData, displayDataMock } = setup(); + + await fetchDisplayData("contextId"); + + expect(displayData.value).toEqual(displayDataMock); + }); + }); + + describe("when an error occurs during loading", () => { + const setup = () => { + const error = new Error("unable to load"); + + useContextExternalToolApiMock.fetchDisplayDataCall.mockRejectedValue( + error + ); + + return { + error, + ...useExternalToolElementDisplayState(), + }; + }; + + it("should handle the error", async () => { + const { fetchDisplayData, error } = setup(); + + await fetchDisplayData("contextId"); + + expect(useErrorHandlerMock.handleError).toHaveBeenCalledWith(error); + }); + }); +}); diff --git a/src/components/data-external-tool/index.ts b/src/components/data-external-tool/index.ts new file mode 100644 index 0000000000..f64b0c1f8f --- /dev/null +++ b/src/components/data-external-tool/index.ts @@ -0,0 +1,2 @@ +export * from "./ContextExternalToolApi.composable"; +export * from "./ExternalToolElementDisplayState.composable"; diff --git a/src/components/external-tools/configuration/ExternalToolConfigurator.vue b/src/components/external-tools/configuration/ExternalToolConfigurator.vue index e66283404f..7f09314e6d 100644 --- a/src/components/external-tools/configuration/ExternalToolConfigurator.vue +++ b/src/components/external-tools/configuration/ExternalToolConfigurator.vue @@ -29,6 +29,7 @@

import ExternalToolConfigSettings from "@/components/external-tools/configuration/ExternalToolConfigSettings.vue"; import { useExternalToolMappings } from "@/composables/external-tool-mappings.composable"; +import { useI18n } from "@/composables/i18n.composable"; import { ExternalToolConfigurationTemplate, SchoolExternalTool, } from "@/store/external-tool"; +import { ContextExternalTool } from "@/store/external-tool/context-external-tool"; import { BusinessError } from "@/store/types/commons"; import { computed, @@ -91,9 +94,7 @@ import { useSlots, watch, } from "vue"; -import { useI18n } from "@/composables/i18n.composable"; import ExternalToolSelectionRow from "./ExternalToolSelectionRow.vue"; -import { ContextExternalTool } from "@/store/external-tool/context-external-tool"; type ConfigurationTypes = SchoolExternalTool | ContextExternalTool; @@ -117,6 +118,10 @@ export default defineComponent({ loading: { type: Boolean, }, + displaySettingsTitle: { + type: Boolean, + default: true, + }, }, setup(props, { emit }) { const { t } = useI18n(); diff --git a/src/components/feature-board-external-tool-element/ExternalToolElement.unit.ts b/src/components/feature-board-external-tool-element/ExternalToolElement.unit.ts index 34a389dd5e..54651ab219 100644 --- a/src/components/feature-board-external-tool-element/ExternalToolElement.unit.ts +++ b/src/components/feature-board-external-tool-element/ExternalToolElement.unit.ts @@ -2,60 +2,88 @@ import { ContentElementType, ExternalToolElementResponse, } from "@/serverApi/v3"; -import ContextExternalToolsModule from "@/store/context-external-tools"; import { ExternalToolDisplayData } from "@/store/external-tool"; -import { CONTEXT_EXTERNAL_TOOLS_MODULE_KEY, I18N_KEY } from "@/utils/inject"; -import { createModuleMocks } from "@/utils/mock-store-module"; +import { ContextExternalTool } from "@/store/external-tool/context-external-tool"; +import { I18N_KEY } from "@/utils/inject"; import { + contextExternalToolFactory, externalToolDisplayDataFactory, i18nMock, timestampsResponseFactory, } from "@@/tests/test-utils"; import createComponentMocks from "@@/tests/test-utils/componentMocks"; -import { createMock } from "@golevelup/ts-jest"; +import { useBoardFocusHandler, useContentElementState } from "@data-board"; +import { useExternalToolElementDisplayState } from "@data-external-tool"; +import { createMock, DeepMocked } from "@golevelup/ts-jest"; import { mdiPuzzleOutline } from "@mdi/js"; import { useDeleteConfirmationDialog } from "@ui-confirmation-dialog"; import { MountOptions, shallowMount, Wrapper } from "@vue/test-utils"; -import Vue from "vue"; +import Vue, { ref } from "vue"; import ExternalToolElement from "./ExternalToolElement.vue"; -jest.mock("@data-board", () => { - return { - useBoardFocusHandler: jest.fn(), - }; -}); +jest.mock("@data-board"); +jest.mock("@data-external-tool"); jest.mock("@ui-confirmation-dialog"); -const TEST_ELEMENT: ExternalToolElementResponse = { +const EMPTY_TEST_ELEMENT: ExternalToolElementResponse = { id: "external-tool-element-id", - content: {}, + content: { + contextExternalToolId: null, + }, type: ContentElementType.ExternalTool, timestamps: timestampsResponseFactory.build(), }; describe("ExternalToolElement", () => { + let useContentElementStateMock: DeepMocked< + ReturnType + >; + let useBoardFocusHandlerMock: DeepMocked< + ReturnType + >; + let useSharedExternalToolElementDisplayStateMock: DeepMocked< + ReturnType + >; + + beforeEach(() => { + useContentElementStateMock = + createMock>(); + useBoardFocusHandlerMock = + createMock>(); + useSharedExternalToolElementDisplayStateMock = + createMock>(); + + jest + .mocked(useContentElementState) + .mockReturnValue(useContentElementStateMock); + jest.mocked(useBoardFocusHandler).mockReturnValue(useBoardFocusHandlerMock); + jest + .mocked(useExternalToolElementDisplayState) + .mockReturnValue(useSharedExternalToolElementDisplayStateMock); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + const getWrapper = ( props: { element: ExternalToolElementResponse; isEditMode: boolean; }, - displayData: ExternalToolDisplayData[] = [] + displayData?: ExternalToolDisplayData ) => { document.body.setAttribute("data-app", "true"); - const contextExternalToolsModule = createModuleMocks( - ContextExternalToolsModule, - { - getExternalToolDisplayDataList: displayData, - } - ); - const useDeleteConfirmationDialogReturnValue = createMock>(); jest .mocked(useDeleteConfirmationDialog) .mockReturnValue(useDeleteConfirmationDialogReturnValue); + useContentElementStateMock.modelValue = ref(props.element.content); + useSharedExternalToolElementDisplayStateMock.displayData = ref(displayData); + const wrapper: Wrapper = shallowMount( ExternalToolElement as MountOptions, { @@ -66,19 +94,20 @@ describe("ExternalToolElement", () => { isFirstElement: false, isLastElement: false, hasMultipleElements: false, + cardId: "cardId", ...props, }, provide: { [I18N_KEY.valueOf()]: i18nMock, - [CONTEXT_EXTERNAL_TOOLS_MODULE_KEY.valueOf()]: - contextExternalToolsModule, + }, + stubs: { + ExternalToolElementConfigurationDialog: true, }, } ); return { wrapper, - contextExternalToolsModule, useDeleteConfirmationDialogReturnValue, }; }; @@ -87,11 +116,42 @@ describe("ExternalToolElement", () => { jest.resetAllMocks(); }); + describe("when the element is mounted", () => { + describe("when the element has a tool attached", () => { + it("should load the display data", () => { + getWrapper({ + element: { + ...EMPTY_TEST_ELEMENT, + content: { contextExternalToolId: "contextExternalToolId" }, + }, + isEditMode: false, + }); + + expect( + useSharedExternalToolElementDisplayStateMock.fetchDisplayData + ).toHaveBeenCalledWith("contextExternalToolId"); + }); + }); + + describe("when the element deos not have a tool attached", () => { + it("should not load the display data", () => { + getWrapper({ + element: EMPTY_TEST_ELEMENT, + isEditMode: false, + }); + + expect( + useSharedExternalToolElementDisplayStateMock.fetchDisplayData + ).not.toHaveBeenCalled(); + }); + }); + }); + describe("when no tool is selected", () => { describe("when not in edit mode", () => { const setup = () => { const { wrapper } = getWrapper({ - element: TEST_ELEMENT, + element: EMPTY_TEST_ELEMENT, isEditMode: false, }); @@ -112,7 +172,7 @@ describe("ExternalToolElement", () => { describe("when in edit mode", () => { const setup = () => { const { wrapper } = getWrapper({ - element: TEST_ELEMENT, + element: EMPTY_TEST_ELEMENT, isEditMode: true, }); @@ -139,17 +199,15 @@ describe("ExternalToolElement", () => { const { wrapper } = getWrapper( { element: { - ...TEST_ELEMENT, + ...EMPTY_TEST_ELEMENT, content: { contextExternalToolId }, }, isEditMode: false, }, - [ - externalToolDisplayDataFactory.build({ - contextExternalToolId, - logoUrl: undefined, - }), - ] + externalToolDisplayDataFactory.build({ + contextExternalToolId, + logoUrl: undefined, + }) ); return { @@ -175,17 +233,15 @@ describe("ExternalToolElement", () => { const { wrapper } = getWrapper( { element: { - ...TEST_ELEMENT, + ...EMPTY_TEST_ELEMENT, content: { contextExternalToolId }, }, isEditMode: false, }, - [ - externalToolDisplayDataFactory.build({ - contextExternalToolId, - logoUrl: "logo-url", - }), - ] + externalToolDisplayDataFactory.build({ + contextExternalToolId, + logoUrl: "logo-url", + }) ); return { @@ -209,7 +265,7 @@ describe("ExternalToolElement", () => { describe("when no tool is selected", () => { const setup = () => { const { wrapper } = getWrapper({ - element: TEST_ELEMENT, + element: EMPTY_TEST_ELEMENT, isEditMode: true, }); @@ -235,16 +291,13 @@ describe("ExternalToolElement", () => { const setup = () => { const contextExternalToolId = "context-external-tool-id"; - const { wrapper } = getWrapper( - { - element: { - ...TEST_ELEMENT, - content: { contextExternalToolId }, - }, - isEditMode: false, + const { wrapper } = getWrapper({ + element: { + ...EMPTY_TEST_ELEMENT, + content: { contextExternalToolId }, }, - [] - ); + isEditMode: false, + }); return { wrapper, @@ -273,12 +326,12 @@ describe("ExternalToolElement", () => { const { wrapper } = getWrapper( { element: { - ...TEST_ELEMENT, + ...EMPTY_TEST_ELEMENT, content: { contextExternalToolId }, }, isEditMode: false, }, - [toolDisplayData] + toolDisplayData ); return { @@ -304,16 +357,15 @@ describe("ExternalToolElement", () => { const setup = () => { const contextExternalToolId = "context-external-tool-id"; - const { wrapper } = getWrapper( - { - element: { - ...TEST_ELEMENT, - content: { contextExternalToolId }, - }, - isEditMode: false, + useSharedExternalToolElementDisplayStateMock.isLoading = ref(true); + + const { wrapper } = getWrapper({ + element: { + ...EMPTY_TEST_ELEMENT, + content: { contextExternalToolId }, }, - [] - ); + isEditMode: false, + }); return { wrapper, @@ -323,9 +375,9 @@ describe("ExternalToolElement", () => { it("should display a loading state", () => { const { wrapper } = setup(); - const title = wrapper.findComponent({ ref: "externalToolElement" }); + const card = wrapper.findComponent({ ref: "externalToolElement" }); - expect(title.attributes("loading")).toEqual("true"); + expect(card.attributes("loading")).toEqual("true"); }); }); @@ -333,15 +385,17 @@ describe("ExternalToolElement", () => { const setup = () => { const contextExternalToolId = "context-external-tool-id"; + useSharedExternalToolElementDisplayStateMock.isLoading = ref(false); + const { wrapper } = getWrapper( { element: { - ...TEST_ELEMENT, + ...EMPTY_TEST_ELEMENT, content: { contextExternalToolId }, }, isEditMode: false, }, - [externalToolDisplayDataFactory.build({ contextExternalToolId })] + externalToolDisplayDataFactory.build({ contextExternalToolId }) ); return { @@ -363,7 +417,7 @@ describe("ExternalToolElement", () => { describe("when in edit mode", () => { const setup = () => { const { wrapper } = getWrapper({ - element: TEST_ELEMENT, + element: EMPTY_TEST_ELEMENT, isEditMode: true, }); @@ -384,7 +438,7 @@ describe("ExternalToolElement", () => { describe("when in display mode", () => { const setup = () => { const { wrapper } = getWrapper({ - element: TEST_ELEMENT, + element: EMPTY_TEST_ELEMENT, isEditMode: false, }); @@ -402,4 +456,86 @@ describe("ExternalToolElement", () => { }); }); }); + + describe("Dialog", () => { + describe("when clicking on a un-configured tool card in edit mode", () => { + const setup = () => { + const { wrapper } = getWrapper({ + element: EMPTY_TEST_ELEMENT, + isEditMode: true, + }); + + return { + wrapper, + }; + }; + + it("should display the configuration dialog", async () => { + const { wrapper } = setup(); + + const card = wrapper.findComponent({ + ref: "externalToolElement", + }); + + card.vm.$emit("click"); + await Vue.nextTick(); + + const dialog = wrapper.find( + '[data-testid="board-external-tool-element-configuration-dialog"]' + ); + + expect(dialog.props("isOpen")).toEqual(true); + }); + }); + + describe("when the dialog is saving a tool", () => { + const setup = () => { + const savedTool: ContextExternalTool = contextExternalToolFactory.build( + { + id: "contextExternalToolId", + } + ); + + const { wrapper } = getWrapper({ + element: EMPTY_TEST_ELEMENT, + isEditMode: true, + }); + + return { + wrapper, + savedTool, + }; + }; + + it("should update the elements content", async () => { + const { wrapper, savedTool } = setup(); + + const dialog = wrapper.find( + '[data-testid="board-external-tool-element-configuration-dialog"]' + ); + + dialog.vm.$emit("save", savedTool); + await Vue.nextTick(); + + expect(useContentElementStateMock.modelValue.value).toEqual({ + contextExternalToolId: savedTool.id, + }); + }); + + it("should fetch the display data", async () => { + const { wrapper, savedTool } = setup(); + + const dialog = wrapper.find( + '[data-testid="board-external-tool-element-configuration-dialog"]' + ); + + dialog.vm.$emit("save", savedTool); + await Vue.nextTick(); + + expect( + useSharedExternalToolElementDisplayStateMock.fetchDisplayData + ).toHaveBeenCalledWith(savedTool.id); + }); + }); + }); }); diff --git a/src/components/feature-board-external-tool-element/ExternalToolElement.vue b/src/components/feature-board-external-tool-element/ExternalToolElement.vue index d1e43626f5..39b7fa00f4 100644 --- a/src/components/feature-board-external-tool-element/ExternalToolElement.vue +++ b/src/components/feature-board-external-tool-element/ExternalToolElement.vue @@ -13,14 +13,20 @@ @keydown.up.down="onKeydownArrow" @click="onClickElement" > -
- +
+
+ +
{{ mdiPuzzleOutline }} - + {{ hasLinkedTool ? toolDisplayName @@ -36,33 +42,43 @@ @edit:element="onEditElement" >
+ + diff --git a/src/components/feature-board-external-tool-element/ExternalToolElementConfigurationDialog.unit.ts b/src/components/feature-board-external-tool-element/ExternalToolElementConfigurationDialog.unit.ts new file mode 100644 index 0000000000..4e5acefa34 --- /dev/null +++ b/src/components/feature-board-external-tool-element/ExternalToolElementConfigurationDialog.unit.ts @@ -0,0 +1,380 @@ +import { ToolContextType } from "@/serverApi/v3"; +import ContextExternalToolsModule from "@/store/context-external-tools"; +import { ContextExternalToolSave } from "@/store/external-tool/context-external-tool"; +import { CONTEXT_EXTERNAL_TOOLS_MODULE_KEY, I18N_KEY } from "@/utils/inject"; +import { createModuleMocks } from "@/utils/mock-store-module"; +import { + businessErrorFactory, + contextExternalToolConfigurationTemplateFactory, + contextExternalToolFactory, + i18nMock, + toolParameterFactory, +} from "@@/tests/test-utils"; +import createComponentMocks from "@@/tests/test-utils/componentMocks"; +import { createMock, DeepMocked } from "@golevelup/ts-jest"; +import { useBoardNotifier } from "@util-board"; +import { mount, MountOptions, Wrapper } from "@vue/test-utils"; +import flushPromises from "flush-promises"; +import Vue from "vue"; +import ExternalToolConfigurator from "../external-tools/configuration/ExternalToolConfigurator.vue"; +import ExternalToolElementConfigurationDialog from "./ExternalToolElementConfigurationDialog.vue"; + +jest.mock("@util-board"); + +describe("ExternalToolElementConfigurationDialog", () => { + let useBoardNotifierMock: DeepMocked>; + + beforeEach(() => { + useBoardNotifierMock = createMock>(); + + jest.mocked(useBoardNotifier).mockReturnValue(useBoardNotifierMock); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + const getWrapper = async ( + props: { configId?: string } = {}, + getters: Partial = {} + ) => { + document.body.setAttribute("data-app", "true"); + + const contextExternalToolsModule = createModuleMocks( + ContextExternalToolsModule, + { + getContextExternalToolConfigurationTemplates: [ + contextExternalToolConfigurationTemplateFactory.build(), + ], + getLoading: false, + getBusinessError: businessErrorFactory.build({ message: undefined }), + ...getters, + } + ); + + const propsData = { + isOpen: false, + contextId: "contextId", + ...props, + }; + + const wrapper: Wrapper = mount( + ExternalToolElementConfigurationDialog as MountOptions, + { + ...createComponentMocks({ + i18n: true, + }), + propsData, + provide: { + [I18N_KEY.valueOf()]: i18nMock, + [CONTEXT_EXTERNAL_TOOLS_MODULE_KEY.valueOf()]: + contextExternalToolsModule, + }, + } + ); + + wrapper.setProps({ + ...propsData, + isOpen: true, + }); + + // Wait for the DOM to update after opening the dialog + await Vue.nextTick(); + + return { + wrapper, + contextExternalToolsModule, + }; + }; + + describe("Title", () => { + const setup = async () => { + const { wrapper } = await getWrapper(); + + return { + wrapper, + }; + }; + + it("should display the title", async () => { + const { wrapper } = await setup(); + + const title = wrapper.find("h2"); + + expect(title.text()).toEqual( + "feature-board-external-tool-element.dialog.title" + ); + }); + }); + + describe("when the dialog is opened", () => { + describe("when creating a new configuration", () => { + it("should load the available tools for a context", async () => { + const { contextExternalToolsModule } = await getWrapper(); + + await Vue.nextTick(); + + expect( + contextExternalToolsModule.loadAvailableToolsForContext + ).toHaveBeenCalledWith({ + contextId: "contextId", + contextType: ToolContextType.BoardElement, + }); + }); + }); + + describe("when updating an existing configuration", () => { + const setup = async () => { + const contextExternalTool = contextExternalToolFactory.build({ + displayName: "testName", + contextType: ToolContextType.BoardElement, + }); + + const { contextExternalToolsModule, wrapper } = await getWrapper({ + configId: "configId", + }); + + await Vue.nextTick(); + + contextExternalToolsModule.loadContextExternalTool.mockResolvedValue( + contextExternalTool + ); + + return { + contextExternalToolsModule, + wrapper, + contextExternalTool, + }; + }; + + it("should load the template", async () => { + const { contextExternalToolsModule } = await setup(); + + expect( + contextExternalToolsModule.loadConfigurationTemplateForContextExternalTool + ).toHaveBeenCalledWith("configId"); + }); + + it("should load the configuration", async () => { + const { contextExternalToolsModule } = await setup(); + + expect( + contextExternalToolsModule.loadContextExternalTool + ).toHaveBeenCalledWith("configId"); + }); + }); + }); + + describe("when canceling the operation", () => { + it("should emit the close event", async () => { + const { wrapper } = await getWrapper(); + + const configurator = wrapper.findComponent(ExternalToolConfigurator); + configurator.vm.$emit("cancel"); + await Vue.nextTick(); + + expect(wrapper.emitted("close")).toBeDefined(); + }); + }); + + describe("when saving a tool", () => { + describe("when creating a new configuration", () => { + const setup = async () => { + const template = contextExternalToolConfigurationTemplateFactory.build({ + parameters: toolParameterFactory.buildList(1), + }); + + const { wrapper, contextExternalToolsModule } = await getWrapper(); + + contextExternalToolsModule.createContextExternalTool.mockResolvedValue( + contextExternalToolFactory.build() + ); + + return { + wrapper, + contextExternalToolsModule, + template, + }; + }; + + it("should call store action to save tool", async () => { + const { wrapper, template, contextExternalToolsModule } = await setup(); + const testValue = "test"; + + const configurator = wrapper.findComponent(ExternalToolConfigurator); + configurator.vm.$emit("save", template, [testValue]); + await Vue.nextTick(); + + expect( + contextExternalToolsModule.createContextExternalTool + ).toHaveBeenCalledWith<[ContextExternalToolSave]>({ + contextId: "contextId", + contextType: ToolContextType.BoardElement, + displayName: template.name, + schoolToolId: template.schoolExternalToolId, + toolVersion: template.version, + parameters: [ + { + name: template.parameters[0].name, + value: testValue, + }, + ], + }); + }); + + it("should display a notification when created", async () => { + const { wrapper, template } = await setup(); + + const configurator = wrapper.findComponent(ExternalToolConfigurator); + configurator.vm.$emit("save", template, []); + await flushPromises(); + + expect(useBoardNotifierMock.showSuccess).toHaveBeenCalledWith( + "components.administration.externalToolsSection.notification.created" + ); + }); + + it("should emit the save event", async () => { + const { wrapper, template } = await setup(); + + const configurator = wrapper.findComponent(ExternalToolConfigurator); + configurator.vm.$emit("save", template, []); + await flushPromises(); + + expect(wrapper.emitted("save")).toBeDefined(); + }); + + it("should emit the close event", async () => { + const { wrapper, template } = await setup(); + + const configurator = wrapper.findComponent(ExternalToolConfigurator); + configurator.vm.$emit("save", template, []); + await flushPromises(); + + expect(wrapper.emitted("close")).toBeDefined(); + }); + }); + + describe("when editing a configuration", () => { + const setup = async () => { + const template = + contextExternalToolConfigurationTemplateFactory.build(); + + const contextExternalToolId = "configId"; + + const { wrapper, contextExternalToolsModule } = await getWrapper({ + configId: contextExternalToolId, + }); + + contextExternalToolsModule.updateContextExternalTool.mockResolvedValue( + contextExternalToolFactory.build({ id: contextExternalToolId }) + ); + + return { + wrapper, + contextExternalToolsModule, + template, + contextExternalToolId, + }; + }; + + it("should call store action to update tool", async () => { + const { + wrapper, + contextExternalToolsModule, + template, + contextExternalToolId, + } = await setup(); + + const configurator = wrapper.findComponent(ExternalToolConfigurator); + configurator.vm.$emit("save", template, []); + await flushPromises(); + + expect( + contextExternalToolsModule.updateContextExternalTool + ).toHaveBeenCalledWith< + [ + { + contextExternalToolId: string; + contextExternalTool: ContextExternalToolSave; + }, + ] + >({ + contextExternalToolId: contextExternalToolId, + contextExternalTool: { + contextId: "contextId", + contextType: ToolContextType.BoardElement, + displayName: template.name, + parameters: [], + toolVersion: template.version, + schoolToolId: template.schoolExternalToolId, + }, + }); + }); + + it("should display a notification when updated", async () => { + const { wrapper, template } = await setup(); + + const configurator = wrapper.findComponent(ExternalToolConfigurator); + configurator.vm.$emit("save", template, []); + await flushPromises(); + + expect(useBoardNotifierMock.showSuccess).toHaveBeenCalledWith( + "components.administration.externalToolsSection.notification.updated" + ); + }); + + it("should emit the save event", async () => { + const { wrapper, template } = await setup(); + + const configurator = wrapper.findComponent(ExternalToolConfigurator); + configurator.vm.$emit("save", template, []); + await flushPromises(); + + expect(wrapper.emitted("save")).toBeDefined(); + }); + + it("should emit the close event", async () => { + const { wrapper, template } = await setup(); + + const configurator = wrapper.findComponent(ExternalToolConfigurator); + configurator.vm.$emit("save", template, []); + await flushPromises(); + + expect(wrapper.emitted("close")).toBeDefined(); + }); + }); + + describe("when an error occurs during saving", () => { + const setup = async () => { + const error = businessErrorFactory.build(); + + const { wrapper } = await getWrapper( + {}, + { + getBusinessError: error, + } + ); + + return { + wrapper, + error, + }; + }; + + it("should display an alert", async () => { + const { wrapper, error } = await setup(); + + const configurator = wrapper.findComponent(ExternalToolConfigurator); + configurator.vm.$emit( + "save", + contextExternalToolConfigurationTemplateFactory.build(), + [] + ); + await Vue.nextTick(); + + expect(configurator.props("error")).toEqual(error); + }); + }); + }); +}); diff --git a/src/components/feature-board-external-tool-element/ExternalToolElementConfigurationDialog.vue b/src/components/feature-board-external-tool-element/ExternalToolElementConfigurationDialog.vue new file mode 100644 index 0000000000..0ac2469682 --- /dev/null +++ b/src/components/feature-board-external-tool-element/ExternalToolElementConfigurationDialog.vue @@ -0,0 +1,222 @@ + + + diff --git a/src/components/feature-board/card/CardHost.vue b/src/components/feature-board/card/CardHost.vue index 8aa4fdaa15..46f6d7d91d 100644 --- a/src/components/feature-board/card/CardHost.vue +++ b/src/components/feature-board/card/CardHost.vue @@ -74,8 +74,8 @@ import { } from "@data-board"; import { BoardMenu, - BoardMenuActionEdit, BoardMenuActionDelete, + BoardMenuActionEdit, } from "@ui-board"; import { useDebounceFn, diff --git a/src/components/feature-board/card/ContentElementList.unit.ts b/src/components/feature-board/card/ContentElementList.unit.ts index d62144dcb4..2f68d293e5 100644 --- a/src/components/feature-board/card/ContentElementList.unit.ts +++ b/src/components/feature-board/card/ContentElementList.unit.ts @@ -22,6 +22,7 @@ describe("ContentElementList", () => { const setup = (props: { elements: AnyContentElement[]; isEditMode: boolean; + cardId: string; }) => { document.body.setAttribute("data-app", "true"); @@ -48,6 +49,7 @@ describe("ContentElementList", () => { setup({ elements: [], isEditMode: false, + cardId: "cardId", }); expect(wrapper.findComponent(ContentElementList).exists()).toBe(true); }); @@ -81,6 +83,7 @@ describe("ContentElementList", () => { setup({ elements: [{ type: elementType } as AnyContentElement], isEditMode: false, + cardId: "cardId", }); expect(wrapper.findComponent(component).exists()).toBe(true); } @@ -94,6 +97,7 @@ describe("ContentElementList", () => { setup({ elements: [{ type: elementType } as AnyContentElement], isEditMode: isEditModeResult, + cardId: "cardId", }); const childComponent = wrapper.findComponent(component); diff --git a/src/components/feature-board/card/ContentElementList.vue b/src/components/feature-board/card/ContentElementList.vue index babf249f00..43dcd4fae9 100644 --- a/src/components/feature-board/card/ContentElementList.vue +++ b/src/components/feature-board/card/ContentElementList.vue @@ -61,19 +61,19 @@ import { ContentElementType, ExternalToolElementResponse, FileElementResponse, + LinkElementResponse, RichTextElementResponse, SubmissionContainerElementResponse, - LinkElementResponse, } from "@/serverApi/v3"; import { AnyContentElement } from "@/types/board/ContentElement"; import { ElementMove } from "@/types/board/DragAndDrop"; import { ENV_CONFIG_MODULE_KEY, injectStrict } from "@/utils/inject"; import { ExternalToolElement } from "@feature-board-external-tool-element"; import { FileContentElement } from "@feature-board-file-element"; +import { LinkContentElement } from "@feature-board-link-element"; import { SubmissionContentElement } from "@feature-board-submission-element"; import { RichTextContentElement } from "@feature-board-text-element"; import { computed, defineComponent, PropType } from "vue"; -import { LinkContentElement } from "@feature-board-link-element"; import ContentElement from "./ContentElement.vue"; export default defineComponent({ diff --git a/src/locales/de.json b/src/locales/de.json index d2ad88488a..592f49ee53 100644 --- a/src/locales/de.json +++ b/src/locales/de.json @@ -1019,5 +1019,6 @@ "ui-confirmation-dialog.ask-delete": "{type} {title} wirklich löschen?", "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." } diff --git a/src/locales/en.json b/src/locales/en.json index be1ae42057..0f3ef519b6 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -1019,5 +1019,6 @@ "pages.videoConference.action.refresh": "Update status", "ui-confirmation-dialog.ask-delete.card": "Delete {type} {title}?", "feature-board-file-element.placeholder.uploadFile": "Upload file", - "feature-board-external-tool-element.placeholder.selectTool": "Select Tool..." + "feature-board-external-tool-element.placeholder.selectTool": "Select Tool...", + "feature-board-external-tool-element.dialog.title": "Selection & Settings" } diff --git a/src/locales/es.json b/src/locales/es.json index 32f3c76917..398d9b0f72 100644 --- a/src/locales/es.json +++ b/src/locales/es.json @@ -1015,5 +1015,6 @@ "pages.videoConference.action.refresh": "Estado de actualización", "ui-confirmation-dialog.ask-delete.card": "¿Eliminar {type} {title}?", "feature-board-file-element.placeholder.uploadFile": "Cargar archivo", - "feature-board-external-tool-element.placeholder.selectTool": "Herramienta de selección..." + "feature-board-external-tool-element.placeholder.selectTool": "Herramienta de selección...", + "feature-board-external-tool-element.dialog.title": "Selección y configuración" } diff --git a/src/locales/uk.json b/src/locales/uk.json index e1fd7559ca..6471883f06 100644 --- a/src/locales/uk.json +++ b/src/locales/uk.json @@ -1045,5 +1045,6 @@ "pages.videoConference.action.refresh": "оновити статус", "ui-confirmation-dialog.ask-delete.card": "{type} {title} буде видалена. Ви впевнені, що хочете видалити?", "feature-board-file-element.placeholder.uploadFile": "Cargar archivo", - "feature-board-external-tool-element.placeholder.selectTool": "Виберіть інструмент..." + "feature-board-external-tool-element.placeholder.selectTool": "Виберіть інструмент...", + "feature-board-external-tool-element.dialog.title": "Вибір і налаштування" } diff --git a/src/pages/context-external-tool/ContextExternalToolConfigurator.page.unit.ts b/src/pages/context-external-tool/ContextExternalToolConfigurator.page.unit.ts index 1faed1615a..fc2057e32d 100644 --- a/src/pages/context-external-tool/ContextExternalToolConfigurator.page.unit.ts +++ b/src/pages/context-external-tool/ContextExternalToolConfigurator.page.unit.ts @@ -1,8 +1,10 @@ import ExternalToolConfigurator from "@/components/external-tools/configuration/ExternalToolConfigurator.vue"; +import { ToolContextType } from "@/serverApi/v3"; import ContextExternalToolsModule from "@/store/context-external-tools"; -import { ToolContextType } from "@/store/external-tool"; +import EnvConfigModule from "@/store/env-config"; import { ContextExternalToolSave } from "@/store/external-tool/context-external-tool"; import NotifierModule from "@/store/notifier"; +import RoomModule from "@/store/room"; import { CONTEXT_EXTERNAL_TOOLS_MODULE_KEY, ENV_CONFIG_MODULE_KEY, @@ -20,8 +22,6 @@ import { } from "@@/tests/test-utils/factory"; import { mount, MountOptions, Wrapper } from "@vue/test-utils"; import Vue from "vue"; -import RoomModule from "@/store/room"; -import EnvConfigModule from "@/store/env-config"; import ContextExternalToolConfigurator from "./ContextExternalToolConfigurator.page.vue"; describe("ContextExternalToolConfigurator", () => { @@ -110,7 +110,7 @@ describe("ContextExternalToolConfigurator", () => { it("should render static breadcrumbs", () => { const { wrapper, roomTitle } = getWrapper({ contextId: "contextId", - contextType: ToolContextType.COURSE, + contextType: ToolContextType.Course, }); const breadcrumbs = wrapper.findAll(".breadcrumbs-item"); @@ -124,7 +124,7 @@ describe("ContextExternalToolConfigurator", () => { it("should render title", () => { const { wrapper } = getWrapper({ contextId: "contextId", - contextType: ToolContextType.COURSE, + contextType: ToolContextType.Course, }); expect(wrapper.find("h1").exists()).toBeTruthy(); @@ -136,7 +136,7 @@ describe("ContextExternalToolConfigurator", () => { it("should load the available tools for a context", async () => { const { contextExternalToolsModule } = getWrapper({ contextId: "contextId", - contextType: ToolContextType.COURSE, + contextType: ToolContextType.Course, }); await Vue.nextTick(); @@ -145,7 +145,7 @@ describe("ContextExternalToolConfigurator", () => { contextExternalToolsModule.loadAvailableToolsForContext ).toHaveBeenCalledWith({ contextId: "contextId", - contextType: ToolContextType.COURSE, + contextType: ToolContextType.Course, }); }); }); @@ -159,7 +159,7 @@ describe("ContextExternalToolConfigurator", () => { const { contextExternalToolsModule, wrapper } = getWrapper({ configId: "configId", contextId: "contextId", - contextType: ToolContextType.COURSE, + contextType: ToolContextType.Course, }); await Vue.nextTick(); @@ -197,7 +197,7 @@ describe("ContextExternalToolConfigurator", () => { it("should change page when cancel button was clicked", async () => { const { wrapper } = getWrapper({ contextId: "contextId", - contextType: ToolContextType.COURSE, + contextType: ToolContextType.Course, }); await wrapper.findComponent(ExternalToolConfigurator).vm.$emit("cancel"); @@ -213,7 +213,7 @@ describe("ContextExternalToolConfigurator", () => { describe("when creating a new configuration", () => { const setup = () => { const contextId = "contextId"; - const contextType: ToolContextType = ToolContextType.COURSE; + const contextType: ToolContextType = ToolContextType.Course; const template = contextExternalToolConfigurationTemplateFactory.build({ parameters: toolParameterFactory.buildList(1), }); @@ -303,7 +303,7 @@ describe("ContextExternalToolConfigurator", () => { contextExternalToolConfigurationTemplateFactory.build(); const contextId = "contextId"; - const contextType: ToolContextType = ToolContextType.COURSE; + const contextType: ToolContextType = ToolContextType.Course; const contextExternalToolId = "configId"; const { wrapper, contextExternalToolsModule, notifierModule } = @@ -350,7 +350,7 @@ describe("ContextExternalToolConfigurator", () => { { contextExternalToolId: string; contextExternalTool: ContextExternalToolSave; - } + }, ] >({ contextExternalToolId: contextExternalToolId, @@ -397,7 +397,7 @@ describe("ContextExternalToolConfigurator", () => { const { wrapper } = getWrapper( { contextId: "contextId", - contextType: ToolContextType.COURSE, + contextType: ToolContextType.Course, }, { getBusinessError: businessErrorFactory.build(), diff --git a/src/pages/context-external-tool/ContextExternalToolConfigurator.page.vue b/src/pages/context-external-tool/ContextExternalToolConfigurator.page.vue index 8717dee628..262523c2f5 100644 --- a/src/pages/context-external-tool/ContextExternalToolConfigurator.page.vue +++ b/src/pages/context-external-tool/ContextExternalToolConfigurator.page.vue @@ -32,13 +32,21 @@