From 64eaa26dced7e2956f5d6cafd66908458615d2a4 Mon Sep 17 00:00:00 2001 From: hoeppner-dataport <106819770+hoeppner-dataport@users.noreply.github.com> Date: Fri, 29 Nov 2024 14:42:46 +0100 Subject: [PATCH 01/11] BC-8349 - room feature tests (#3454) BC-8349 - add missing room-feature unit tests --------- Co-authored-by: wolfganggreschus Co-authored-by: Murat Merdoglu Co-authored-by: Murat Merdoglu <64781656+muratmerdoglu-dp@users.noreply.github.com> --- .../data/room/RoomCreate.state.unit.ts | 105 ++++++ .../data/room/RoomDetails.store.unit.ts | 138 ++++++++ src/modules/data/room/RoomEdit.state.unit.ts | 142 ++++++++ src/modules/data/room/Rooms.state.unit.ts | 136 ++++++++ src/modules/page/room/RoomCreate.page.unit.ts | 95 +++++ .../page/room/RoomDetails.page.unit.ts | 327 ++++++++++++++++++ src/modules/page/room/RoomDetails.page.vue | 24 +- src/modules/page/room/RoomEdit.page.unit.ts | 159 +++++++++ src/modules/page/room/RoomMembers.page.vue | 1 - src/modules/page/room/Rooms.page.unit.ts | 95 +++++ src/plugins/application-error-handler.ts | 1 - src/store/env-config.ts | 1 - .../test-utils/factory/roomDetailsFactory.ts | 15 + 13 files changed, 1229 insertions(+), 10 deletions(-) create mode 100644 src/modules/data/room/RoomCreate.state.unit.ts create mode 100644 src/modules/data/room/RoomDetails.store.unit.ts create mode 100644 src/modules/data/room/RoomEdit.state.unit.ts create mode 100644 src/modules/data/room/Rooms.state.unit.ts create mode 100644 src/modules/page/room/RoomCreate.page.unit.ts create mode 100644 src/modules/page/room/RoomDetails.page.unit.ts create mode 100644 src/modules/page/room/RoomEdit.page.unit.ts create mode 100644 src/modules/page/room/Rooms.page.unit.ts create mode 100644 tests/test-utils/factory/roomDetailsFactory.ts diff --git a/src/modules/data/room/RoomCreate.state.unit.ts b/src/modules/data/room/RoomCreate.state.unit.ts new file mode 100644 index 0000000000..8f17f278b3 --- /dev/null +++ b/src/modules/data/room/RoomCreate.state.unit.ts @@ -0,0 +1,105 @@ +import { createMock, DeepMocked } from "@golevelup/ts-jest"; +import { useRoomCreateState } from "./RoomCreate.state"; +import * as serverApi from "@/serverApi/v3/api"; +import { AxiosInstance } from "axios"; +import { useApplicationError } from "@/composables/application-error.composable"; +import { initializeAxios, mapAxiosErrorToResponseError } from "@/utils/api"; +import setupStores from "@@/tests/test-utils/setupStores"; +import ApplicationErrorModule from "@/store/application-error"; +import { RoomCreateParams } from "@/types/room/Room"; +import { ref } from "vue"; +import { + apiResponseErrorFactory, + axiosErrorFactory, +} from "@@/tests/test-utils"; + +jest.mock("@/utils/api"); +const mockedMapAxiosErrorToResponseError = jest.mocked( + mapAxiosErrorToResponseError +); + +jest.mock("@/composables/application-error.composable"); +const mockedCreateApplicationError = jest.mocked(useApplicationError); + +const setupErrorResponse = (message = "NOT_FOUND", code = 404) => { + const expectedPayload = apiResponseErrorFactory.build({ + message, + code, + }); + const responseError = axiosErrorFactory.build({ + response: { data: expectedPayload }, + }); + + return { + responseError, + expectedPayload, + }; +}; + +describe("useRoomCreateState", () => { + let roomApiMock: DeepMocked; + let axiosMock: DeepMocked; + let mockedCreateApplicationErrorCalls: ReturnType; + + beforeEach(() => { + roomApiMock = createMock(); + axiosMock = createMock(); + + jest.spyOn(serverApi, "RoomApiFactory").mockReturnValue(roomApiMock); + initializeAxios(axiosMock); + + mockedCreateApplicationErrorCalls = + createMock>(); + mockedCreateApplicationError.mockReturnValue( + mockedCreateApplicationErrorCalls + ); + + setupStores({ + applicationErrorModule: ApplicationErrorModule, + }); + }); + + afterEach(() => { + jest.resetAllMocks(); + }); + + const setup = () => { + const { createRoom, isLoading, roomData } = useRoomCreateState(); + const { expectedPayload } = setupErrorResponse(); + mockedMapAxiosErrorToResponseError.mockReturnValueOnce(expectedPayload); + return { createRoom, isLoading, roomData }; + }; + + describe("createRoom", () => { + const roomData = ref({ + name: "Room 1", + color: serverApi.RoomColor.BlueGrey, + startDate: undefined, + endDate: undefined, + }); + + it("should call roomApi.roomControllerCreateRoom with the provided params", async () => { + const { createRoom, isLoading } = setup(); + expect(isLoading.value).toBe(true); + + await createRoom(roomData.value); + expect(roomApiMock.roomControllerCreateRoom).toHaveBeenCalledWith( + roomData.value + ); + expect(isLoading.value).toBe(false); + }); + + it("should throw an error when fetching room data fails", async () => { + const { createRoom, isLoading } = setup(); + expect(isLoading.value).toBe(true); + roomApiMock.roomControllerCreateRoom.mockRejectedValue({ code: 404 }); + + await createRoom(roomData.value).catch(() => { + expect(mockedMapAxiosErrorToResponseError).toHaveBeenCalledWith({ + code: 404, + }); + }); + expect(isLoading.value).toBe(false); + }); + }); +}); diff --git a/src/modules/data/room/RoomDetails.store.unit.ts b/src/modules/data/room/RoomDetails.store.unit.ts new file mode 100644 index 0000000000..ae0bca7fa4 --- /dev/null +++ b/src/modules/data/room/RoomDetails.store.unit.ts @@ -0,0 +1,138 @@ +import { createPinia, setActivePinia } from "pinia"; +import { useRoomDetailsStore, RoomVariant } from "./RoomDetails.store"; +import { createMock, DeepMocked } from "@golevelup/ts-jest"; +import { AxiosInstance } from "axios"; +import * as serverApi from "@/serverApi/v3/api"; +import { useApplicationError } from "@/composables/application-error.composable"; +import { initializeAxios, mapAxiosErrorToResponseError } from "@/utils/api"; +import { + apiResponseErrorFactory, + axiosErrorFactory, +} from "@@/tests/test-utils"; + +jest.mock("@/utils/api"); +const mockedMapAxiosErrorToResponseError = jest.mocked( + mapAxiosErrorToResponseError +); + +jest.mock("@/composables/application-error.composable"); +const mockedCreateApplicationError = jest.mocked(useApplicationError); + +const setupErrorResponse = (message = "NOT_FOUND", code = 404) => { + const expectedPayload = apiResponseErrorFactory.build({ + message, + code, + }); + const responseError = axiosErrorFactory.build({ + response: { data: expectedPayload }, + }); + + return { + responseError, + expectedPayload, + }; +}; + +describe("useRoomDetailsStore", () => { + let roomApiMock: DeepMocked; + let axiosMock: DeepMocked; + let mockedCreateApplicationErrorCalls: ReturnType; + + beforeEach(() => { + setActivePinia(createPinia()); + roomApiMock = createMock(); + axiosMock = createMock(); + mockedCreateApplicationErrorCalls = + createMock>(); + mockedCreateApplicationError.mockReturnValue( + mockedCreateApplicationErrorCalls + ); + + jest.spyOn(serverApi, "RoomApiFactory").mockReturnValue(roomApiMock); + initializeAxios(axiosMock); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + const setup = ( + options: { errorCode: number } = { + errorCode: 404, + } + ) => { + const store = useRoomDetailsStore(); + + const { expectedPayload } = setupErrorResponse(); + if (options.errorCode !== 404) { + expectedPayload.code = options.errorCode; + } + + mockedMapAxiosErrorToResponseError.mockReturnValue(expectedPayload); + + return { store }; + }; + + describe("fetchRoom", () => { + it("should call fetchRoom api", async () => { + const { store } = setup(); + + expect(store.isLoading).toBe(true); + await store.fetchRoom("room-id"); + + expect(roomApiMock.roomControllerGetRoomDetails).toHaveBeenCalledWith( + "room-id" + ); + expect(roomApiMock.roomControllerGetRoomBoards).toHaveBeenCalledWith( + "room-id" + ); + expect(store.isLoading).toBe(false); + }); + + describe("when fetching room fails with 404", () => { + it("should set roomVariant to COURSE_ROOM", async () => { + const { store } = setup(); + expect(store.isLoading).toBe(true); + roomApiMock.roomControllerGetRoomDetails.mockRejectedValue({ + code: 404, + }); + + await store.fetchRoom("room-id"); + + expect(store.roomVariant).toBe(RoomVariant.COURSE_ROOM); + expect(store.isLoading).toBe(false); + }); + }); + + describe("when fetching room fails with other errors", () => { + it("should throw an error", async () => { + const { store } = setup({ errorCode: 401 }); + expect(store.isLoading).toBe(true); + roomApiMock.roomControllerGetRoomDetails.mockRejectedValue({ + code: 401, + }); + + await expect(store.fetchRoom("room-id")).rejects.toThrow(); + expect(store.isLoading).toBe(false); + }); + }); + }); + + describe("resetState", () => { + it("should reset the state", () => { + const { store } = setup(); + store.resetState(); + expect(store.isLoading).toBe(true); + expect(store.room).toBeUndefined(); + }); + }); + + describe("deactivateRoom", () => { + it("should reset the state", () => { + const { store } = setup(); + store.deactivateRoom(); + expect(store.isLoading).toBe(false); + expect(store.room).toBeUndefined(); + }); + }); +}); diff --git a/src/modules/data/room/RoomEdit.state.unit.ts b/src/modules/data/room/RoomEdit.state.unit.ts new file mode 100644 index 0000000000..52bf187f5b --- /dev/null +++ b/src/modules/data/room/RoomEdit.state.unit.ts @@ -0,0 +1,142 @@ +import { useRoomEditState } from "./RoomEdit.state"; +import * as serverApi from "@/serverApi/v3/api"; +import { initializeAxios, mapAxiosErrorToResponseError } from "@/utils/api"; +import { createMock, DeepMocked } from "@golevelup/ts-jest"; +import { AxiosInstance } from "axios"; +import setupStores from "@@/tests/test-utils/setupStores"; +import ApplicationErrorModule from "@/store/application-error"; +import { useApplicationError } from "@/composables/application-error.composable"; +import { + apiResponseErrorFactory, + axiosErrorFactory, +} from "@@/tests/test-utils"; + +jest.mock("@/utils/api"); +const mockedMapAxiosErrorToResponseError = jest.mocked( + mapAxiosErrorToResponseError +); + +jest.mock("@/composables/application-error.composable"); +const mockedCreateApplicationError = jest.mocked(useApplicationError); + +const setupErrorResponse = (message = "NOT_FOUND", code = 404) => { + const expectedPayload = apiResponseErrorFactory.build({ + message, + code, + }); + const responseError = axiosErrorFactory.build({ + response: { data: expectedPayload }, + }); + + return { + responseError, + expectedPayload, + }; +}; + +describe("useRoomEditState", () => { + let roomApiMock: DeepMocked; + let axiosMock: DeepMocked; + let mockedCreateApplicationErrorCalls: ReturnType; + + beforeEach(() => { + roomApiMock = createMock(); + axiosMock = createMock(); + + jest.spyOn(serverApi, "RoomApiFactory").mockReturnValue(roomApiMock); + initializeAxios(axiosMock); + + mockedCreateApplicationErrorCalls = + createMock>(); + mockedCreateApplicationError.mockReturnValue( + mockedCreateApplicationErrorCalls + ); + + setupStores({ + applicationErrorModule: ApplicationErrorModule, + }); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + const setup = () => { + const { fetchRoom, isLoading, updateRoom, roomData } = useRoomEditState(); + + const { expectedPayload } = setupErrorResponse(); + mockedMapAxiosErrorToResponseError.mockReturnValueOnce(expectedPayload); + + return { + fetchRoom, + isLoading, + updateRoom, + roomData, + }; + }; + + describe("fetchRoom", () => { + it("should fetchRoom api", async () => { + const { fetchRoom, isLoading } = setup(); + expect(isLoading.value).toBe(true); + + await fetchRoom("room-id"); + expect(roomApiMock.roomControllerGetRoomDetails).toHaveBeenCalledWith( + "room-id" + ); + expect(isLoading.value).toBe(false); + }); + + it("should throw an error when fetching room data fails", async () => { + const { fetchRoom, isLoading } = setup(); + roomApiMock.roomControllerGetRoomDetails.mockRejectedValue({ code: 404 }); + + expect(roomApiMock.roomControllerGetRoomDetails).not.toHaveBeenCalledWith( + "room-id" + ); + await expect(fetchRoom("room-id")).rejects.toThrow(); + expect(isLoading.value).toBe(false); + }); + }); + + describe("updateRoom", () => { + it("should call updateRoom api", async () => { + const { updateRoom, isLoading } = setup(); + expect(isLoading.value).toBe(true); + const params = { + name: "room-name", + color: serverApi.RoomColor.BlueGrey, + }; + + await updateRoom("room-id", params); + + expect(roomApiMock.roomControllerUpdateRoom).toHaveBeenCalledWith( + "room-id", + params + ); + + expect(isLoading.value).toBe(false); + }); + }); + + it("should throw an error when updating room data fails", async () => { + const { updateRoom, isLoading } = setup(); + const params = { + name: "room-name", + color: serverApi.RoomColor.BlueGrey, + }; + roomApiMock.roomControllerUpdateRoom.mockRejectedValue({ code: 404 }); + + expect(roomApiMock.roomControllerUpdateRoom).not.toHaveBeenCalledWith( + "room-id", + params + ); + + await updateRoom("room-id", params).catch(() => { + expect(mockedMapAxiosErrorToResponseError).toHaveBeenCalledWith({ + code: 404, + }); + }); + expect(isLoading.value).toBe(false); + }); +}); diff --git a/src/modules/data/room/Rooms.state.unit.ts b/src/modules/data/room/Rooms.state.unit.ts new file mode 100644 index 0000000000..f9fab51c5d --- /dev/null +++ b/src/modules/data/room/Rooms.state.unit.ts @@ -0,0 +1,136 @@ +import { createMock, DeepMocked } from "@golevelup/ts-jest"; +import { useRoomsState } from "./Rooms.state"; +import * as serverApi from "@/serverApi/v3/api"; +import { AxiosInstance } from "axios"; +import { useApplicationError } from "@/composables/application-error.composable"; +import { initializeAxios, mapAxiosErrorToResponseError } from "@/utils/api"; +import setupStores from "@@/tests/test-utils/setupStores"; +import ApplicationErrorModule from "@/store/application-error"; +import { + apiResponseErrorFactory, + axiosErrorFactory, +} from "@@/tests/test-utils"; + +jest.mock("@/utils/api"); +const mockedMapAxiosErrorToResponseError = jest.mocked( + mapAxiosErrorToResponseError +); + +jest.mock("@/composables/application-error.composable"); +const mockedCreateApplicationError = jest.mocked(useApplicationError); + +const setupErrorResponse = (message = "NOT_FOUND", code = 404) => { + const expectedPayload = apiResponseErrorFactory.build({ + message, + code, + }); + const responseError = axiosErrorFactory.build({ + response: { data: expectedPayload }, + }); + + return { + responseError, + expectedPayload, + }; +}; + +describe("useRoomsState", () => { + let roomApiMock: DeepMocked; + let axiosMock: DeepMocked; + let mockedCreateApplicationErrorCalls: ReturnType; + + beforeEach(() => { + roomApiMock = createMock(); + axiosMock = createMock(); + + jest.spyOn(serverApi, "RoomApiFactory").mockReturnValue(roomApiMock); + initializeAxios(axiosMock); + + mockedCreateApplicationErrorCalls = + createMock>(); + mockedCreateApplicationError.mockReturnValue( + mockedCreateApplicationErrorCalls + ); + + setupStores({ + applicationErrorModule: ApplicationErrorModule, + }); + }); + + afterEach(() => { + jest.resetAllMocks(); + }); + + const setup = () => { + const { fetchRooms, isLoading, deleteRoom } = useRoomsState(); + const { expectedPayload } = setupErrorResponse(); + mockedMapAxiosErrorToResponseError.mockReturnValueOnce(expectedPayload); + + return { fetchRooms, isLoading, deleteRoom }; + }; + + describe("fetchRooms", () => { + it("should call fetchRooms api", async () => { + const { fetchRooms, isLoading } = setup(); + expect(isLoading.value).toBe(true); + + await fetchRooms(); + + expect(roomApiMock.roomControllerGetRooms).toHaveBeenCalled(); + expect(isLoading.value).toBe(false); + }); + + it("should throw an error when fetching room data fails", async () => { + const { fetchRooms, isLoading } = setup(); + expect(isLoading.value).toBe(true); + roomApiMock.roomControllerGetRooms.mockRejectedValue({ code: 404 }); + + await expect(fetchRooms()).rejects.toThrow(); + expect(isLoading.value).toBe(false); + }); + }); + + describe("deleteRoom", () => { + it("should call deleteRoom api", async () => { + const { deleteRoom, isLoading } = setup(); + expect(isLoading.value).toBe(true); + + await deleteRoom("room-id"); + expect(roomApiMock.roomControllerDeleteRoom).toHaveBeenCalledWith( + "room-id" + ); + expect(isLoading.value).toBe(false); + }); + + it("should throw an error when fetching room data fails", async () => { + const { deleteRoom, isLoading } = setup(); + expect(isLoading.value).toBe(true); + roomApiMock.roomControllerDeleteRoom.mockRejectedValue({ code: 404 }); + + await expect(deleteRoom("room-id")).rejects.toThrow(); + expect(isLoading.value).toBe(false); + }); + }); + + describe("isEmpty", () => { + it("should return true when there are no rooms", () => { + const { isEmpty, rooms } = useRoomsState(); + rooms.value = []; + expect(isEmpty.value).toBe(true); + }); + + it("should return false when there are rooms", () => { + const { isEmpty, rooms } = useRoomsState(); + rooms.value = [ + { + id: "1", + name: "Room 1", + color: serverApi.RoomColor.BlueGrey, + createdAt: "2024.11.18", + updatedAt: "2024.11.18", + }, + ]; + expect(isEmpty.value).toBe(false); + }); + }); +}); diff --git a/src/modules/page/room/RoomCreate.page.unit.ts b/src/modules/page/room/RoomCreate.page.unit.ts new file mode 100644 index 0000000000..d99ac7b860 --- /dev/null +++ b/src/modules/page/room/RoomCreate.page.unit.ts @@ -0,0 +1,95 @@ +import { RoomCreateParams } from "@/types/room/Room"; +import { useRoomCreateState } from "@data-room"; +import { + createTestingI18n, + createTestingVuetify, +} from "@@/tests/test-utils/setup"; +import { RoomCreatePage } from "@page-room"; +import { useRouter } from "vue-router"; +import { RoomColor } from "@/serverApi/v3"; +import { RoomForm } from "@feature-room"; +import { NOTIFIER_MODULE_KEY } from "@/utils/inject"; +import { createModuleMocks } from "@@/tests/test-utils/mock-store-module"; +import NotifierModule from "@/store/notifier"; +import { flushPromises } from "@vue/test-utils"; + +jest.mock("vue-router", () => ({ + useRouter: jest.fn().mockReturnValue({ + push: jest.fn(), + }), +})); + +jest.mock("@data-room/RoomCreate.state.ts", () => ({ + useRoomCreateState: jest.fn().mockReturnValue({ + createRoom: jest.fn().mockResolvedValue({ + id: "123", + name: "test", + color: "blue", + }), + roomData: { + name: "test-room-data", + color: "blue", + }, + }), +})); + +jest.mock("@/utils/pageTitle", () => ({ + buildPageTitle: (pageTitle) => pageTitle ?? "", +})); + +const roomParams: RoomCreateParams = { + name: "test", + color: RoomColor.Blue, +}; + +describe("@pages/RoomCreate.page.vue", () => { + const setup = () => { + const notifierModule = createModuleMocks(NotifierModule); + + const wrapper = mount(RoomCreatePage, { + global: { + plugins: [createTestingVuetify(), createTestingI18n()], + provide: { + [NOTIFIER_MODULE_KEY.valueOf()]: notifierModule, + }, + }, + }); + + const { createRoom } = useRoomCreateState(); + const roomFormComponent = wrapper.findComponent(RoomForm); + + return { + wrapper, + router: useRouter(), + createRoom, + roomFormComponent, + }; + }; + + it("should have roomFormComponent", () => { + const { roomFormComponent } = setup(); + expect(roomFormComponent).toBeDefined(); + }); + + it("should call createRoom with correct parameters on save", async () => { + const { createRoom, roomFormComponent } = setup(); + roomFormComponent.vm.$emit("save", { room: roomParams }); + await flushPromises(); + expect(createRoom).toHaveBeenCalledWith(roomParams); + }); + + it("should navigate to 'room-details' with correct room id on save", async () => { + const { roomFormComponent, router } = setup(); + roomFormComponent.vm.$emit("save", roomParams); + expect(router.push).toHaveBeenCalledWith({ + name: "room-details", + params: { id: "123" }, + }); + }); + + it("should navigate to 'rooms' on cancel", async () => { + const { router, roomFormComponent } = setup(); + roomFormComponent.vm.$emit("cancel"); + expect(router.push).toHaveBeenCalledWith({ name: "rooms" }); + }); +}); diff --git a/src/modules/page/room/RoomDetails.page.unit.ts b/src/modules/page/room/RoomDetails.page.unit.ts new file mode 100644 index 0000000000..0014a1964f --- /dev/null +++ b/src/modules/page/room/RoomDetails.page.unit.ts @@ -0,0 +1,327 @@ +import * as serverApi from "@/serverApi/v3/api"; +import DefaultWireframe from "@/components/templates/DefaultWireframe.vue"; +import { RoomColor } from "@/serverApi/v3"; +import { AUTH_MODULE_KEY, ENV_CONFIG_MODULE_KEY } from "@/utils/inject"; +import EnvConfigModule from "@/store/env-config"; +import { envsFactory, mockedPiniaStoreTyping } from "@@/tests/test-utils"; +import { createModuleMocks } from "@@/tests/test-utils/mock-store-module"; +import { + createTestingI18n, + createTestingVuetify, +} from "@@/tests/test-utils/setup"; +import { RoomVariant, useRoomDetailsStore } from "@data-room"; +import { RoomDetailsPage } from "@page-room"; +import { createTestingPinia } from "@pinia/testing"; +import AuthModule from "@/store/auth"; +import { nextTick, Ref } from "vue"; +import { Breadcrumb } from "@/components/templates/default-wireframe.types"; +import setupStores from "@@/tests/test-utils/setupStores"; +import { roomDetailsFactory } from "@@/tests/test-utils/factory/roomDetailsFactory"; +import { flushPromises } from "@vue/test-utils"; +import { Router, useRoute, useRouter } from "vue-router"; +import { createMock } from "@golevelup/ts-jest"; + +jest.mock("vue-router", () => ({ + useRoute: jest.fn(), + useRouter: jest.fn(), +})); + +describe("@pages/RoomsDetails.page.vue", () => { + const router = createMock(); + const useRouteMock = useRoute; + useRouteMock.mockReturnValue({ params: { id: "room-id" }, push: jest.fn() }); + const useRouterMock = useRouter; + + beforeEach(() => { + useRouterMock.mockReturnValue(router); + setupStores({ + envConfigModule: EnvConfigModule, + }); + }); + + const setup = ( + { + isLoading, + roomVariant, + envs, + isTeacher, + hasEditPermission, + }: { + isLoading: boolean; + roomVariant?: RoomVariant; + envs?: Record; + isTeacher?: boolean; + hasEditPermission?: boolean; + } = { isLoading: false, roomVariant: RoomVariant.ROOM } + ) => { + const envConfigModule = createModuleMocks(EnvConfigModule, { + getEnv: envsFactory.build({ + FEATURE_BOARD_LAYOUT_ENABLED: true, + FEATURE_ROOMS_ENABLED: true, + ...envs, + }), + }); + + const authModule = createModuleMocks(AuthModule, { + getUserPermissions: hasEditPermission ? ["course_edit"] : [], + getUserRoles: !isTeacher ? ["teacher"] : [], + }); + + const wrapper = mount(RoomDetailsPage, { + global: { + plugins: [ + createTestingVuetify(), + createTestingI18n(), + createTestingPinia(), + ], + provide: { + [ENV_CONFIG_MODULE_KEY.valueOf()]: envConfigModule, + [AUTH_MODULE_KEY.valueOf()]: authModule, + }, + stubs: { + SelectBoardLayoutDialog: true, + CourseRoomDetailsPage: true, + }, + }, + router, + }); + + const roomDetailsStore = mockedPiniaStoreTyping(useRoomDetailsStore); + const room = roomDetailsFactory.build(); + roomDetailsStore.room = room; + roomDetailsStore.roomVariant = roomVariant; + roomDetailsStore.isLoading = isLoading; + roomDetailsStore.roomBoards = []; + + const wrapperVM = wrapper.vm as unknown as { + room: { + id: string; + name: string; + color: RoomColor; + createdAt: string; + updatedAt: string; + }; + pageTitle: string; + breadcrumbs: Breadcrumb[]; + fabItems: { + icon: string; + title: string; + ariaLabel: string; + testId: string; + }[]; + isMembersDialogOpen: boolean; + isRoom: Ref; + onFabClick: ReturnType; + boardLayoutsEnabled: Ref; + }; + + return { + wrapper, + roomDetailsStore, + wrapperVM, + authModule, + }; + }; + + describe("when page is mounted", () => { + it("should be rendered in DOM", () => { + const { wrapper } = setup(); + + expect(wrapper.vm).toBeDefined(); + }); + + it("should render DefaultWireframe", () => { + const { wrapper } = setup(); + + const defaultWireframe = wrapper.findComponent(DefaultWireframe); + expect(defaultWireframe).toBeDefined(); + }); + + describe("breadcrumbs", () => { + it("should have elements inside the list", () => { + const { wrapperVM } = setup(); + + expect(wrapperVM.breadcrumbs).toHaveLength(2); + expect(wrapperVM.breadcrumbs[0].title).toContain("pages.rooms.title"); + }); + + describe("when room is undefined", () => { + it("should not have any element inside the list", () => { + const { wrapperVM, roomDetailsStore } = setup(); + roomDetailsStore.room = undefined; + expect(wrapperVM.breadcrumbs).toHaveLength(0); + }); + }); + }); + + describe("pageTitle", () => { + it("should set the page title", async () => { + const { wrapperVM } = setup(); + expect(wrapperVM.pageTitle).toContain("pages.roomDetails.title"); + }); + }); + + describe("boardLayoutsEnabled", () => { + it("should be true", () => { + const { wrapperVM } = setup(); + expect(wrapperVM.boardLayoutsEnabled).toBe(true); + }); + + it("should be false", () => { + const { wrapperVM } = setup({ + envs: { FEATURE_BOARD_LAYOUT_ENABLED: false }, + isLoading: false, + }); + expect(wrapperVM.boardLayoutsEnabled).toBe(false); + }); + }); + + describe("when FEATURE_ROOMS_ENABLED flag is set true", () => { + it("should call fetchRoom on mounted", () => { + const { roomDetailsStore } = setup(); + + expect(roomDetailsStore.fetchRoom).toHaveBeenCalledWith("room-id"); + expect(roomDetailsStore.deactivateRoom).not.toHaveBeenCalled(); + }); + }); + + describe("when FEATURE_ROOMS_ENABLED flag is set false", () => { + it("should not call fetchRoom on mounted", () => { + const { roomDetailsStore } = setup({ + envs: { FEATURE_ROOMS_ENABLED: false }, + isLoading: false, + }); + + expect(roomDetailsStore.deactivateRoom).toHaveBeenCalled(); + expect(roomDetailsStore.fetchRoom).not.toHaveBeenCalled(); + }); + }); + }); + + describe("when loading", () => { + it("should render a loading indication", () => { + const { wrapper } = setup({ isLoading: true }); + + const div = wrapper.find("[data-testid=loading]"); + expect(div.exists()).toBe(true); + }); + }); + + describe("when roomVariant is invalid", () => { + it("should not render RoomDetails", () => { + const { wrapper } = setup({ + roomVariant: RoomVariant.COURSE_ROOM, + isLoading: false, + }); + + const roomDetailsComponent = wrapper.findComponent({ + name: "RoomDetails", + }); + expect(roomDetailsComponent.exists()).toBe(false); + }); + }); + + describe("when not loading", () => { + it("should not render a loading indication", async () => { + const { wrapper } = setup({ isLoading: false }); + await flushPromises(); + + const div = wrapper.find('[data-testid="loading"]'); + expect(div.exists()).toBe(false); + }); + + describe("when roomVariant is valid", () => { + it("should render DefaultLayout ", async () => { + const { wrapper } = setup({ + isLoading: false, + roomVariant: RoomVariant.ROOM, + }); + await flushPromises(); + + const defaultWireframe = wrapper.findComponent(DefaultWireframe); + expect(defaultWireframe.exists()).toBe(true); + }); + + describe("when user clicks on add content button", () => { + it("should open the select layout dialog", async () => { + const { wrapper } = setup({ + isLoading: false, + roomVariant: RoomVariant.ROOM, + }); + + await flushPromises(); + const defaultWireframe = wrapper.findComponent(DefaultWireframe); + defaultWireframe.vm.$emit("fabItemClick", "board-type-dialog-open"); + + const selectLayoutDialog = wrapper.findComponent({ + name: "SelectBoardLayoutDialog", + }); + expect(selectLayoutDialog.exists()).toBe(true); + }); + }); + + describe("when user creates a new board", () => { + it.each([ + { event: "multi-column", layout: "columns" }, + { event: "single-column", layout: "list" }, + ])( + "should have a '$layout'-layout when '$event' was chosen", + async ({ event, layout }) => { + const { wrapper } = setup({ + isLoading: false, + roomVariant: RoomVariant.ROOM, + }); + + await flushPromises(); + + const mockApi = { + boardControllerCreateBoard: jest + .fn() + .mockResolvedValue({ data: { id: "board-id" } }), + }; + const spy = jest + .spyOn(serverApi, "BoardApiFactory") + .mockReturnValue( + mockApi as unknown as serverApi.BoardApiInterface + ); + + const selectLayoutDialog = wrapper.findComponent({ + name: "SelectBoardLayoutDialog", + }); + + await selectLayoutDialog.vm.$emit(`select:${event}`); + + expect(mockApi.boardControllerCreateBoard).toHaveBeenCalledTimes(1); + expect(mockApi.boardControllerCreateBoard).toHaveBeenCalledWith( + expect.objectContaining({ layout }) + ); + + spy.mockRestore(); + } + ); + }); + + describe("when user clicks on edit room button", () => { + it("should navigate to the edit room page", async () => { + const { wrapper } = setup({ + isLoading: false, + roomVariant: RoomVariant.ROOM, + hasEditPermission: true, + }); + + await flushPromises(); + const defaultWireframe = wrapper.findComponent(DefaultWireframe); + const kebabMenu = defaultWireframe.find('[data-testid="room-menu"]'); + await kebabMenu.trigger("click"); + + const menus = wrapper.findAllComponents({ name: "VListItem" }); + + menus[0].vm.$emit("click"); + await nextTick(); + + expect(useRouteMock).toHaveBeenCalled(); + }); + }); + }); + }); +}); diff --git a/src/modules/page/room/RoomDetails.page.vue b/src/modules/page/room/RoomDetails.page.vue index 6cf03b0587..abb5a90f31 100644 --- a/src/modules/page/room/RoomDetails.page.vue +++ b/src/modules/page/room/RoomDetails.page.vue @@ -1,5 +1,14 @@ diff --git a/src/components/share/ShareModal.unit.ts b/src/components/share/ShareModal.unit.ts index 4af690eddd..1bc75a2555 100644 --- a/src/components/share/ShareModal.unit.ts +++ b/src/components/share/ShareModal.unit.ts @@ -1,22 +1,22 @@ -import ShareModule from "@/store/share"; -import { createModuleMocks } from "@@/tests/test-utils/mock-store-module"; -import { mount } from "@vue/test-utils"; -import ShareModal from "./ShareModal.vue"; import vCustomDialog from "@/components/organisms/vCustomDialog.vue"; import ShareModalOptionsForm from "@/components/share/ShareModalOptionsForm.vue"; import ShareModalResult from "@/components/share/ShareModalResult.vue"; import { ShareTokenBodyParamsParentTypeEnum } from "@/serverApi/v3"; +import EnvConfigModule from "@/store/env-config"; +import NotifierModule from "@/store/notifier"; +import ShareModule from "@/store/share"; import { ENV_CONFIG_MODULE_KEY, NOTIFIER_MODULE_KEY, SHARE_MODULE_KEY, } from "@/utils/inject"; -import NotifierModule from "@/store/notifier"; -import EnvConfigModule from "@/store/env-config"; +import { createModuleMocks } from "@@/tests/test-utils/mock-store-module"; import { createTestingI18n, createTestingVuetify, } from "@@/tests/test-utils/setup"; +import { mount } from "@vue/test-utils"; +import ShareModal from "./ShareModal.vue"; describe("@/components/share/ShareModal", () => { let shareModuleMock: ShareModule; @@ -137,11 +137,10 @@ describe("@/components/share/ShareModal", () => { `[data-testid="share-modal-external-tools-info"]` ); + expect(infotext.isVisible()).toBe(true); expect(infotext.text()).toEqual( - "components.molecules.share.courses.options.ctlTools.infotext" + "components.molecules.shareImport.options.ctlTools.infoText.unavailable" ); - - expect(infotext.isVisible()).toBe(true); }); }); diff --git a/src/components/share/ShareModal.vue b/src/components/share/ShareModal.vue index 06b3ef7a77..c33befa9a3 100644 --- a/src/components/share/ShareModal.vue +++ b/src/components/share/ShareModal.vue @@ -18,24 +18,72 @@
+

+ {{ t(`components.molecules.share.${type}.options.infoText`) }} +

-
- {{ t(`components.molecules.share.${type}.options.infoText`) }} -
- {{ t("components.molecules.copyResult.courseFiles.info") }} -
- {{ - t( - `components.molecules.share.courses.options.ctlTools.infotext` - ) - }} -
+
+ {{ t("components.molecules.share.options.tableHeader.InfoText") }} +
    +
  • + {{ + t( + "components.molecules.shareImport.options.restrictions.infoText.personalData" + ) + }} +
  • +
  • + {{ + t( + "components.molecules.shareImport.options.ctlTools.infoText.unavailable" + ) + }} +
  • +
  • + {{ + t( + "components.molecules.shareImport.options.ctlTools.infoText.protected" + ) + }} +
  • +
  • + {{ + t( + "components.molecules.shareImport.options.restrictions.infoText.courseFiles" + ) + }} +
  • +
  • + {{ + t( + "components.molecules.shareImport.options.restrictions.infoText.etherpad" + ) + }} +
  • +
  • + {{ + t( + "components.molecules.shareImport.options.restrictions.infoText.geogebra" + ) + }} +
  • +
  • + {{ + t( + "components.molecules.shareImport.options.restrictions.infoText.courseGroups" + ) + }} +
  • +
diff --git a/src/components/share/ShareModalResult.vue b/src/components/share/ShareModalResult.vue index 72e99c42dd..bde60a4dd6 100644 --- a/src/components/share/ShareModalResult.vue +++ b/src/components/share/ShareModalResult.vue @@ -5,6 +5,7 @@ :model-value="shareUrl" readonly :label="`${t(`components.molecules.share.${type}.result.linkLabel`)}`" + data-testid="share-course-result-url" />
Personenbezogene Daten werden nicht importiert.
Externe Tools werden nicht kopiert.
Der Kurs kann im Folgenden umbenannt werden.", - "components.molecules.import.courses.options.infoText": - "Es wird eine Kopie erstellt. Personenbezogene Daten werden nicht importiert. Der Kurs kann im Folgenden umbenannt werden.", - "components.molecules.import.courses.options.title": "Kurs importieren", + "components.molecules.import.courses.rename": + "Bei Bedarf kann der Name des Kurses umbenannt werden: ", + "components.molecules.import.courses.options.title": "Kurs-Kopie importieren", "components.molecules.import.lessons.label": "Thema", - "components.molecules.import.lessons.options.infoText": - "Es wird eine Kopie erstellt. Personenbezogene Daten werden nicht importiert. Das Thema kann im Folgenden umbenannt werden.", + "components.molecules.import.lessons.rename": + "Bei Bedarf kann der Name des Themas umbenannt werden: ", "components.molecules.import.lessons.options.selectCourse.infoText": "Der Kurs, in den das Thema importiert werden soll, muss im Folgenden ausgewählt werden.", "components.molecules.import.lessons.options.selectCourse": "Kurs wählen", @@ -613,9 +614,11 @@ export default { "components.molecules.import.options.loadingMessage": "Import läuft...", "components.molecules.import.options.success": "{name} wurde erfolgreich importiert", + "components.molecules.import.options.tableHeader.InfoText": + "Folgende Inhalte werden nicht importiert:", "components.molecules.import.tasks.label": "Aufgabe", - "components.molecules.import.tasks.options.infoText": - "Es wird eine Kopie erstellt. Personenbezogene Daten werden nicht importiert. Die Aufgabe kann im Folgenden umbenannt werden.", + "components.molecules.import.tasks.rename": + "Bei Bedarf kann der Name der Aufgabe umbenannt werden: ", "components.molecules.import.tasks.options.selectCourse.infoText": "Der Kurs, in den die Aufgabe importiert werden soll, muss im Folgenden ausgewählt werden.", "components.molecules.export.options.info": @@ -654,20 +657,34 @@ export default { "Organisationsleitung", "components.molecules.MintEcFooter.chapters": "Kapitelübersicht", "components.molecules.share.columnBoard.options.infoText": - "Mit dem folgenden Link kann der Bereich als Kopie von anderen Lehrkräften importiert werden. Personenbezogene Daten werden dabei nicht importiert.", + "Mit dem folgenden Link kann der Bereich als Kopie von anderen Lehrkräften importiert werden.", "components.molecules.share.columnBoard.result.linkLabel": "Link Bereich-Kopie", "components.molecules.share.courses.mail.body": "Link zum Kurs:", "components.molecules.share.courses.mail.subject": "Kurs zum Importieren", - "components.molecules.share.courses.options.ctlTools.infotext": - "Externe Tools, die dem Kurs oder Karten im Bereich zugeordnet sind, werden nicht kopiert.", + "components.molecules.shareImport.options.ctlTools.infoText.unavailable": + "In Zielschule nicht verfügbare, externe Tools", + "components.molecules.shareImport.options.ctlTools.infoText.protected": + "Geschützte Einstellungen externer Tools", "components.molecules.share.courses.options.infoText": - "Mit dem folgenden Link kann der Kurs als Kopie von anderen Lehrkräften importiert werden. Personenbezogene Daten werden dabei nicht importiert.", + "Mit dem folgenden Link kann der Kurs als Kopie von anderen Lehrkräften importiert werden.", + "components.molecules.shareImport.options.restrictions.infoText.personalData": + "Personenbezogene Daten", + "components.molecules.shareImport.options.restrictions.infoText.courseFiles": + "Dateien unter Kurs-Dateien", + "components.molecules.shareImport.options.restrictions.infoText.etherpad": + "Inhalte aus Etherpads", + "components.molecules.shareImport.options.restrictions.infoText.geogebra": + "Geogebra IDs und", + "components.molecules.shareImport.options.restrictions.infoText.courseGroups": + "Kursgruppen", + "components.molecules.share.options.tableHeader.InfoText": + "Folgende Inhalte werden nicht kopiert:", "components.molecules.share.courses.result.linkLabel": "Link Kurskopie", "components.molecules.share.lessons.mail.body": "Link zum Thema:", "components.molecules.share.lessons.mail.subject": "Thema zum Importieren", "components.molecules.share.lessons.options.infoText": - "Mit dem folgenden Link kann das Thema als Kopie von anderen Lehrkräften importiert werden. Personenbezogene Daten werden dabei nicht importiert.", + "Mit dem folgenden Link kann das Thema als Kopie von anderen Lehrkräften importiert werden.", "components.molecules.share.lessons.result.linkLabel": "Link Themakopie", "components.molecules.share.options.expiresInDays": "Link läuft nach 21 Tagen ab", @@ -681,7 +698,7 @@ export default { "components.molecules.share.tasks.mail.body": "Link zur Aufgabe:", "components.molecules.share.tasks.mail.subject": "Aufgabe zum Importieren", "components.molecules.share.tasks.options.infoText": - "Mit dem folgenden Link kann die Aufgabe als Kopie von anderen Lehrkräften importiert werden. Personenbezogene Daten werden dabei nicht importiert.", + "Mit dem folgenden Link kann die Aufgabe als Kopie von anderen Lehrkräften importiert werden.", "components.molecules.share.tasks.result.linkLabel": "Link Aufgabekopie", "components.molecules.TaskItemMenu.confirmDelete.text": 'Bist du dir sicher, dass du die Aufgabe "{taskTitle}" löschen möchtest?', diff --git a/src/locales/en.ts b/src/locales/en.ts index bf6a788f59..4e0c95d93b 100644 --- a/src/locales/en.ts +++ b/src/locales/en.ts @@ -515,7 +515,7 @@ export default { "components.molecules.copyResult.ctlTools.info": "External tools associated with the course and boarding cards are not copied.", "components.molecules.copyResult.ctlTools.withFeature.info": - "Protected parts of the tool configurations are not copied.", + "External tools and protected parts of the tool configurations that are not available in the target school are not copied.", "components.molecules.copyResult.etherpadCopy.info": "Content is not copied for data protection reasons and must be added again.", "components.molecules.copyResult.failedCopy": @@ -546,8 +546,9 @@ export default { "components.molecules.copyResult.label.tldraw": "Whiteboard", "components.molecules.copyResult.label.link": "Link", "components.molecules.copyResult.label.timeGroup": "Time Group", - "components.molecules.copyResult.label.unknown": "Unkown", + "components.molecules.copyResult.label.unknown": "Unknown", "components.molecules.copyResult.label.userGroup": "User Group", + "components.molecules.copyResult.label.toolElements": "Tool Element", "components.molecules.copyResult.metadata": "General Information", "components.molecules.copyResult.nexboardCopy.info": "Content is not copied for data protection reasons and must be added again.", @@ -576,6 +577,8 @@ export default { "components.molecules.EdusharingFooter.img_alt": "edusharing-logo", "components.molecules.EdusharingFooter.text": "powered by", "components.molecules.import.columnBoard.label": "Board title", + "components.molecules.import.columnBoard.rename": + "If necessary, the name of the board can be renamed: ", "components.molecules.import.columnBoard.options.infoText": "The board can be renamed below.", "components.molecules.import.columnBoard.options.title": "Import board", @@ -583,18 +586,16 @@ export default { "Select course", "components.molecules.import.columnBoard.options.selectCourse.infoText": "Please select the course into which you would like to import the board.", - "components.molecules.import.courses.label": "Course", + "components.molecules.import.courses.label": "Course name", "components.molecules.import.columnBoard.options.selectRoom": "Select room", "components.molecules.import.columnBoard.options.selectRoom.infoText": "Please select the room into which you would like to import the board.", - "components.molecules.import.courses.options.ctlTools.infoText": - "A copy will be created.
Personal data will not be imported.
External tools will not be copied.
The course can be renamed below.", - "components.molecules.import.courses.options.infoText": - "Participant-related data will not be copied. The course can be renamed below.", - "components.molecules.import.courses.options.title": "Import course", + "components.molecules.import.courses.rename": + "If necessary, the name of the course can be renamed: ", + "components.molecules.import.courses.options.title": "Import course copy", "components.molecules.import.lessons.label": "Topic", - "components.molecules.import.lessons.options.infoText": - "Participant-related data will not be copied. The topic can be renamed below.", + "components.molecules.import.lessons.rename": + "If necessary, the name of the topic can be renamed: ", "components.molecules.import.lessons.options.selectCourse.infoText": "Please select the course into which you would like to import the topic.", "components.molecules.import.lessons.options.selectCourse": "Select course", @@ -607,9 +608,11 @@ export default { "Unfortunately, the necessary authorization is missing.", "components.molecules.import.options.loadingMessage": "Import in progress...", "components.molecules.import.options.success": "{name} imported successfully", + "components.molecules.import.options.tableHeader.InfoText": + "The following content will not be imported:", "components.molecules.import.tasks.label": "Task", - "components.molecules.import.tasks.options.infoText": - "Participant-related data will not be copied. The task can be renamed below.", + "components.molecules.import.tasks.rename": + "If necessary, the name of the task can be renamed: ", "components.molecules.import.tasks.options.selectCourse.infoText": "Please select the course into which you would like to import the task.", "components.molecules.import.tasks.options.selectCourse": "Select course", @@ -642,18 +645,32 @@ export default { "components.molecules.MintEcFooter.chapters": "Chapter overview", "components.molecules.share.courses.mail.body": "Link to the course:", "components.molecules.share.courses.mail.subject": "Course you can import", - "components.molecules.share.courses.options.ctlTools.infotext": - "External tools associated with the course or boarding cards will not be copied.", + "components.molecules.shareImport.options.ctlTools.infoText.unavailable": + "External tools not available in the target school", + "components.molecules.shareImport.options.ctlTools.infoText.protected": + "Protected settings of external tools", "components.molecules.share.courses.options.infoText": - "With the following link, the course can be imported as a copy by other teachers. Personal data will not be imported.", + "With the following link, the course can be imported as a copy by other teachers.", + "components.molecules.shareImport.options.restrictions.infoText.personalData": + "Personal data", + "components.molecules.shareImport.options.restrictions.infoText.courseFiles": + "Files under Course Files", + "components.molecules.shareImport.options.restrictions.infoText.etherpad": + "Content from Etherpads", + "components.molecules.shareImport.options.restrictions.infoText.geogebra": + "Geogebra IDs and", + "components.molecules.shareImport.options.restrictions.infoText.courseGroups": + "Course groups", + "components.molecules.share.options.tableHeader.InfoText": + "The following content will not be copied:", "components.molecules.share.courses.result.linkLabel": "Link course copy", "components.molecules.share.lessons.mail.body": "Link to the topic:", "components.molecules.share.lessons.mail.subject": "Topic you can import", "components.molecules.share.lessons.options.infoText": - "With the following link, the topic can be imported as a copy by other teachers. Personal data will not be imported.", + "With the following link, the topic can be imported as a copy by other teachers.", "components.molecules.share.lessons.result.linkLabel": "Link topic copy", "components.molecules.share.columnBoard.options.infoText": - "With the following link, the board can be imported as a copy by other teachers. Personal data will not be imported.", + "With the following link, the board can be imported as a copy by other teachers.", "components.molecules.share.columnBoard.result.linkLabel": "Link to Board copy", "components.molecules.share.options.expiresInDays": diff --git a/src/locales/es.ts b/src/locales/es.ts index edcd7a5fc7..e7e9c76c7d 100644 --- a/src/locales/es.ts +++ b/src/locales/es.ts @@ -525,7 +525,7 @@ export default { "components.molecules.copyResult.ctlTools.info": "Las herramientas externas asociadas al curso y las tarjetas de embarque no se copian.", "components.molecules.copyResult.ctlTools.withFeature.info": - "Las partes protegidas de las configuraciones de herramientas no se copian.", + "Las herramientas externas y las partes protegidas de las configuraciones de herramientas que no están disponibles en la escuela de destino no se copian.", "components.molecules.copyResult.etherpadCopy.info": "El contenido no se copia por razones de protección de datos y debe agregarse nuevamente.", "components.molecules.copyResult.failedCopy": @@ -561,6 +561,8 @@ export default { "components.molecules.copyResult.label.timeGroup": "Grupo de tiempo", "components.molecules.copyResult.label.unknown": "Desconocido", "components.molecules.copyResult.label.userGroup": "Grupo de usuario", + "components.molecules.copyResult.label.toolElements": + "Elemento de herramienta", "components.molecules.copyResult.metadata": "Información general", "components.molecules.copyResult.nexboardCopy.info": "El contenido no se copia por razones de protección de datos y debe agregarse nuevamente.", @@ -589,6 +591,8 @@ export default { "components.molecules.EdusharingFooter.img_alt": "edusharing-logotipo", "components.molecules.EdusharingFooter.text": "desarrollado por", "components.molecules.import.columnBoard.label": "Título del tablero", + "components.molecules.import.columnBoard.rename": + "Si es necesario, se puede cambiar el nombre del tablero: ", "components.molecules.import.columnBoard.options.infoText": "Puede cambiar el nombre del tablero a continuación.", "components.molecules.import.columnBoard.options.title": "Importar tablero", @@ -596,19 +600,17 @@ export default { "Elija el curso", "components.molecules.import.columnBoard.options.selectCourse.infoText": "Seleccione el curso al que desea importar el tablero.", - "components.molecules.import.courses.label": "Curso", + "components.molecules.import.courses.label": "Nombre del curso", "components.molecules.import.columnBoard.options.selectRoom": "Seleccionar sala", "components.molecules.import.columnBoard.options.selectRoom.infoText": "Seleccione la sala en la que desea importar el tablero.", - "components.molecules.import.courses.options.ctlTools.infoText": - "Se creará una copia.
No se importarán datos personales.
No se copiarán herramientas externas.
Se puede cambiar el nombre del curso a continuación.", - "components.molecules.import.courses.options.infoText": - "Los datos relacionados con los participantes no se copiarán. El curso se puede renombrar a continuación.", - "components.molecules.import.courses.options.title": "Importar curso", + "components.molecules.import.courses.rename": + "Si es necesario, se puede cambiar el nombre del curso: ", + "components.molecules.import.courses.options.title": "Importar copia nuestra", "components.molecules.import.lessons.label": "Tema", - "components.molecules.import.lessons.options.infoText": - "Los datos relacionados con los participantes no se copiarán. El tema se puede renombrar a continuación.", + "components.molecules.import.lessons.rename": + "Si es necesario, se puede cambiar el nombre del tema: ", "components.molecules.import.lessons.options.selectCourse.infoText": "Seleccione el curso al que desea importar el tema.", "components.molecules.import.lessons.options.selectCourse": "Elija el curso", @@ -622,9 +624,11 @@ export default { "components.molecules.import.options.loadingMessage": "Importación en curso...", "components.molecules.import.options.success": "{name} importado con éxito", + "components.molecules.import.options.tableHeader.InfoText": + "No se importará el siguiente contenido:", "components.molecules.import.tasks.label": "Tarea", - "components.molecules.import.tasks.options.infoText": - "Los datos relacionados con los participantes no se copiarán. La tarea se puede renombrar a continuación.", + "components.molecules.import.tasks.rename": + "Si es necesario, se puede cambiar el nombre de la tarea: ", "components.molecules.import.tasks.options.selectCourse.infoText": "Seleccione el curso al que desea importar la tarea.", "components.molecules.import.tasks.options.selectCourse": "Elija el curso", @@ -661,16 +665,32 @@ export default { "Enlace a la copia del tablón", "components.molecules.share.courses.mail.body": "Enlace al curso:", "components.molecules.share.courses.mail.subject": "Curso de importación", - "components.molecules.share.courses.options.ctlTools.infotext": + "components.molecules.share.courses.options.ctlTools.infoText": "No se copiarán herramientas externas asociadas al curso ni tarjetas de embarque.", + "components.molecules.shareImport.options.ctlTools.infoText.unavailable": + "Herramientas externas no disponibles en la escuela de destino", + "components.molecules.shareImport.options.ctlTools.infoText.protected": + "Configuraciones protegidas de herramientas externas", "components.molecules.share.courses.options.infoText": - "Con el siguiente enlace, el curso puede ser importado como copia por otros profesores. Los datos personales no se importarán.", + "Utilizando el siguiente enlace, otros profesores pueden importar el curso como una copia.", + "components.molecules.shareImport.options.restrictions.infoText.personalData": + "Datos personales", + "components.molecules.shareImport.options.restrictions.infoText.courseFiles": + "Archivos en Archivos de curso", + "components.molecules.shareImport.options.restrictions.infoText.etherpad": + "Contenido de Etherpads", + "components.molecules.shareImport.options.restrictions.infoText.geogebra": + "ID de Geogebra y", + "components.molecules.shareImport.options.restrictions.infoText.courseGroups": + "Grupos de cursos", + "components.molecules.share.options.tableHeader.InfoText": + "No se copiará el siguiente contenido:", "components.molecules.share.courses.result.linkLabel": "Enlace a la copia del curso", "components.molecules.share.lessons.mail.body": "Enlace al tema:", "components.molecules.share.lessons.mail.subject": "Tema de importación", "components.molecules.share.lessons.options.infoText": - "Con el siguiente enlace, el tema puede ser importado como copia por otros profesores. Los datos personales no se importarán.", + "Con el siguiente enlace, el tema puede ser importado como copia por otros profesores.", "components.molecules.share.lessons.result.linkLabel": "Enlace a la copia del tema", "components.molecules.share.options.expiresInDays": @@ -685,7 +705,7 @@ export default { "components.molecules.share.tasks.mail.body": "Enlace a la tarea:", "components.molecules.share.tasks.mail.subject": "Tarea de importación", "components.molecules.share.tasks.options.infoText": - "Con el siguiente enlace, la tarea puede ser importado como copia por otros profesores. Los datos personales no se importarán.", + "Con el siguiente enlace, la tarea puede ser importado como copia por otros profesores.", "components.molecules.share.tasks.result.linkLabel": "Enlace a la copia de la tarea", "components.molecules.TaskItemMenu.confirmDelete.text": diff --git a/src/locales/uk.ts b/src/locales/uk.ts index b2b2ad29dd..7f754142b3 100644 --- a/src/locales/uk.ts +++ b/src/locales/uk.ts @@ -523,7 +523,7 @@ export default { "components.molecules.copyResult.ctlTools.info": "Зовнішні інструменти, пов’язані з курсом, і посадкові картки не копіюються.", "components.molecules.copyResult.ctlTools.withFeature.info": - "Захищені частини конфігурацій інструменту не копіюються.", + "Зовнішні інструменти та захищені частини конфігурацій інструментів, які недоступні в цільовій школі, не копіюються.", "components.molecules.copyResult.etherpadCopy.info": "Вміст не копіюється з міркувань захисту даних і повинен бути доданий повторно.", "components.molecules.copyResult.failedCopy": @@ -557,6 +557,8 @@ export default { "components.molecules.copyResult.label.timeGroup": "Група часу", "components.molecules.copyResult.label.unknown": "Невідомий", "components.molecules.copyResult.label.userGroup": "Група користувачів", + "components.molecules.copyResult.label.toolElements": + "Інструментальний елемент", "components.molecules.copyResult.metadata": "Загальна інформація", "components.molecules.copyResult.nexboardCopy.info": "Вміст не копіюється з міркувань захисту даних і повинен бути доданий повторно.", @@ -585,6 +587,8 @@ export default { "components.molecules.EdusharingFooter.img_alt": "логотип edusharing", "components.molecules.EdusharingFooter.text": "на платформі", "components.molecules.import.columnBoard.label": "Назва дошки", + "components.molecules.import.columnBoard.rename": + "При необхідності назву дошки можна змінити: ", "components.molecules.import.columnBoard.options.infoText": "Ви можете перейменувати дошку нижче", "components.molecules.import.columnBoard.options.title": "Дошка імпорту", @@ -592,19 +596,18 @@ export default { "Оберіть курс", "components.molecules.import.columnBoard.options.selectCourse.infoText": "Виберіть курс, до якого ви бажаєте імпортувати дошку.", - "components.molecules.import.courses.label": "Курс", + "components.molecules.import.courses.label": "Назва курсу", "components.molecules.import.columnBoard.options.selectRoom": "Оберіть кімнату", "components.molecules.import.columnBoard.options.selectRoom.infoText": "Виберіть кімнату, до якого ви бажаєте імпортувати дошку.", - "components.molecules.import.courses.options.ctlTools.infoText": - "Буде створено копію.
собисті дані не будуть імпортовані.
Зовнішні інструменти не будуть скопійовані.
Курс можна перейменувати нижче.", - "components.molecules.import.courses.options.infoText": - "Дані учасників не будуть скопійовані. Курс можна перейменувати нижче.", - "components.molecules.import.courses.options.title": "Курс імпорту", + "components.molecules.import.courses.rename": + "При необхідності назву курсу можна перейменувати: ", + "components.molecules.import.courses.options.title": + "Імпортувати копію курсу", "components.molecules.import.lessons.label": "Тема", - "components.molecules.import.lessons.options.infoText": - "Дані учасників не будуть скопійовані. Тема можна перейменувати нижче.", + "components.molecules.import.lessons.rename": + "При необхідності назву теми можна перейменувати: ", "components.molecules.import.lessons.options.selectCourse.infoText": "Будь ласка, оберіть курс з якого ви хочете імпортувати тему", "components.molecules.import.lessons.options.selectCourse": "Оберіть курс", @@ -618,9 +621,11 @@ export default { "components.molecules.import.options.loadingMessage": "Виконується імпорту...", "components.molecules.import.options.success": "{name} успішно імпортовано", + "components.molecules.import.options.tableHeader.InfoText": + "Наступний вміст не буде імпортовано:", "components.molecules.import.tasks.label": "Завдання", - "components.molecules.import.tasks.options.infoText": - "Дані, що стосуються учасників, не копіюються. Завдання можна перейменувати нижче.", + "components.molecules.import.tasks.rename": + "При необхідності назву завдання можна перейменувати: ", "components.molecules.import.tasks.options.selectCourse.infoText": "Виберіть курс, до якого ви хочете імпортувати завдання.", "components.molecules.import.tasks.options.selectCourse": "Оберіть курс", @@ -658,17 +663,33 @@ export default { "Посилання на копію дошки", "components.molecules.share.courses.mail.body": "Посилання на курс:", "components.molecules.share.courses.mail.subject": "Курс імпорту", - "components.molecules.share.courses.options.ctlTools.infotext": + "components.molecules.share.courses.options.ctlTools.infoText": "Зовнішні інструменти, пов’язані з курсом або посадочними картками, не будуть скопійовані.", + "components.molecules.shareImport.options.ctlTools.infoText.unavailable": + "Зовнішні інструменти недоступні в цільовій школі", + "components.molecules.shareImport.options.ctlTools.infoText.protected": + "Захищені налаштування зовнішніх інструментів", "components.molecules.share.courses.options.infoText": - "За наступним посиланням курс може бути імпортований як копія іншими викладачами. Персональні дані не імпортуються.", + "Використовуючи наступне посилання, курс може бути імпортований як копія іншими викладачами.", + "components.molecules.shareImport.options.restrictions.infoText.personalData": + "Персональні дані", + "components.molecules.shareImport.options.restrictions.infoText.courseFiles": + "Файли в розділі Файли курсу", + "components.molecules.shareImport.options.restrictions.infoText.etherpad": + "Вміст із Etherpads", + "components.molecules.shareImport.options.restrictions.infoText.geogebra": + "Ідентифікатори Geogebra та", + "components.molecules.shareImport.options.restrictions.infoText.courseGroups": + "Групи курсів", + "components.molecules.share.options.tableHeader.InfoText": + "Наступний вміст не буде скопійовано:", "components.molecules.share.courses.result.linkLabel": "Посилання на копію курсу", "components.molecules.share.lessons.mail.body": "Посилання на курс:", "components.molecules.share.lessons.mail.subject": "Теми, які можна імпортувати", "components.molecules.share.lessons.options.infoText": - "За наступним посиланням тему можуть імпортувати як копію інші вчителі. Особисті дані не будуть імпортовані.", + "За наступним посиланням тему можуть імпортувати як копію інші вчителі.", "components.molecules.share.lessons.result.linkLabel": "Копія теми посилання", "components.molecules.share.options.expiresInDays": "Термін дії посилання закінчується через 21 днів", @@ -683,7 +704,7 @@ export default { "components.molecules.share.tasks.mail.subject": "Завдання, які можна імпортувати", "components.molecules.share.tasks.options.infoText": - "За наступним посиланням завдання можуть імпортувати як копію інші вчителі. Особисті дані не будуть імпортовані.", + "За наступним посиланням завдання можуть імпортувати як копію інші вчителі.", "components.molecules.share.tasks.result.linkLabel": "Зв'язати копію завдання", "components.molecules.TaskItemMenu.confirmDelete.text": diff --git a/src/modules/feature/board-deleted-element/DeletedElement.vue b/src/modules/feature/board-deleted-element/DeletedElement.vue index 3460ff13d0..454a4923cb 100644 --- a/src/modules/feature/board-deleted-element/DeletedElement.vue +++ b/src/modules/feature/board-deleted-element/DeletedElement.vue @@ -8,20 +8,6 @@ ref="deletedElement" :ripple="false" > - - {{ - $t( - "components.cardElement.deletedElement.warning.externalToolElement", - { - toolName: element.content.title, - } - ) - }} - + + {{ + $t( + "components.cardElement.deletedElement.warning.externalToolElement", + { + toolName: element.content.title, + } + ) + }} + diff --git a/src/store/copy.ts b/src/store/copy.ts index a11675d548..3bbb9a45fc 100644 --- a/src/store/copy.ts +++ b/src/store/copy.ts @@ -216,6 +216,7 @@ export default class CopyModule extends VuexModule { if (type === CopyApiResponseTypeEnum.DrawingElement) return true; if (type === CopyApiResponseTypeEnum.CollaborativeTextEditorElement) return true; + if (type === CopyApiResponseTypeEnum.ExternalToolElement) return true; return false; }; From a0274e5d1fc195b53902e2c270bb9538fd276927 Mon Sep 17 00:00:00 2001 From: NFriedo <69233063+NFriedo@users.noreply.github.com> Date: Tue, 10 Dec 2024 10:43:37 +0100 Subject: [PATCH 08/11] BC-8500 - fix removing members in members table (#3465) * fix deletion of members via the delete icon in the table * refactor: roomMemberlist handling --------- Co-authored-by: hoeppner.dataport --- .../room/RoomMembers/MembersTable.unit.ts | 444 +++++++++++++----- .../feature/room/RoomMembers/MembersTable.vue | 89 ++-- .../page/room/RoomMembers.page.unit.ts | 444 ++++++++---------- src/modules/page/room/RoomMembers.page.vue | 39 +- 4 files changed, 588 insertions(+), 428 deletions(-) diff --git a/src/modules/feature/room/RoomMembers/MembersTable.unit.ts b/src/modules/feature/room/RoomMembers/MembersTable.unit.ts index b7db2ddbeb..a0da092c36 100644 --- a/src/modules/feature/room/RoomMembers/MembersTable.unit.ts +++ b/src/modules/feature/room/RoomMembers/MembersTable.unit.ts @@ -3,175 +3,375 @@ import { createTestingVuetify, } from "@@/tests/test-utils/setup"; import MembersTable from "./MembersTable.vue"; -import { Ref } from "vue"; +import { ref } from "vue"; import { mdiMenuDown, mdiMenuUp, mdiMagnify } from "@icons/material"; import { roomMemberResponseFactory } from "@@/tests/test-utils"; -import { RoomMember } from "@data-room"; -import { flushPromises } from "@vue/test-utils"; +import { DOMWrapper, VueWrapper } from "@vue/test-utils"; +import { VDataTable, VTextField } from "vuetify/lib/components/index.mjs"; +import { useConfirmationDialog } from "@ui-confirmation-dialog"; +import setupConfirmationComposableMock from "@@/tests/test-utils/composable-mocks/setupConfirmationComposableMock"; -const mockMembers = roomMemberResponseFactory.buildList(3); +jest.mock("@ui-confirmation-dialog"); +const mockedUseRemoveConfirmationDialog = jest.mocked(useConfirmationDialog); describe("MembersTable", () => { + let askConfirmationMock: jest.Mock; + + beforeEach(() => { + askConfirmationMock = jest.fn(); + setupConfirmationComposableMock({ + askConfirmationMock, + }); + mockedUseRemoveConfirmationDialog.mockReturnValue({ + askConfirmation: askConfirmationMock, + isDialogOpen: ref(false), + }); + }); + + const tableHeaders = [ + "common.labels.firstName", + "common.labels.lastName", + "common.labels.role", + "common.words.mainSchool", + "", + ]; + const setup = () => { + const mockMembers = roomMemberResponseFactory.buildList(3); const wrapper = mount(MembersTable, { + attachTo: document.body, global: { plugins: [createTestingVuetify(), createTestingI18n()], }, - props: { members: mockMembers, selectedMembers: [] }, + props: { members: mockMembers }, }); - const wrapperVM = wrapper.vm as unknown as { - members: RoomMember[]; - search: Ref; - tableTitle: string; - tableHeader: { title: string; key: string }[]; - selectedMemberList: string[]; - }; + return { wrapper, mockMembers }; + }; + + // index 0 is the header checkbox + const selectCheckboxes = async (indices: number[], wrapper: VueWrapper) => { + const dataTable = wrapper.getComponent(VDataTable); + const checkboxes = dataTable.findAll("input[type='checkbox']"); - return { wrapper, wrapperVM }; + for (const index of indices) { + const checkbox = checkboxes[index]; + await checkbox.trigger("click"); + } + + return { checkboxes }; }; - describe("when component is mounted", () => { - it("should render member's table", () => { + const getCheckedIndices = (checkboxes: DOMWrapper[]) => + checkboxes.reduce((selectedIndices, checkbox, index) => { + if (checkbox.attributes("checked") === "") { + selectedIndices.push(index); + } + return selectedIndices; + }, [] as Array); + + it("should render members table component", () => { + const { wrapper } = setup(); + + expect(wrapper.exists()).toBe(true); + }); + + it("should render data table", () => { + const { wrapper, mockMembers } = setup(); + + const dataTable = wrapper.getComponent(VDataTable); + + expect(dataTable.props("headers")!.map((header) => header.title)).toEqual( + tableHeaders + ); + expect(dataTable.props("items")).toEqual(mockMembers); + expect(dataTable.props("sortAscIcon")).toEqual(mdiMenuDown); + expect(dataTable.props("sortDescIcon")).toEqual(mdiMenuUp); + }); + + it("should render checkboxes", async () => { + const { wrapper, mockMembers } = setup(); + + const dataTable = wrapper.findComponent(VDataTable); + const checkboxes = dataTable.findAll("input[type='checkbox']"); + + expect(checkboxes.length).toEqual(mockMembers.length + 1); // all checkboxes including header checkbox + }); + + describe("when selecting members", () => { + it("should select all members when header checkbox is clicked", async () => { const { wrapper } = setup(); - expect(wrapper.exists()).toBe(true); - expect(wrapper.findComponent(MembersTable)).toBeTruthy(); + const { checkboxes } = await selectCheckboxes([0], wrapper); + const checkedIndices = getCheckedIndices(checkboxes); + + const expectedIndices = [0, 1, 2, 3]; + + expect(checkedIndices).toEqual(expectedIndices); }); - }); - describe("DataTable component", () => { - it("should render the table component", () => { - const { wrapper, wrapperVM } = setup(); - const dataTable = wrapper.findComponent({ name: "v-data-table" }); + it("should emit select:members", async () => { + const { wrapper, mockMembers } = setup(); + + await selectCheckboxes([1], wrapper); + + const selectEvents = wrapper.emitted("select:members"); + expect(selectEvents).toHaveLength(1); + expect(selectEvents![0]).toEqual([[mockMembers[0].userId]]); + }); + + it("should render the multi action menu", async () => { + const { wrapper } = setup(); + + await selectCheckboxes([1], wrapper); + + const multiActionMenu = wrapper.find("[data-testid=multi-action-menu]"); + + expect(multiActionMenu.exists()).toBe(true); + }); + + it("should render selected members remove button", async () => { + const { wrapper } = setup(); + + await selectCheckboxes([1], wrapper); + + const removeButton = wrapper.findComponent({ + ref: "removeSelectedMembers", + }); + + expect(removeButton.exists()).toBe(true); + }); + + it("should render selected members reset button", async () => { + const { wrapper } = setup(); + + await selectCheckboxes([1, 2], wrapper); + + const resetButton = wrapper.findComponent({ + ref: "resetSelectedMembers", + }); + + expect(resetButton.exists()).toBe(true); + }); + + it("should reset member selection when clicking reset button", async () => { + const { wrapper } = setup(); + + askConfirmationMock.mockResolvedValue(false); + + await selectCheckboxes([0], wrapper); + + const resetButton = wrapper.findComponent({ + ref: "resetSelectedMembers", + }); + await resetButton.trigger("click"); + + const checkboxes = wrapper + .getComponent(VDataTable) + .findAll("input[type='checkbox']"); + + const checkedIndices = getCheckedIndices(checkboxes); + + expect(checkedIndices).toEqual([]); + }); + + it.each([ + { + description: "one member", + checkboxesToSelect: [1], + }, + { + description: "multiple members", + checkboxesToSelect: [1, 2], + }, + ])( + "should render number of selected users in multi action menu, when $description selected", + async ({ checkboxesToSelect }) => { + const { wrapper } = setup(); + + await selectCheckboxes(checkboxesToSelect, wrapper); + + const multiActionMenu = wrapper.get("[data-testid=multi-action-menu]"); + + expect(multiActionMenu.text()).toBe( + `${checkboxesToSelect.length} pages.administration.selected` + ); + } + ); + + it("should emit remove:members when selected members remove button is clicked", async () => { + const { wrapper, mockMembers } = setup(); + + askConfirmationMock.mockResolvedValue(true); + + await selectCheckboxes([1], wrapper); + + const removeButton = wrapper.findComponent({ + ref: "removeSelectedMembers", + }); + await removeButton.trigger("click"); + + const removeEvents = wrapper.emitted("remove:members"); + expect(removeEvents).toHaveLength(1); + expect(removeEvents![0]).toEqual([[mockMembers[0].userId]]); + }); + + it("should not emit remove:members event when remove was cancled", async () => { + const { wrapper } = setup(); + + askConfirmationMock.mockResolvedValue(false); - expect(dataTable).toBeTruthy(); - expect(dataTable.vm.items).toEqual(mockMembers); - expect(dataTable.vm.headers).toEqual(wrapperVM.tableHeader); - expect(dataTable.vm["sortAscIcon"]).toEqual(mdiMenuDown); - expect(dataTable.vm["sortDescIcon"]).toEqual(mdiMenuUp); + await selectCheckboxes([1], wrapper); + + const removeButton = wrapper.findComponent({ + ref: "removeSelectedMembers", + }); + await removeButton.trigger("click"); + + expect(wrapper.emitted()).not.toHaveProperty("remove:members"); }); - describe("when the remove button is clicked", () => { - it("should emit the remove event", async () => { + it.each([ + { + description: "single member", + checkboxesToSelect: [1], + expectedMessage: "pages.rooms.members.remove.confirmation", + }, + { + description: "multiple members", + checkboxesToSelect: [1, 2], + expectedMessage: "pages.rooms.members.multipleRemove.confirmation", + }, + ])( + "should render confirmation dialog with text for $description when remove button is clicked", + async ({ checkboxesToSelect, expectedMessage }) => { const { wrapper } = setup(); + + askConfirmationMock.mockResolvedValue(true); + + await selectCheckboxes(checkboxesToSelect, wrapper); + const removeButton = wrapper.findComponent({ - name: "v-btn", - ref: "removeMember", + ref: "removeSelectedMembers", }); + await removeButton.trigger("click"); - await removeButton.vm.$emit("click"); expect(wrapper.emitted()).toHaveProperty("remove:members"); + + expect(askConfirmationMock).toHaveBeenCalledWith({ + confirmActionLangKey: "common.actions.remove", + message: expectedMessage, + }); + } + ); + + it("should keep selection if confirmation dialog is canceled", async () => { + const { wrapper } = setup(); + + askConfirmationMock.mockResolvedValue(false); + + await selectCheckboxes([1], wrapper); + + const removeButton = wrapper.getComponent({ + ref: "removeSelectedMembers", }); + await removeButton.trigger("click"); + + const checkboxes = wrapper + .getComponent(VDataTable) + .findAll("input[type='checkbox']"); + + const checkedIndices = getCheckedIndices(checkboxes); + + expect(checkedIndices).toEqual([1]); + }); + }); + + describe("when no members are selected", () => { + it("should not render multi action menu when no members are selected", async () => { + const { wrapper } = setup(); + const multiActionMenu = wrapper.find("[data-testid=multi-action-menu]"); + + expect(multiActionMenu.exists()).toBe(false); }); - describe("multiple selection", () => { - it("should render checkBoxes", async () => { + describe("when the remove button in the user row is clicked", () => { + const triggerMemberRemoval = async ( + index: number, + wrapper: VueWrapper + ) => { + const dataTable = wrapper.getComponent(VDataTable); + const removeButton = dataTable.findComponent( + `[data-testid=remove-member-${index}]` + ); + + await removeButton.trigger("click"); + }; + + it("should open confirmation dialog with remove message for single member ", async () => { const { wrapper } = setup(); - const dataTable = wrapper.findComponent({ name: "v-data-table" }); - const checkBoxes = dataTable.findAll("tr input[type='checkbox']"); - expect(checkBoxes.length).toBeGreaterThan(0); - }); + askConfirmationMock.mockResolvedValue(true); - describe("when checkboxes are clicked", () => { - it("should set the selectedMembers", async () => { - const { wrapper, wrapperVM } = setup(); - const dataTable = wrapper.findComponent({ name: "v-data-table" }); - expect(wrapperVM.selectedMemberList.length).toStrictEqual(0); - dataTable.vm.$emit("update:modelValue", [ - mockMembers[0].userId, - mockMembers[1].userId, - ]); - - expect(wrapperVM.selectedMemberList).toStrictEqual([ - mockMembers[0].userId, - mockMembers[1].userId, - ]); - }); + await triggerMemberRemoval(0, wrapper); - describe("bulk remove button", () => { - it("should be visible", async () => { - const { wrapper } = setup(); - const dataTable = wrapper.findComponent({ name: "v-data-table" }); - const bulkRemoveButtonBefore = wrapper.findComponent({ - ref: "removeSelectedMembers", - }); - - expect(bulkRemoveButtonBefore.exists()).toBe(false); - dataTable.vm.$emit("update:modelValue", [ - mockMembers[0].userId, - mockMembers[1].userId, - ]); - await flushPromises(); - const bulkRemoveButtonAfter = wrapper.findComponent({ - ref: "removeSelectedMembers", - }); - expect(bulkRemoveButtonAfter.exists()).toBe(true); - }); - - describe("when the bulk remove button is clicked", () => { - it("should emit the 'remove:members'", async () => { - const { wrapper } = setup(); - const dataTable = wrapper.findComponent({ name: "v-data-table" }); - dataTable.vm.$emit("update:modelValue", [ - mockMembers[0].userId, - mockMembers[1].userId, - ]); - await flushPromises(); - const bulkRemoveButton = wrapper.findComponent({ - ref: "removeSelectedMembers", - }); - await bulkRemoveButton.vm.$emit("click"); - expect(wrapper.emitted()).toHaveProperty("remove:members"); - }); - }); + expect(askConfirmationMock).toHaveBeenCalledWith({ + confirmActionLangKey: "common.actions.remove", + message: "pages.rooms.members.remove.confirmation", }); + }); - describe("when reset button is clicked", () => { - it("should reset the selected members", async () => { - const { wrapper, wrapperVM } = setup(); - const dataTable = wrapper.findComponent({ name: "v-data-table" }); - dataTable.vm.$emit("update:modelValue", [ - mockMembers[0].userId, - mockMembers[1].userId, - ]); - await flushPromises(); - expect(wrapperVM.selectedMemberList).toStrictEqual([ - mockMembers[0].userId, - mockMembers[1].userId, - ]); - const resetButton = wrapper.findComponent({ - ref: "resetSelectedMembers", - }); - resetButton.vm.$emit("click"); - expect(wrapperVM.selectedMemberList).toStrictEqual([]); - }); - }); + it("should call remove:members event after confirmation", async () => { + const { wrapper, mockMembers } = setup(); + + askConfirmationMock.mockResolvedValue(true); + + await triggerMemberRemoval(0, wrapper); + + expect(wrapper.emitted()).toHaveProperty("remove:members"); + + const removeEvents = wrapper.emitted("remove:members"); + expect(removeEvents).toHaveLength(1); + expect(removeEvents![0]).toEqual([[mockMembers[0].userId]]); + }); + + it("should not call remove:members event when dialog is cancelled", async () => { + const { wrapper } = setup(); + + askConfirmationMock.mockResolvedValue(false); + + await triggerMemberRemoval(0, wrapper); + + expect(wrapper.emitted()).not.toHaveProperty("remove:members"); }); }); }); - describe("Search component", () => { + describe("when searching for members", () => { it("should render the search component", () => { - const { wrapper, wrapperVM } = setup(); - const search = wrapper.findComponent({ name: "v-text-field" }); + const { wrapper } = setup(); - expect(search).toBeTruthy(); - expect(search.vm["label"]).toEqual("common.labels.search"); - expect(search.vm["prependInnerIcon"]).toEqual(mdiMagnify); - expect(search.vm["vModel"]).toEqual(wrapperVM.search.value); + const search = wrapper.getComponent(VTextField); + + expect(search.props("label")).toEqual("common.labels.search"); + expect(search.props("prependInnerIcon")).toEqual(mdiMagnify); }); it("should filter the members based on the search value", async () => { - const { wrapper, wrapperVM } = setup(); - const search = wrapper.findComponent({ name: "v-text-field" }); + const { wrapper, mockMembers } = setup(); + + const search = wrapper.getComponent(VTextField); + const searchValue = mockMembers[0].firstName; + + await search.setValue(searchValue); - await search.vm.$emit("update:modelValue", mockMembers[0].firstName); - expect(wrapperVM.search).toBe(mockMembers[0].firstName); - const dataTable = wrapper.findComponent({ name: "v-data-table" }); + const dataTable = wrapper.getComponent(VDataTable); + const dataTableTextContent = dataTable.text(); - expect(dataTable.vm.search).toEqual(mockMembers[0].firstName); + expect(dataTable.props("search")).toEqual(searchValue); + expect(dataTableTextContent).toContain(mockMembers[0].firstName); + expect(dataTableTextContent).not.toContain(mockMembers[1].firstName); + expect(dataTableTextContent).not.toContain(mockMembers[2].firstName); }); }); }); diff --git a/src/modules/feature/room/RoomMembers/MembersTable.vue b/src/modules/feature/room/RoomMembers/MembersTable.vue index d227baafea..d8ba934427 100644 --- a/src/modules/feature/room/RoomMembers/MembersTable.vue +++ b/src/modules/feature/room/RoomMembers/MembersTable.vue @@ -2,10 +2,13 @@
-