diff --git a/src/components/data-external-tool/ExternalToolApi.composable.ts b/src/components/data-external-tool/ExternalToolApi.composable.ts new file mode 100644 index 0000000000..9848ede046 --- /dev/null +++ b/src/components/data-external-tool/ExternalToolApi.composable.ts @@ -0,0 +1,32 @@ +import { + ToolApiFactory, + ToolApiInterface, + ToolLaunchRequestResponse, +} from "@/serverApi/v3"; +import { ToolLaunchRequest } from "@/store/external-tool"; +import { ExternalToolMapper } from "@/store/external-tool/mapper"; +import { $axios } from "@/utils/api"; +import { AxiosResponse } from "axios"; + +export const useExternalToolApi = () => { + const toolApi: ToolApiInterface = ToolApiFactory(undefined, "/v3", $axios); + + const fetchLaunchDataCall = async ( + contextExternalToolId: string + ): Promise => { + const response: AxiosResponse = + await toolApi.toolLaunchControllerGetToolLaunchRequest( + contextExternalToolId + ); + + const mapped: ToolLaunchRequest = ExternalToolMapper.mapToToolLaunchRequest( + response.data + ); + + return mapped; + }; + + return { + fetchLaunchDataCall, + }; +}; diff --git a/src/components/data-external-tool/ExternalToolApi.composable.unit.ts b/src/components/data-external-tool/ExternalToolApi.composable.unit.ts new file mode 100644 index 0000000000..b025e3da78 --- /dev/null +++ b/src/components/data-external-tool/ExternalToolApi.composable.unit.ts @@ -0,0 +1,65 @@ +import * as serverApi from "@/serverApi/v3/api"; +import { ToolLaunchRequestResponse } from "@/serverApi/v3/api"; +import { + ToolLaunchRequest, + ToolLaunchRequestMethodEnum, +} from "@/store/external-tool"; +import { + mockApiResponse, + toolLaunchRequestResponseFactory, +} from "@@/tests/test-utils"; +import { createMock, DeepMocked } from "@golevelup/ts-jest"; +import { useExternalToolApi } from "./ExternalToolApi.composable"; + +describe("ExternalToolApi.composable", () => { + let toolApi: DeepMocked; + + beforeEach(() => { + toolApi = createMock(); + + jest.spyOn(serverApi, "ToolApiFactory").mockReturnValue(toolApi); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + describe("fetchLaunchDataCall", () => { + const setup = () => { + const launchRequest: ToolLaunchRequestResponse = + toolLaunchRequestResponseFactory.build(); + + toolApi.toolLaunchControllerGetToolLaunchRequest.mockResolvedValue( + mockApiResponse({ data: launchRequest }) + ); + + return { + launchRequest, + }; + }; + + it("should call the api for a tool launch request", async () => { + setup(); + + await useExternalToolApi().fetchLaunchDataCall("contextExternalToolId"); + + expect( + toolApi.toolLaunchControllerGetToolLaunchRequest + ).toHaveBeenCalledWith("contextExternalToolId"); + }); + + it("should return launch request data", async () => { + const { launchRequest } = setup(); + + const result: ToolLaunchRequest = + await useExternalToolApi().fetchLaunchDataCall("contextExternalToolId"); + + expect(result).toEqual({ + url: launchRequest.url, + payload: launchRequest.payload, + method: ToolLaunchRequestMethodEnum.Get, + openNewTab: launchRequest.openNewTab, + }); + }); + }); +}); diff --git a/src/components/data-external-tool/ExternalToolElementDisplayState.composable.unit.ts b/src/components/data-external-tool/ExternalToolElementDisplayState.composable.unit.ts index 1fe5ff37ed..1a9b629b0d 100644 --- a/src/components/data-external-tool/ExternalToolElementDisplayState.composable.unit.ts +++ b/src/components/data-external-tool/ExternalToolElementDisplayState.composable.unit.ts @@ -2,10 +2,10 @@ 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 { useContextExternalToolApi } from "./ContextExternalToolApi.composable"; import { useExternalToolElementDisplayState } from "./ExternalToolElementDisplayState.composable"; -import { useContextExternalToolApi } from "./index"; -jest.mock("@data-external-tool"); +jest.mock("@data-external-tool/ContextExternalToolApi.composable"); jest.mock("@/components/error-handling/ErrorHandler.composable"); describe("ExternalToolElementDisplayState.composable", () => { diff --git a/src/components/data-external-tool/ExternalToolLaunchState.composable.ts b/src/components/data-external-tool/ExternalToolLaunchState.composable.ts new file mode 100644 index 0000000000..d4b8b61673 --- /dev/null +++ b/src/components/data-external-tool/ExternalToolLaunchState.composable.ts @@ -0,0 +1,101 @@ +import { + ToolLaunchRequest, + ToolLaunchRequestMethodEnum, +} from "@/store/external-tool"; +import { BusinessError } from "@/store/types/commons"; +import { HttpStatusCode } from "@/store/types/http-status-code.enum"; +import { mapAxiosErrorToResponseError } from "@/utils/api"; +import { ref, Ref } from "vue"; +import { useExternalToolApi } from "./ExternalToolApi.composable"; + +export const useExternalToolLaunchState = () => { + const { fetchLaunchDataCall } = useExternalToolApi(); + + const isLoading: Ref = ref(false); + const error: Ref = ref(); + const toolLaunchRequest: Ref = ref(); + + const fetchLaunchRequest = async ( + contextExternalToolId: string + ): Promise => { + isLoading.value = true; + error.value = undefined; + + try { + toolLaunchRequest.value = await fetchLaunchDataCall( + contextExternalToolId + ); + } catch (axiosError: unknown) { + const apiError = mapAxiosErrorToResponseError(axiosError); + + error.value = { + error: apiError, + message: apiError.message, + statusCode: apiError.code, + }; + } + + isLoading.value = false; + }; + + const launchTool = () => { + if (!toolLaunchRequest.value) { + return; + } + + switch (toolLaunchRequest.value.method) { + case ToolLaunchRequestMethodEnum.Get: + handleGetLaunchRequest(toolLaunchRequest.value); + break; + case ToolLaunchRequestMethodEnum.Post: + handlePostLaunchRequest(toolLaunchRequest.value); + break; + default: + error.value = { + message: "Unknown launch method", + statusCode: HttpStatusCode.UnprocessableEntity, + }; + break; + } + }; + + const handleGetLaunchRequest = (toolLaunch: ToolLaunchRequest) => { + if (toolLaunch.openNewTab) { + window.open(toolLaunch.url, "_blank"); + return; + } + window.location.href = toolLaunch.url; + }; + + const handlePostLaunchRequest = (toolLaunch: ToolLaunchRequest) => { + const form: HTMLFormElement = document.createElement("form"); + form.method = "POST"; + form.action = toolLaunch.url; + form.target = toolLaunch.openNewTab ? "_blank" : "_self"; + form.id = "launch-form"; + + const payload = JSON.parse(toolLaunch.payload || "{}"); + + for (const key in payload) { + if (Object.prototype.hasOwnProperty.call(payload, key)) { + const hiddenField = document.createElement("input"); + hiddenField.type = "hidden"; + hiddenField.name = key; + hiddenField.value = payload[key]; + + form.appendChild(hiddenField); + } + } + + document.body.appendChild(form); + form.submit(); + }; + + return { + toolLaunchRequest, + error, + isLoading, + fetchLaunchRequest, + launchTool, + }; +}; diff --git a/src/components/data-external-tool/ExternalToolLaunchState.composable.unit.ts b/src/components/data-external-tool/ExternalToolLaunchState.composable.unit.ts new file mode 100644 index 0000000000..053a45bb3d --- /dev/null +++ b/src/components/data-external-tool/ExternalToolLaunchState.composable.unit.ts @@ -0,0 +1,270 @@ +import { + ToolLaunchRequest, + ToolLaunchRequestMethodEnum, +} from "@/store/external-tool"; +import { BusinessError } from "@/store/types/commons"; +import { HttpStatusCode } from "@/store/types/http-status-code.enum"; +import { toolLaunchRequestFactory } from "@@/tests/test-utils/factory/toolLaunchRequestFactory"; +import { createMock, DeepMocked } from "@golevelup/ts-jest"; +import { axiosErrorFactory } from "../../../tests/test-utils"; +import { mapAxiosErrorToResponseError } from "../../utils/api"; +import { useExternalToolApi } from "./ExternalToolApi.composable"; +import { useExternalToolLaunchState } from "./ExternalToolLaunchState.composable"; + +jest.mock("@data-external-tool/ExternalToolApi.composable"); + +describe("ExternalToolLaunchState.composable", () => { + let useExternalToolApiMock: DeepMocked>; + + beforeEach(() => { + useExternalToolApiMock = + createMock>(); + + jest.mocked(useExternalToolApi).mockReturnValue(useExternalToolApiMock); + }); + + afterEach(() => { + jest.clearAllMocks(); + + window.location.href = ""; + }); + + describe("fetchLaunchRequest", () => { + describe("when fetching a tool", () => { + const setup = () => { + const response = toolLaunchRequestFactory.build(); + + useExternalToolApiMock.fetchLaunchDataCall.mockResolvedValue(response); + + return { + ...useExternalToolLaunchState(), + response, + }; + }; + + it("should load the launch data from the store", async () => { + const { fetchLaunchRequest } = setup(); + + await fetchLaunchRequest("contextExternalToolId"); + + expect(useExternalToolApiMock.fetchLaunchDataCall).toHaveBeenCalledWith( + "contextExternalToolId" + ); + }); + + it("should save the loaded request in a state", async () => { + const { fetchLaunchRequest, toolLaunchRequest, response } = setup(); + + await fetchLaunchRequest("contextExternalToolId"); + + expect(toolLaunchRequest.value).toEqual({ + method: ToolLaunchRequestMethodEnum.Get, + url: response.url, + payload: response.payload, + openNewTab: response.openNewTab, + }); + }); + + it("should not have an error", async () => { + const { fetchLaunchRequest, error } = setup(); + + await fetchLaunchRequest("contextExternalToolId"); + + expect(error.value).toBeUndefined(); + }); + }); + + describe("when an error occurs", () => { + const setup = () => { + const axiosError = axiosErrorFactory.build(); + const apiError = mapAxiosErrorToResponseError(axiosError); + + useExternalToolApiMock.fetchLaunchDataCall.mockRejectedValue( + axiosError + ); + + return { + ...useExternalToolLaunchState(), + apiError, + }; + }; + + it("should load the launch data from the store", async () => { + const { fetchLaunchRequest, error, apiError } = setup(); + + await fetchLaunchRequest("contextExternalToolId"); + + expect(error.value).toEqual({ + message: apiError.message, + statusCode: apiError.code, + error: apiError, + }); + }); + }); + }); + + describe("launchTool", () => { + describe("when launching without loading", () => { + it("should do nothing", () => { + const { launchTool } = useExternalToolLaunchState(); + + launchTool(); + + expect(window.location.href).toEqual(""); + }); + }); + + describe("when launching a tool with get method", () => { + describe("when opening in the same tab", () => { + const setup = () => { + const launchRequest = toolLaunchRequestFactory.build({ + method: ToolLaunchRequestMethodEnum.Get, + openNewTab: false, + }); + + const composable = useExternalToolLaunchState(); + composable.toolLaunchRequest.value = launchRequest; + + return { + ...composable, + launchRequest, + }; + }; + + it("should set the location", () => { + const { launchRequest, launchTool } = setup(); + + launchTool(); + + expect(window.location.href).toEqual(launchRequest.url); + }); + }); + + describe("when opening in a new tab", () => { + const setup = () => { + jest.spyOn(window, "open"); + const launchRequest = toolLaunchRequestFactory.build({ + method: ToolLaunchRequestMethodEnum.Get, + openNewTab: true, + }); + + const composable = useExternalToolLaunchState(); + composable.toolLaunchRequest.value = launchRequest; + + return { + ...composable, + launchRequest, + }; + }; + + it("should open in a new tab", () => { + const { launchRequest, launchTool } = setup(); + + launchTool(); + + expect(window.open).toHaveBeenCalledWith(launchRequest.url, "_blank"); + }); + }); + }); + + describe("when launching a tool with post method", () => { + afterEach(() => { + document.getElementById("launch-form")?.remove(); + }); + + describe("when opening in the same tab", () => { + const setup = () => { + const launchRequest = toolLaunchRequestFactory.build({ + method: ToolLaunchRequestMethodEnum.Post, + openNewTab: false, + }); + + const composable = useExternalToolLaunchState(); + composable.toolLaunchRequest.value = launchRequest; + + return { + ...composable, + launchRequest, + }; + }; + + it("should create a launch form with target _self", () => { + const { launchRequest, launchTool } = setup(); + + launchTool(); + + const form = document.getElementById("launch-form"); + + expect(form?.outerHTML).toEqual( + `
` + ); + }); + }); + + describe("when opening in a new tab", () => { + const setup = () => { + const launchRequest = toolLaunchRequestFactory.build({ + method: ToolLaunchRequestMethodEnum.Post, + openNewTab: true, + payload: "", + }); + + const composable = useExternalToolLaunchState(); + composable.toolLaunchRequest.value = launchRequest; + + return { + ...composable, + launchRequest, + }; + }; + + it("should create a launch form with target _blank", () => { + const { launchRequest, launchTool } = setup(); + + launchTool(); + + const form = document.getElementById("launch-form"); + + expect(form?.outerHTML).toEqual( + `
` + ); + }); + }); + }); + + describe("when the launch method is unknown", () => { + const setup = () => { + const launchRequest = toolLaunchRequestFactory.build({ + method: "unknown" as unknown as ToolLaunchRequestMethodEnum, + }); + + const composable = useExternalToolLaunchState(); + composable.toolLaunchRequest.value = launchRequest; + + return { + ...composable, + launchRequest, + }; + }; + + it("should set an error", () => { + const { error, launchTool } = setup(); + + launchTool(); + + expect(error.value).toEqual({ + message: "Unknown launch method", + statusCode: HttpStatusCode.UnprocessableEntity, + }); + }); + + it("should not redirect", () => { + const { launchTool } = setup(); + + launchTool(); + + expect(window.location.href).toEqual(""); + }); + }); + }); +}); diff --git a/src/components/data-external-tool/index.ts b/src/components/data-external-tool/index.ts index f64b0c1f8f..9c9d3f1c8e 100644 --- a/src/components/data-external-tool/index.ts +++ b/src/components/data-external-tool/index.ts @@ -1,2 +1,3 @@ export * from "./ContextExternalToolApi.composable"; export * from "./ExternalToolElementDisplayState.composable"; +export * from "./ExternalToolLaunchState.composable"; diff --git a/src/components/external-tools/configuration/ExternalToolConfigSettings.unit.ts b/src/components/external-tools/configuration/ExternalToolConfigSettings.unit.ts index f62f4c04b1..189942b31a 100644 --- a/src/components/external-tools/configuration/ExternalToolConfigSettings.unit.ts +++ b/src/components/external-tools/configuration/ExternalToolConfigSettings.unit.ts @@ -1,9 +1,5 @@ import { ExternalToolConfigurationTemplate } from "@/store/external-tool"; -import ExternalToolsModule from "@/store/external-tools"; -import { EXTERNAL_TOOLS_MODULE_KEY, I18N_KEY } from "@/utils/inject"; -import { createModuleMocks } from "@/utils/mock-store-module"; import { - i18nMock, schoolExternalToolConfigurationTemplateFactory, toolParameterFactory, } from "@@/tests/test-utils"; @@ -20,26 +16,16 @@ describe("ExternalToolConfigSettings", () => { } = { template: schoolExternalToolConfigurationTemplateFactory.build(), value: [], - }, - getter: Partial = {} + } ) => { document.body.setAttribute("data-app", "true"); - const externalToolsModule = createModuleMocks(ExternalToolsModule, { - getLoading: false, - ...getter, - }) as jest.Mocked; - const wrapper: Wrapper = shallowMount( ExternalToolConfigSettings as MountOptions, { ...createComponentMocks({ i18n: true, }), - provide: { - [I18N_KEY.valueOf()]: i18nMock, - [EXTERNAL_TOOLS_MODULE_KEY.valueOf()]: externalToolsModule, - }, propsData: { ...props, }, @@ -48,7 +34,6 @@ describe("ExternalToolConfigSettings", () => { return { wrapper, - externalToolsModule, }; }; @@ -62,40 +47,6 @@ describe("ExternalToolConfigSettings", () => { }); }); - describe("progressbar", () => { - it("should display progressbar when loading in store is set", () => { - const { wrapper } = getWrapper( - { - template: schoolExternalToolConfigurationTemplateFactory.build(), - value: [], - }, - { - getLoading: true, - } - ); - - const progressbar = wrapper.find("v-progress-linear-stub"); - - expect(progressbar.attributes().active).toBeTruthy(); - }); - - it("should not display progressbar when loading in store is not set", () => { - const { wrapper } = getWrapper( - { - template: schoolExternalToolConfigurationTemplateFactory.build(), - value: [], - }, - { - getLoading: false, - } - ); - - const progressbar = wrapper.find("v-progress-linear-stub"); - - expect(progressbar.attributes().active).toBeFalsy(); - }); - }); - describe("parameters", () => { const setup = () => { const template = schoolExternalToolConfigurationTemplateFactory.build({ diff --git a/src/components/external-tools/configuration/ExternalToolConfigSettings.vue b/src/components/external-tools/configuration/ExternalToolConfigSettings.vue index 816068dcce..61c023a6f0 100644 --- a/src/components/external-tools/configuration/ExternalToolConfigSettings.vue +++ b/src/components/external-tools/configuration/ExternalToolConfigSettings.vue @@ -6,24 +6,14 @@ v-model="inputValues[index]" /> -