diff --git a/src/components/icons/material/index.ts b/src/components/icons/material/index.ts index 1d838ad28f..3087fc7164 100644 --- a/src/components/icons/material/index.ts +++ b/src/components/icons/material/index.ts @@ -88,6 +88,7 @@ import { mdiKeyboardReturn, mdiLightbulbOnOutline, mdiLink, + mdiLocationExit, mdiLock, mdiLockOutline, mdiLogin, @@ -233,6 +234,7 @@ export { mdiImport, mdiInformation, mdiKeyboardReturn, + mdiLocationExit, mdiLightbulbOnOutline, mdiLink, mdiLock, diff --git a/src/locales/de.ts b/src/locales/de.ts index be9b880848..fc4602ff2a 100644 --- a/src/locales/de.ts +++ b/src/locales/de.ts @@ -16,6 +16,7 @@ export default { "common.actions.logout": "Abmelden", "common.actions.ok": "OK", "common.actions.pickColor": "Hintergrundfarbe auswählen", + "common.actions.leave": "Verlassen", "common.actions.remove": "Entfernen", "common.actions.rename": "Umbenennen", "common.actions.save": "Speichern", @@ -1800,6 +1801,8 @@ export default { "Räume sind vorerst nur für Lehrkräfte sichtbar und werden weiter ausgebaut. Weitere Information gibt es auf unserer {helpLink}. Wir freuen uns über {feedbackLink} zum aktuellen Stand.", "pages.rooms.infoAlert.welcome.visibility.help": "Hilfeseite", "pages.rooms.infoAlert.welcome.visibility.feedback": "Rückmeldungen", + "pages.rooms.leaveRoom.confirmation": 'Raum "{roomName}" wirklich verlassen?', + "pages.rooms.leaveRoom.menu": "Raum verlassen", "pages.rooms.members.error.load": "Die Teilnehmenden-Liste konnte nicht geladen werden.", "pages.rooms.members.error.add": @@ -1811,6 +1814,9 @@ export default { "pages.rooms.members.infoText.moreInformation": "weitere Informationen", "pages.rooms.members.label": "Teilnehmende", "pages.rooms.members.add": "Mitglieder hinzufügen", + "pages.rooms.members.changePermission": "Raumberechtigungen ändern", + "pages.rooms.members.changePermission.ariaLabel": + "Berechtigung für {memberName} ändern", "pages.rooms.members.manage": "Raum-Mitglieder", "pages.rooms.members.remove.ariaLabel": "{memberName} aus Raum entfernen", "pages.rooms.members.resetSelection.ariaLabel": @@ -1827,6 +1833,7 @@ export default { "pages.rooms.members.roomPermissions.viewer": "Lesen", "pages.rooms.members.tableHeader.roomRole": "Raumberechtigungen", "pages.rooms.members.tableHeader.schoolRole": "Schulrolle", + "pages.rooms.members.tableHeader.actions": "Aktionen", "pages.rooms.title": "Räume", "pages.taskCard.addElement": "Element hinzufügen", "pages.taskCard.deleteElement.text": diff --git a/src/locales/en.ts b/src/locales/en.ts index b8234d1a31..d9baeb39b2 100644 --- a/src/locales/en.ts +++ b/src/locales/en.ts @@ -17,6 +17,7 @@ export default { "common.actions.logout": "Logout", "common.actions.ok": "OK", "common.actions.pickColor": "Select background colour", + "common.actions.leave": "Leave", "common.actions.remove": "Remove", "common.actions.rename": "Rename", "common.actions.save": "Save", @@ -1771,6 +1772,8 @@ export default { "Rooms are currently only visible to teachers and will be further developed. Further information can be found on our {helpLink}. We look forward to receiving {feedbackLink} on the current status.", "pages.rooms.infoAlert.welcome.visibility.help": "help page", "pages.rooms.infoAlert.welcome.visibility.feedback": "feedback", + "pages.rooms.leaveRoom.confirmation": 'Leave room "{roomName}"?', + "pages.rooms.leaveRoom.menu": "Leave room", "pages.rooms.members.error.load": "The participant list could not be loaded.", "pages.rooms.members.error.add": "Adding participants failed.", "pages.rooms.members.error.remove": "Deleting participants failed.", @@ -1779,6 +1782,9 @@ export default { "pages.rooms.members.infoText.moreInformation": "more information", "pages.rooms.members.label": "Participants", "pages.rooms.members.add": "Add members", + "pages.rooms.members.changePermission": "Change permissions", + "pages.rooms.members.changePermission.ariaLabel": + "Change permissions for {memberName}", "pages.rooms.members.manage": "Room members", "pages.rooms.members.remove.ariaLabel": "Remove {memberName} from the room", "pages.rooms.members.resetSelection.ariaLabel": @@ -1795,6 +1801,7 @@ export default { "pages.rooms.members.roomPermissions.viewer": "Read", "pages.rooms.members.tableHeader.roomRole": "Room Permissions", "pages.rooms.members.tableHeader.schoolRole": "School Role", + "pages.rooms.members.tableHeader.actions": "Actions", "pages.rooms.title": "Rooms", "pages.taskCard.addElement": "Add element", "pages.taskCard.deleteElement.text": diff --git a/src/locales/es.ts b/src/locales/es.ts index 0a47f1b506..5ce0e798a0 100644 --- a/src/locales/es.ts +++ b/src/locales/es.ts @@ -17,6 +17,7 @@ export default { "common.actions.logout": "Desconectar", "common.actions.ok": "Aceptar", "common.actions.pickColor": "Seleccione el color de fondo", + "common.actions.leave": "Dejar", "common.actions.remove": "Eliminar", "common.actions.rename": "Renombrar", "common.actions.save": "Guardar", @@ -1820,6 +1821,8 @@ export default { "Las salas son actualmente visibles solo para los profesores y se seguirán desarrollando. Puede encontrar más información en nuestro {helpLink}. Agradecemos sus {feedbackLink} sobre el estado actual.", "pages.rooms.infoAlert.welcome.visibility.help": "página de ayuda", "pages.rooms.infoAlert.welcome.visibility.feedback": "comentarios", + "pages.rooms.leaveRoom.confirmation": 'Dejar la sala "{roomName}"?', + "pages.rooms.leaveRoom.menu": "Salir de la sala", "pages.rooms.members.error.load": "No se pudo cargar la lista de participantes.", "pages.rooms.members.error.add": "Error al agregar participantes.", @@ -1829,6 +1832,9 @@ export default { "pages.rooms.members.infoText.moreInformation": "más información", "pages.rooms.members.label": "Participantes", "pages.rooms.members.add": "Añadir miembros", + "pages.rooms.members.changePermission": "Cambiar permisos", + "pages.rooms.members.changePermission.ariaLabel": + "Cambiar el permiso para {memberName}", "pages.rooms.members.manage": "Miembros de la sala", "pages.rooms.members.remove.ariaLabel": "Eliminar {memberName} de la sala", "pages.rooms.members.resetSelection.ariaLabel": @@ -1847,6 +1853,7 @@ export default { "pages.rooms.members.roomPermissions.viewer": "Leer", "pages.rooms.members.tableHeader.roomRole": "Permisos de la sala", "pages.rooms.members.tableHeader.schoolRole": "Rol en la escuela", + "pages.rooms.members.tableHeader.actions": "Acciones", "pages.rooms.title": "Salas", "pages.taskCard.addElement": "Añadir artículo", "pages.taskCard.deleteElement.text": diff --git a/src/locales/uk.ts b/src/locales/uk.ts index 4a0187ad49..2a548ad259 100644 --- a/src/locales/uk.ts +++ b/src/locales/uk.ts @@ -17,6 +17,7 @@ export default { "common.actions.logout": "Вийти з аккаунта", "common.actions.ok": "ОК", "common.actions.pickColor": "Вибрати колір тла", + "common.actions.leave": "Залиште", "common.actions.remove": "Вилучити", "common.actions.rename": "перейменувати", "common.actions.save": "Зберегти", @@ -1800,6 +1801,8 @@ export default { "Кімнати наразі видимі лише для вчителів і будуть далі розвиватися. Додаткову інформацію можна знайти на нашій {helpLink}. Ми будемо вдячні за ваші {feedbackLink} щодо поточного стану.", "pages.rooms.infoAlert.welcome.visibility.help": "Сторінка допомоги", "pages.rooms.infoAlert.welcome.visibility.feedback": "відгуки", + "pages.rooms.leaveRoom.confirmation": 'Дійсно залишити місце "{roomName}"?', + "pages.rooms.leaveRoom.menu": "Залиште кімнату", "pages.rooms.members.error.load": "Не вдалося завантажити список учасників.", "pages.rooms.members.error.add": "Не вдалося додати учасників.", "pages.rooms.members.error.remove": "Не вдалося видалити учасників.", @@ -1808,6 +1811,9 @@ export default { "pages.rooms.members.infoText.moreInformation": "більше інформації", "pages.rooms.members.label": "Учасники", "pages.rooms.members.add": "Додайте члени", + "pages.rooms.members.changePermission": "Змінити дозволи", + "pages.rooms.members.changePermission.ariaLabel": + "Змінити дозвіл для {memberName}", "pages.rooms.members.manage": "Учасник кімнати", "pages.rooms.members.remove.ariaLabel": "Видалити {memberName} з кімнати", "pages.rooms.members.resetSelection.ariaLabel": @@ -1824,6 +1830,7 @@ export default { "pages.rooms.members.roomPermissions.viewer": "Читати", "pages.rooms.members.tableHeader.roomRole": "Дозволи кімнати", "pages.rooms.members.tableHeader.schoolRole": "Роль у школі", + "pages.rooms.members.tableHeader.actions": "Дії", "pages.rooms.title": "Кімнати", "pages.taskCard.addElement": "Додати елемент", "pages.taskCard.deleteElement.text": diff --git a/src/modules/data/room/Rooms.state.ts b/src/modules/data/room/Rooms.state.ts index 2632175e2f..c6cd38121c 100644 --- a/src/modules/data/room/Rooms.state.ts +++ b/src/modules/data/room/Rooms.state.ts @@ -36,6 +36,19 @@ export const useRoomsState = () => { } }; + const leaveRoom = async (roomId: string, userId: string) => { + isLoading.value = true; + try { + await roomApi.roomControllerRemoveMembers(roomId, { userIds: [userId] }); + } catch (error) { + const responseError = mapAxiosErrorToResponseError(error); + + throw createApplicationError(responseError.code); + } finally { + isLoading.value = false; + } + }; + const isEmpty = computed(() => { return rooms.value.length === 0; }); @@ -46,5 +59,6 @@ export const useRoomsState = () => { isEmpty, fetchRooms, deleteRoom, + leaveRoom, }; }; diff --git a/src/modules/data/room/Rooms.state.unit.ts b/src/modules/data/room/Rooms.state.unit.ts index 09440ba79b..799a30474c 100644 --- a/src/modules/data/room/Rooms.state.unit.ts +++ b/src/modules/data/room/Rooms.state.unit.ts @@ -113,6 +113,34 @@ describe("useRoomsState", () => { }); }); + describe("leaveRoom", () => { + it("should call leaveRoom api", async () => { + const { leaveRoom, isLoading } = useRoomsState(); + const roomId = "room-id"; + const userId = "user-id"; + + await leaveRoom(roomId, userId); + expect(roomApiMock.roomControllerRemoveMembers).toHaveBeenCalledWith( + roomId, + { userIds: [userId] } + ); + expect(isLoading.value).toBe(false); + }); + + it("should throw an error when leaving room fails", async () => { + const { leaveRoom, isLoading } = useRoomsState(); + const roomId = "room-id"; + const userId = "user-id"; + + roomApiMock.roomControllerRemoveMembers.mockRejectedValue({ + code: 404, + }); + + await expect(leaveRoom(roomId, userId)).rejects.toThrow(); + expect(isLoading.value).toBe(false); + }); + }); + describe("isEmpty", () => { it("should return true when there are no rooms", () => { const { isEmpty, rooms } = useRoomsState(); diff --git a/src/modules/data/room/index.ts b/src/modules/data/room/index.ts index ec14b0091f..1648829498 100644 --- a/src/modules/data/room/index.ts +++ b/src/modules/data/room/index.ts @@ -7,4 +7,5 @@ export { useRoomCreateState } from "./RoomCreate.state"; export { useRoomEditState } from "./RoomEdit.state"; export { useRoomMembers } from "./roomMembers/roomMembers.composable"; +export { useRoomMemberVisibilityOptions } from "./roomMembers/membersVisibleOptions.composable"; export type { RoomMember } from "./roomMembers/types"; diff --git a/src/modules/data/room/roomMembers/membersVisibleOptions.composable.ts b/src/modules/data/room/roomMembers/membersVisibleOptions.composable.ts new file mode 100644 index 0000000000..9bb8883706 --- /dev/null +++ b/src/modules/data/room/roomMembers/membersVisibleOptions.composable.ts @@ -0,0 +1,100 @@ +import { RoleName, RoomMemberResponse } from "@/serverApi/v3"; +import { computed, ComputedRef } from "vue"; +import { ENV_CONFIG_MODULE_KEY, injectStrict } from "@/utils/inject"; + +type VisibilityOptions = { + isVisibleSelectionColumn: boolean; + isVisibleActionColumn: boolean; + isVisibleAddMemberButton: boolean; + isVisibleActionInRow: boolean; + isVisibleChangeRoleButton: boolean; + isVisibleLeaveRoomButton: boolean; +}; + +type RoomRoles = + | RoleName.Roomowner + | RoleName.Roomadmin + | RoleName.Roomeditor + | RoleName.Roomviewer; + +const defaultOptions: VisibilityOptions = { + isVisibleSelectionColumn: false, + isVisibleActionColumn: false, + isVisibleAddMemberButton: false, + isVisibleActionInRow: false, + isVisibleChangeRoleButton: false, + isVisibleLeaveRoomButton: true, +}; + +export const roleConfigMap: Record = { + [RoleName.Roomowner]: { + isVisibleSelectionColumn: true, + isVisibleActionColumn: true, + isVisibleActionInRow: false, + isVisibleAddMemberButton: true, + isVisibleChangeRoleButton: true, + isVisibleLeaveRoomButton: false, + }, + [RoleName.Roomadmin]: { + isVisibleSelectionColumn: true, + isVisibleActionColumn: true, + isVisibleAddMemberButton: true, + isVisibleActionInRow: true, + isVisibleChangeRoleButton: true, + isVisibleLeaveRoomButton: true, + }, + [RoleName.Roomeditor]: defaultOptions, + [RoleName.Roomviewer]: defaultOptions, +}; + +export const useRoomMemberVisibilityOptions = ( + currentUser: ComputedRef +) => { + const envConfigModule = injectStrict(ENV_CONFIG_MODULE_KEY); + const { FEATURE_ROOMS_CHANGE_PERMISSIONS_ENABLED } = envConfigModule.getEnv; + const visibilityOptions = computed( + () => roleConfigMap[currentUser?.value?.roomRoleName as RoomRoles] + ); + + const isVisibleSelectionColumn = computed(() => { + return visibilityOptions.value?.isVisibleSelectionColumn; + }); + + const isVisibleActionColumn = computed(() => { + return visibilityOptions.value?.isVisibleActionColumn; + }); + + const isVisibleAddMemberButton = computed(() => { + return visibilityOptions.value?.isVisibleAddMemberButton; + }); + + const isVisibleChangeRoleButton = computed(() => { + return ( + visibilityOptions.value?.isVisibleChangeRoleButton && + FEATURE_ROOMS_CHANGE_PERMISSIONS_ENABLED + ); + }); + + const isVisibleLeaveRoomButton = computed(() => { + return visibilityOptions.value?.isVisibleLeaveRoomButton; + }); + + const isVisibleActionInRow = (user: RoomMemberResponse) => { + if (user.roomRoleName === RoleName.Roomowner) return false; + return user.userId !== currentUser?.value.userId; + }; + + const isVisibleRemoveMemberButton = (user: RoomMemberResponse) => { + return user.roomRoleName !== RoleName.Roomowner; + }; + + return { + isVisibleSelectionColumn, + isVisibleActionColumn, + isVisibleAddMemberButton, + isVisibleChangeRoleButton, + isVisibleLeaveRoomButton, + isVisibleActionInRow, + isVisibleRemoveMemberButton, + }; +}; diff --git a/src/modules/data/room/roomMembers/membersVisibleOptions.composable.unit.ts b/src/modules/data/room/roomMembers/membersVisibleOptions.composable.unit.ts new file mode 100644 index 0000000000..18a2ac1366 --- /dev/null +++ b/src/modules/data/room/roomMembers/membersVisibleOptions.composable.unit.ts @@ -0,0 +1,187 @@ +import { RoleName, RoomMemberResponse } from "@/serverApi/v3"; +import { + envsFactory, + mountComposable, + roomMemberFactory, +} from "@@/tests/test-utils"; +import { useRoomMemberVisibilityOptions, useRoomMembers } from "@data-room"; +import { createMock, DeepMocked } from "@golevelup/ts-jest"; +import { computed, ComputedRef } from "vue"; +import { ENV_CONFIG_MODULE_KEY } from "@/utils/inject"; +import EnvConfigModule from "@/store/env-config"; +import { createModuleMocks } from "@@/tests/test-utils/mock-store-module"; +import { roleConfigMap } from "./membersVisibleOptions.composable"; + +jest.mock("./roomMembers.composable"); +const mockUseRoomMembers = jest.mocked(useRoomMembers); + +const mockOptions = () => { + const defaultOptions = { + isVisibleSelectionColumn: false, + isVisibleActionColumn: false, + isVisibleAddMemberButton: false, + isVisibleActionInRow: false, + isVisibleChangeRoleButton: false, + isVisibleLeaveRoomButton: true, + }; + roleConfigMap[RoleName.Roomowner] = { + isVisibleSelectionColumn: true, + isVisibleActionColumn: true, + isVisibleActionInRow: false, + isVisibleAddMemberButton: true, + isVisibleChangeRoleButton: true, + isVisibleLeaveRoomButton: false, + }; + roleConfigMap[RoleName.Roomadmin] = { + isVisibleSelectionColumn: true, + isVisibleActionColumn: true, + isVisibleAddMemberButton: true, + isVisibleActionInRow: true, + isVisibleChangeRoleButton: true, + isVisibleLeaveRoomButton: true, + }; + roleConfigMap[RoleName.Roomeditor] = defaultOptions; + roleConfigMap[RoleName.Roomviewer] = defaultOptions; +}; + +describe("useRoomMemberVisibilityOptions", () => { + let mockRoomMemberCalls: DeepMocked>; + + beforeEach(() => { + mockRoomMemberCalls = createMock>(); + mockUseRoomMembers.mockReturnValue(mockRoomMemberCalls); + mockOptions(); + }); + + const createCurrentUser = ( + roomRoleName: RoleName + ): ComputedRef => { + return computed(() => ({ + firstName: "first-name", + lastName: "last-name", + roomRoleName, + schoolRoleName: "school-role-name", + schoolName: "school-name", + userId: "user-id", + })); + }; + + const defaultEnvs = envsFactory.build(); + + const setup = ( + options: { + roomRoleName: RoleName; + changeRoleFeatureFlag?: boolean; + } = { + roomRoleName: RoleName.Roomowner, + changeRoleFeatureFlag: true, + } + ) => { + const currentUser = createCurrentUser(options?.roomRoleName); + const envConfigModule = createModuleMocks(EnvConfigModule, { + getEnv: { + ...defaultEnvs, + FEATURE_ROOMS_CHANGE_PERMISSIONS_ENABLED: + options.changeRoleFeatureFlag ?? true, + }, + }); + + return mountComposable(() => useRoomMemberVisibilityOptions(currentUser), { + global: { + provide: { + [ENV_CONFIG_MODULE_KEY.valueOf()]: envConfigModule, + }, + }, + }); + }; + + describe("isVisibleSelectionColumn", () => { + it.each([ + { roomRoleName: RoleName.Roomowner, expected: true }, + { roomRoleName: RoleName.Roomadmin, expected: true }, + { roomRoleName: RoleName.Roomeditor, expected: false }, + { roomRoleName: RoleName.Roomviewer, expected: false }, + ])("should return %p for %p", ({ roomRoleName, expected }) => { + const { isVisibleSelectionColumn } = setup({ roomRoleName }); + expect(isVisibleSelectionColumn.value).toBe(expected); + }); + }); + + describe("isVisibleActionColumn", () => { + it.each([ + { roomRoleName: RoleName.Roomowner, expected: true }, + { roomRoleName: RoleName.Roomadmin, expected: true }, + { roomRoleName: RoleName.Roomeditor, expected: false }, + { roomRoleName: RoleName.Roomviewer, expected: false }, + ])("should return %p for %p", ({ roomRoleName, expected }) => { + const { isVisibleActionColumn } = setup({ roomRoleName }); + expect(isVisibleActionColumn.value).toBe(expected); + }); + }); + + describe("isVisibleAddMemberButton", () => { + it.each([ + { roomRoleName: RoleName.Roomowner, expected: true }, + { roomRoleName: RoleName.Roomadmin, expected: true }, + { roomRoleName: RoleName.Roomeditor, expected: false }, + { roomRoleName: RoleName.Roomviewer, expected: false }, + ])("should return %p for %p", ({ roomRoleName, expected }) => { + const { isVisibleAddMemberButton } = setup({ roomRoleName }); + expect(isVisibleAddMemberButton.value).toBe(expected); + }); + }); + + describe("isVisibleActionInRow", () => { + it.each([ + { roomRoleName: RoleName.Roomowner, expected: false }, + { roomRoleName: RoleName.Roomadmin, expected: true }, + { roomRoleName: RoleName.Roomeditor, expected: true }, + { roomRoleName: RoleName.Roomviewer, expected: true }, + ])("should return %p for %p", ({ roomRoleName, expected }) => { + const { isVisibleActionInRow } = setup({ roomRoleName }); + const roomMember = roomMemberFactory(roomRoleName).build(); + expect(isVisibleActionInRow(roomMember)).toBe(expected); + }); + }); + + describe("isVisibleChangeRoleButton", () => { + it.each([ + { roomRoleName: RoleName.Roomowner, expected: true }, + { roomRoleName: RoleName.Roomadmin, expected: true }, + { roomRoleName: RoleName.Roomeditor, expected: false }, + { roomRoleName: RoleName.Roomviewer, expected: false }, + ])("should return %p for %p", ({ roomRoleName, expected }) => { + const { isVisibleChangeRoleButton } = setup({ roomRoleName }); + + expect(isVisibleChangeRoleButton.value).toBe(expected); + }); + + describe("when FEATURE_ROOMS_CHANGE_PERMISSIONS_ENABLED is false", () => { + it.each([ + { roomRoleName: RoleName.Roomowner, expected: false }, + { roomRoleName: RoleName.Roomadmin, expected: false }, + { roomRoleName: RoleName.Roomeditor, expected: false }, + { roomRoleName: RoleName.Roomviewer, expected: false }, + ])("should return %p for %p", ({ roomRoleName, expected }) => { + const { isVisibleChangeRoleButton } = setup({ + roomRoleName: roomRoleName, + changeRoleFeatureFlag: false, + }); + expect(isVisibleChangeRoleButton.value).toBe(expected); + }); + }); + }); + + describe("isVisibleRemoveMemberButton", () => { + it.each([ + { roomRoleName: RoleName.Roomowner, expected: false }, + { roomRoleName: RoleName.Roomadmin, expected: true }, + { roomRoleName: RoleName.Roomeditor, expected: true }, + { roomRoleName: RoleName.Roomviewer, expected: true }, + ])("should return %p for %p", ({ roomRoleName, expected }) => { + const { isVisibleRemoveMemberButton } = setup({ roomRoleName }); + const roomMember = roomMemberFactory(roomRoleName).build(); + expect(isVisibleRemoveMemberButton(roomMember)).toBe(expected); + }); + }); +}); diff --git a/src/modules/data/room/roomMembers/roomMembers.composable.ts b/src/modules/data/room/roomMembers/roomMembers.composable.ts index f4523306bd..d71b81f661 100644 --- a/src/modules/data/room/roomMembers/roomMembers.composable.ts +++ b/src/modules/data/room/roomMembers/roomMembers.composable.ts @@ -1,4 +1,4 @@ -import { Ref, ref } from "vue"; +import { computed, Ref, ref } from "vue"; import { RoomMember } from "./types"; import { RoleName, @@ -25,6 +25,11 @@ export const useRoomMembers = (roomId: string) => { name: schoolsModule.getSchool.name, }; const currentUserId = authModule.getUser?.id ?? ""; + const currentUser = computed(() => { + return roomMembers.value?.find( + (member) => member.userId === currentUserId + ) as RoomMemberResponse; + }); const roomRole: Record = { [RoleName.Roomowner]: t("pages.rooms.members.roomPermissions.owner"), @@ -48,12 +53,12 @@ export const useRoomMembers = (roomId: string) => { roomMembers.value = data.map((member: RoomMemberResponse) => { return { ...member, - displayRoomRole: roomRole[member.roomRoleName], - displaySchoolRole: schoolRole[member.schoolRoleName], isSelectable: !( member.userId === currentUserId || member.roomRoleName === RoleName.Roomowner ), + displayRoomRole: roomRole[member.roomRoleName], + displaySchoolRole: schoolRole[member.schoolRoleName], }; }); isLoading.value = false; @@ -142,6 +147,7 @@ export const useRoomMembers = (roomId: string) => { getPotentialMembers, getSchools, removeMembers, + currentUser, isLoading, roomMembers, potentialRoomMembers, diff --git a/src/modules/feature/room/RoomMembers/MembersTable.unit.ts b/src/modules/feature/room/RoomMembers/MembersTable.unit.ts index d282e582ed..df4fec0755 100644 --- a/src/modules/feature/room/RoomMembers/MembersTable.unit.ts +++ b/src/modules/feature/room/RoomMembers/MembersTable.unit.ts @@ -5,12 +5,15 @@ import { import MembersTable from "./MembersTable.vue"; import { nextTick, ref } from "vue"; import { mdiMenuDown, mdiMenuUp, mdiMagnify } from "@icons/material"; -import { roomMemberFactory } from "@@/tests/test-utils"; +import { envsFactory, roomMemberFactory } from "@@/tests/test-utils"; import { DOMWrapper, flushPromises, 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"; import { RoleName } from "@/serverApi/v3"; +import { ENV_CONFIG_MODULE_KEY } from "@/utils/inject"; +import EnvConfigModule from "@/store/env-config"; +import { createModuleMocks } from "@@/tests/test-utils/mock-store-module"; jest.mock("@ui-confirmation-dialog"); const mockedUseRemoveConfirmationDialog = jest.mocked(useConfirmationDialog); @@ -35,17 +38,36 @@ describe("MembersTable", () => { "pages.rooms.members.tableHeader.roomRole", "pages.rooms.members.tableHeader.schoolRole", "common.words.mainSchool", - "", + "pages.rooms.members.tableHeader.actions", ]; - const setup = () => { - const mockMembers = roomMemberFactory(RoleName.Roomeditor).buildList(3); + const setup = (options?: { + currentUserRole?: RoleName; + changePermissionFlag?: boolean; + }) => { + const envConfigModuleMock = createModuleMocks(EnvConfigModule, { + getEnv: { + ...envsFactory.build(), + FEATURE_ROOMS_CHANGE_PERMISSIONS_ENABLED: + options?.changePermissionFlag ?? false, + }, + }); + const mockMembers = roomMemberFactory(RoleName.Roomadmin).buildList(3); const wrapper = mount(MembersTable, { attachTo: document.body, global: { plugins: [createTestingVuetify(), createTestingI18n()], + provide: { + [ENV_CONFIG_MODULE_KEY.valueOf()]: envConfigModuleMock, + }, + }, + props: { + members: mockMembers, + currentUser: { + ...mockMembers[0], + roomRoleName: options?.currentUserRole || RoleName.Roomowner, + }, }, - props: { members: mockMembers }, }); return { wrapper, mockMembers }; @@ -290,19 +312,25 @@ describe("MembersTable", () => { wrapper: VueWrapper ) => { const dataTable = wrapper.getComponent(VDataTable); - const removeButton = dataTable.findComponent( - `[data-testid=remove-member-${index}]` + const menuButton = dataTable.findComponent( + `[data-testid=kebab-menu-${index}]` + ); + await menuButton.trigger("click"); + await nextTick(); + + const removeButton = wrapper.findComponent( + `[data-testid=kebab-menu-action-remove-member]` ); await removeButton.trigger("click"); }; - it("should open confirmation dialog with remove message for single member ", async () => { + it("should open confirmation dialog with remove message for single member", async () => { const { wrapper } = setup(); askConfirmationMock.mockResolvedValue(true); - await triggerMemberRemoval(0, wrapper); + await triggerMemberRemoval(2, wrapper); expect(askConfirmationMock).toHaveBeenCalledWith({ confirmActionLangKey: "common.actions.remove", @@ -315,13 +343,13 @@ describe("MembersTable", () => { askConfirmationMock.mockResolvedValue(true); - await triggerMemberRemoval(0, wrapper); + await triggerMemberRemoval(2, wrapper); expect(wrapper.emitted()).toHaveProperty("remove:members"); const removeEvents = wrapper.emitted("remove:members"); expect(removeEvents).toHaveLength(1); - expect(removeEvents![0]).toEqual([[mockMembers[0].userId]]); + expect(removeEvents![0]).toEqual([[mockMembers[2].userId]]); }); it("should not call remove:members event when dialog is cancelled", async () => { @@ -329,7 +357,7 @@ describe("MembersTable", () => { askConfirmationMock.mockResolvedValue(false); - await triggerMemberRemoval(0, wrapper); + await triggerMemberRemoval(2, wrapper); expect(wrapper.emitted()).not.toHaveProperty("remove:members"); }); @@ -413,10 +441,114 @@ describe("MembersTable", () => { }, }); await nextTick(); - await nextTick(); const elementAfter = wrapper.find(".table-title-header"); expect(elementAfter.classes("fixed-position")).toBe(true); }); }); + + describe("visibility options", () => { + describe("isActionColumnVisible", () => { + it.each([ + { + description: "when user is roomowner", + currentUserRole: RoleName.Roomowner, + expected: true, + }, + { + description: "when user is roomadmin", + currentUserRole: RoleName.Roomadmin, + expected: true, + }, + { + description: "when user is roomeditor", + currentUserRole: RoleName.Roomeditor, + expected: false, + }, + { + description: "when user is roomviewer", + currentUserRole: RoleName.Roomviewer, + expected: false, + }, + ])( + "should be $expected $description", + async ({ currentUserRole, expected }) => { + const { wrapper } = setup({ currentUserRole }); + const dataTable = wrapper.getComponent(VDataTable); + + const menu = dataTable.findComponent('[data-testid="kebab-menu-1'); + + expect(menu.exists()).toBe(expected); + } + ); + }); + + describe("isSelectionColumnVisible", () => { + it.each([ + { + description: "when user is roomowner", + currentUserRole: RoleName.Roomowner, + expected: true, + }, + { + description: "when user is roomadmin", + currentUserRole: RoleName.Roomadmin, + expected: true, + }, + { + description: "when user is roomeditor", + currentUserRole: RoleName.Roomeditor, + expected: false, + }, + { + description: "when user is roomviewer", + currentUserRole: RoleName.Roomviewer, + expected: false, + }, + ])( + "should be $expected $description", + async ({ currentUserRole, expected }) => { + const { wrapper } = setup({ currentUserRole }); + const dataTable = wrapper.getComponent(VDataTable); + + const checkbox = dataTable.findComponent(".v-selection-control"); + + expect(checkbox.exists()).toBe(expected); + } + ); + }); + + describe("isChangeRoleButtonVisible", () => { + it.each([ + { + description: "when user is roomowner", + currentUserRole: RoleName.Roomowner, + expected: true, + }, + { + description: "when user is roomadmin", + currentUserRole: RoleName.Roomadmin, + expected: true, + }, + ])( + "should be $expected $description", + async ({ currentUserRole, expected }) => { + const { wrapper } = setup({ + currentUserRole, + changePermissionFlag: true, + }); + const dataTable = wrapper.getComponent(VDataTable); + + const menuBtn = dataTable.findComponent('[data-testid="kebab-menu-1'); + await menuBtn.trigger("click"); + + const changeRoleButton = wrapper.findComponent( + '[data-testid="kebab-menu-action-change-permission"]' + ); + + expect(changeRoleButton.exists()).toBe(expected); + } + ); + }); + }); }); diff --git a/src/modules/feature/room/RoomMembers/MembersTable.vue b/src/modules/feature/room/RoomMembers/MembersTable.vue index af21550dae..3b5c4ae6ff 100644 --- a/src/modules/feature/room/RoomMembers/MembersTable.vue +++ b/src/modules/feature/room/RoomMembers/MembersTable.vue @@ -40,22 +40,30 @@ :items-per-page-options="[5, 10, 25, 50, 100]" :items-per-page="50" :mobile="null" - :show-select="true" + :show-select="isVisibleSelectionColumn" :sort-asc-icon="mdiMenuDown" :sort-desc-icon="mdiMenuUp" @update:current-items="onUpdateFilter" @update:model-value="onSelectMembers" > - @@ -55,13 +69,14 @@ import { mdiPencilOutline, mdiTrashCanOutline, mdiAccountGroupOutline, + mdiLocationExit, } from "@icons/material"; import { useRoomAuthorization } from "@feature-room"; import { useRoomDetailsStore } from "@data-room"; import { storeToRefs } from "pinia"; -defineEmits(["room:edit", "room:manage-members", "room:delete"]); +defineEmits(["room:edit", "room:manage-members", "room:delete", "room:leave"]); const { room } = storeToRefs(useRoomDetailsStore()); -const { canEditRoom, canDeleteRoom } = useRoomAuthorization(room); +const { canEditRoom, canDeleteRoom, canLeaveRoom } = useRoomAuthorization(room); diff --git a/src/modules/feature/room/roomAuthorization.composable.ts b/src/modules/feature/room/roomAuthorization.composable.ts index 64a3351c06..ce0a2d1c77 100644 --- a/src/modules/feature/room/roomAuthorization.composable.ts +++ b/src/modules/feature/room/roomAuthorization.composable.ts @@ -17,6 +17,7 @@ export const useRoomAuthorization = ( const canViewRoom = ref(false); const canEditRoom = ref(false); const canDeleteRoom = ref(false); + const canLeaveRoom = ref(false); watchEffect(() => { const permissions = toValue(room)?.permissions ?? []; @@ -28,6 +29,8 @@ export const useRoomAuthorization = ( canViewRoom.value = permissions.includes(Permission.RoomView); canEditRoom.value = permissions.includes(Permission.RoomEdit); canDeleteRoom.value = permissions.includes(Permission.RoomDelete); + + canLeaveRoom.value = !permissions.includes(Permission.RoomChangeOwner); }); return { @@ -35,5 +38,6 @@ export const useRoomAuthorization = ( canViewRoom, canEditRoom, canDeleteRoom, + canLeaveRoom, }; }; diff --git a/src/modules/page/room/RoomDetails.page.unit.ts b/src/modules/page/room/RoomDetails.page.unit.ts index ac780bd3c5..d972538ed7 100644 --- a/src/modules/page/room/RoomDetails.page.unit.ts +++ b/src/modules/page/room/RoomDetails.page.unit.ts @@ -34,6 +34,7 @@ const roomPermissions: ReturnType = { canViewRoom: ref(false), canEditRoom: ref(false), canDeleteRoom: ref(false), + canLeaveRoom: ref(false), }; (useRoomAuthorization as jest.Mock).mockReturnValue(roomPermissions); diff --git a/src/modules/page/room/RoomDetails.page.vue b/src/modules/page/room/RoomDetails.page.vue index cd875c142f..5eb4df1aed 100644 --- a/src/modules/page/room/RoomDetails.page.vue +++ b/src/modules/page/room/RoomDetails.page.vue @@ -15,6 +15,7 @@ @room:edit="onEdit" @room:manage-members="onManageMembers" @room:delete="onDelete" + @room:leave="onLeaveRoom" /> @@ -32,6 +33,7 @@ import { Breadcrumb } from "@/components/templates/default-wireframe.types"; import DefaultWireframe from "@/components/templates/DefaultWireframe.vue"; import { BoardLayout } from "@/serverApi/v3"; +import { authModule } from "@/store"; import { ENV_CONFIG_MODULE_KEY, injectStrict } from "@/utils/inject"; import { buildPageTitle } from "@/utils/pageTitle"; import { useRoomDetailsStore, useRoomsState } from "@data-room"; @@ -44,6 +46,7 @@ import { import { ConfirmationDialog, useDeleteConfirmationDialog, + useConfirmationDialog, } from "@ui-confirmation-dialog"; import { SelectBoardLayoutDialog } from "@ui-room-details"; import { useTitle } from "@vueuse/core"; @@ -56,8 +59,9 @@ const router = useRouter(); const { t } = useI18n(); const envConfigModule = injectStrict(ENV_CONFIG_MODULE_KEY); -const { deleteRoom } = useRoomsState(); +const { deleteRoom, leaveRoom } = useRoomsState(); const { askDeleteConfirmation } = useDeleteConfirmationDialog(); +const { askConfirmation } = useConfirmationDialog(); const roomDetailsStore = useRoomDetailsStore(); const { room, roomBoards } = storeToRefs(roomDetailsStore); @@ -175,6 +179,23 @@ const onDelete = async () => { } }; +const onLeaveRoom = async () => { + const currentUserId = authModule.getUser?.id; + const roomId = room.value?.id; + if (!currentUserId || !roomId) return; + + const shouldLeave = await askConfirmation({ + message: t("pages.rooms.leaveRoom.confirmation", { + roomName: room.value?.name, + }), + confirmActionLangKey: "common.actions.leave", + }); + + if (!shouldLeave) return; + await leaveRoom(roomId, currentUserId); + router.push("/rooms"); +}; + const onCreateBoard = async (layout: BoardLayout) => { if (!room.value || !canEditRoom.value) return; diff --git a/src/modules/page/room/RoomDetailsSwitch.page.unit.ts b/src/modules/page/room/RoomDetailsSwitch.page.unit.ts index 7d75d22c0c..eb3bcc98a2 100644 --- a/src/modules/page/room/RoomDetailsSwitch.page.unit.ts +++ b/src/modules/page/room/RoomDetailsSwitch.page.unit.ts @@ -27,6 +27,7 @@ const roomPermissions: ReturnType = { canViewRoom: ref(false), canEditRoom: ref(false), canDeleteRoom: ref(false), + canLeaveRoom: ref(false), }; (useRoomAuthorization as jest.Mock).mockReturnValue(roomPermissions); diff --git a/src/modules/page/room/RoomMembers.page.unit.ts b/src/modules/page/room/RoomMembers.page.unit.ts index 07829b59e1..c9ffe18323 100644 --- a/src/modules/page/room/RoomMembers.page.unit.ts +++ b/src/modules/page/room/RoomMembers.page.unit.ts @@ -5,6 +5,7 @@ import { mockedPiniaStoreTyping, roomMemberFactory, roomMemberSchoolResponseFactory, + envsFactory, } from "@@/tests/test-utils"; import { useRoomMembers, useRoomDetailsStore } from "@data-room"; import { @@ -15,13 +16,17 @@ import { Router, useRoute, useRouter } from "vue-router"; import { createMock, DeepMocked } from "@golevelup/ts-jest"; import EnvConfigModule from "@/store/env-config"; import setupStores from "@@/tests/test-utils/setupStores"; -import { ref } from "vue"; +import { computed, ref } from "vue"; import { RoleName, RoomDetailsResponse } from "@/serverApi/v3"; import { roomFactory } from "@@/tests/test-utils/factory/room"; import { VBtn, VDialog } from "vuetify/lib/components/index.mjs"; import { AddMembers, MembersTable } from "@feature-room"; import { mdiPlus } from "@icons/material"; import { VueWrapper } from "@vue/test-utils"; +import { useConfirmationDialog } from "@ui-confirmation-dialog"; +import setupConfirmationComposableMock from "@@/tests/test-utils/composable-mocks/setupConfirmationComposableMock"; +import { ENV_CONFIG_MODULE_KEY } from "@/utils/inject"; +import { createModuleMocks } from "@@/tests/test-utils/mock-store-module"; jest.mock("vue-router"); const useRouterMock = useRouter; @@ -32,11 +37,15 @@ const mockUseRoomMembers = jest.mocked(useRoomMembers); jest.mock("@vueuse/integrations"); // mock focus trap from add members because we use mount +jest.mock("@ui-confirmation-dialog"); +const mockedUseRemoveConfirmationDialog = jest.mocked(useConfirmationDialog); + describe("RoomMembersPage", () => { let router: DeepMocked; let route: DeepMocked>; let mockRoomMemberCalls: DeepMocked>; let wrapper: VueWrapper>; + let askConfirmationMock: jest.Mock; const routeRoomId = "room-id"; @@ -54,6 +63,15 @@ describe("RoomMembersPage", () => { mockRoomMemberCalls = createMock>(); mockUseRoomMembers.mockReturnValue(mockRoomMemberCalls); + + askConfirmationMock = jest.fn(); + setupConfirmationComposableMock({ + askConfirmationMock, + }); + mockedUseRemoveConfirmationDialog.mockReturnValue({ + askConfirmation: askConfirmationMock, + isDialogOpen: ref(false), + }); }); const buildRoom = () => { @@ -70,17 +88,34 @@ describe("RoomMembersPage", () => { return room; }; - const setup = (options?: { createRoom?: boolean }) => { - const { createRoom } = { + const setup = (options?: { + createRoom?: boolean; + currentUserRole?: RoleName; + }) => { + const { createRoom, currentUserRole } = { createRoom: true, + currentUserRole: RoleName.Roomowner, ...options, }; + const envConfigModuleMock = createModuleMocks(EnvConfigModule, { + getEnv: { + ...envsFactory.build(), + FEATURE_ROOMS_CHANGE_PERMISSIONS_ENABLED: true, + }, + }); + const room = createRoom ? buildRoom() : undefined; const members = roomMemberFactory(RoleName.Roomeditor).buildList(3); mockRoomMemberCalls.roomMembers = ref(members); + mockRoomMemberCalls.currentUser = computed(() => { + return { + ...members[0], + roomRoleName: currentUserRole, + }; + }); wrapper = mount(RoomMembersPage, { attachTo: document.body, @@ -97,6 +132,9 @@ describe("RoomMembersPage", () => { createTestingI18n(), createTestingVuetify(), ], + provide: { + [ENV_CONFIG_MODULE_KEY.valueOf()]: envConfigModuleMock, + }, }, }); @@ -152,6 +190,93 @@ describe("RoomMembersPage", () => { expect(infoText.text()).toBe("pages.rooms.members.infoText"); }); + describe("onLeaveRoom", () => { + describe("when user is not room owner", () => { + it("should not render leave room button", async () => { + const { wrapper } = setup({ currentUserRole: RoleName.Roomowner }); + + const leaveRoomButton = wrapper.findComponent( + '[data-testid="room-member-menu"]' + ); + + expect(leaveRoomButton.exists()).toBe(false); + }); + }); + + it("should open confirmation dialog", async () => { + const { wrapper } = setup({ currentUserRole: RoleName.Roomadmin }); + askConfirmationMock.mockResolvedValue(true); + + const menuBtn = wrapper.findComponent('[data-testid="room-member-menu"]'); + await menuBtn.trigger("click"); + + const leaveMenu = wrapper.findComponent( + '[data-testid="kebab-menu-action-leave-room"]' + ); + await leaveMenu.trigger("click"); + + expect(askConfirmationMock).toHaveBeenCalledWith({ + confirmActionLangKey: "common.actions.leave", + message: "pages.rooms.leaveRoom.confirmation", + }); + }); + + it("should call remove method after confirmation", async () => { + const { wrapper } = setup({ currentUserRole: RoleName.Roomadmin }); + + askConfirmationMock.mockResolvedValue(true); + + const menuBtn = wrapper.findComponent('[data-testid="room-member-menu"]'); + await menuBtn.trigger("click"); + + const leaveMenu = wrapper.findComponent( + '[data-testid="kebab-menu-action-leave-room"]' + ); + await leaveMenu.trigger("click"); + + expect(mockRoomMemberCalls.removeMembers).toHaveBeenCalledWith([ + mockRoomMemberCalls.currentUser.value.userId, + ]); + }); + + it("should not call remove method when dialog is cancelled", async () => { + const { wrapper } = setup({ currentUserRole: RoleName.Roomadmin }); + + askConfirmationMock.mockResolvedValue(false); + + const menuBtn = wrapper.findComponent('[data-testid="room-member-menu"]'); + await menuBtn.trigger("click"); + + const leaveMenu = wrapper.findComponent( + '[data-testid="kebab-menu-action-leave-room"]' + ); + await leaveMenu.trigger("click"); + + expect(mockRoomMemberCalls.removeMembers).not.toHaveBeenCalled(); + }); + }); + + describe("visibility options", () => { + describe("title menu visibility", () => { + it.each([ + [RoleName.Roomowner, false], + [RoleName.Roomadmin, true], + [RoleName.Roomeditor, true], + [RoleName.Roomviewer, true], + ])( + "%s role should see the title menu", + async (currentUserRole, expectedVisibility) => { + const { wrapper } = setup({ currentUserRole }); + const titleMenu = wrapper.findComponent( + '[data-testid="room-member-menu"]' + ); + + expect(titleMenu.exists()).toBe(expectedVisibility); + } + ); + }); + }); + describe("DefaultWireframe", () => { const buildBreadcrumbs = (room: RoomDetailsResponse) => { return [ @@ -191,127 +316,126 @@ describe("RoomMembersPage", () => { dataTestId: "fab-add-members", }); }); + }); - describe("add members fab", () => { - it("should call getSchools and getPotantialMembers method", async () => { - const { wrapper } = setup(); - const wireframe = wrapper.findComponent({ name: "DefaultWireframe" }); - - const addMemberButton = wireframe - .getComponent("[data-testid=fab-add-members]") - .getComponent(VBtn); + describe("add members fab", () => { + it("should call getSchools and getPotantialMembers method", async () => { + const { wrapper } = setup(); + const wireframe = wrapper.findComponent({ name: "DefaultWireframe" }); - await addMemberButton.trigger("click"); + const addMemberButton = wireframe + .getComponent("[data-testid=fab-add-members]") + .getComponent(VBtn); - expect(mockRoomMemberCalls.getSchools).toHaveBeenCalled(); - expect(mockRoomMemberCalls.getPotentialMembers).toHaveBeenCalledWith( - RoleName.Teacher - ); - }); + await addMemberButton.trigger("click"); - it("should open Dialog", async () => { - const { wrapper } = setup(); - const wireframe = wrapper.findComponent({ name: "DefaultWireframe" }); - const addMemberDialogBeforeClick = wrapper - .getComponent(VDialog) - .findComponent(AddMembers); + expect(mockRoomMemberCalls.getSchools).toHaveBeenCalled(); + expect(mockRoomMemberCalls.getPotentialMembers).toHaveBeenCalledWith( + RoleName.Teacher + ); + }); - expect(addMemberDialogBeforeClick.exists()).toBe(false); + it("should open Dialog", async () => { + const { wrapper } = setup(); + const wireframe = wrapper.findComponent({ name: "DefaultWireframe" }); + const addMemberDialogBeforeClick = wrapper + .getComponent(VDialog) + .findComponent(AddMembers); - const addMemberButton = wireframe - .getComponent("[data-testid=fab-add-members]") - .getComponent(VBtn); + expect(addMemberDialogBeforeClick.exists()).toBe(false); - await addMemberButton.trigger("click"); + const addMemberButton = wireframe + .getComponent("[data-testid=fab-add-members]") + .getComponent(VBtn); - const addMemberDialogAfterClick = wrapper - .getComponent(VDialog) - .findComponent(AddMembers); + await addMemberButton.trigger("click"); - expect(addMemberDialogAfterClick.exists()).toBe(true); - }); + const addMemberDialogAfterClick = wrapper + .getComponent(VDialog) + .findComponent(AddMembers); - describe("add members dialog", () => { - it("should set isMembersDialogOpen to false on @close", async () => { - const { wrapper } = setup(); + expect(addMemberDialogAfterClick.exists()).toBe(true); + }); + }); - const dialog = wrapper.findComponent(VDialog); - await dialog.setValue(true); - expect(dialog.props("modelValue")).toBe(true); + describe("add members dialog", () => { + it("should set isMembersDialogOpen to false on @close", async () => { + const { wrapper } = setup(); - const addMemberComponent = dialog.findComponent(AddMembers); - await addMemberComponent.vm.$emit("close"); + const dialog = wrapper.findComponent(VDialog); + await dialog.setValue(true); + expect(dialog.props("modelValue")).toBe(true); - expect(dialog.props("modelValue")).toBe(false); - }); + const addMemberComponent = dialog.findComponent(AddMembers); + await addMemberComponent.vm.$emit("close"); - it("should close dialog on escape key", async () => { - const { wrapper } = setup(); + expect(dialog.props("modelValue")).toBe(false); + }); - const dialog = wrapper.getComponent(VDialog); - await dialog.setValue(true); + it("should close dialog on escape key", async () => { + const { wrapper } = setup(); - const dialogContent = dialog.getComponent(AddMembers); - await dialogContent.trigger("keydown.escape"); + const dialog = wrapper.getComponent(VDialog); + await dialog.setValue(true); - expect(dialog.props("modelValue")).toBe(false); - }); + const dialogContent = dialog.getComponent(AddMembers); + await dialogContent.trigger("keydown.escape"); - it("should call addMembers method on @add:members", async () => { - const { wrapper } = setup(); + expect(dialog.props("modelValue")).toBe(false); + }); - const dialog = wrapper.findComponent(VDialog); - await dialog.setValue(true); - const addMemberComponent = dialog.findComponent(AddMembers); + it("should call addMembers method on @add:members", async () => { + const { wrapper } = setup(); - await addMemberComponent.vm.$emit("add:members"); + const dialog = wrapper.findComponent(VDialog); + await dialog.setValue(true); + const addMemberComponent = dialog.findComponent(AddMembers); - expect(mockRoomMemberCalls.addMembers).toHaveBeenCalled(); - }); + await addMemberComponent.vm.$emit("add:members"); - it("should call getPotentialMembers method on @update:role", async () => { - const { wrapper } = setup(); + expect(mockRoomMemberCalls.addMembers).toHaveBeenCalled(); + }); - const dialog = wrapper.getComponent(VDialog); - await dialog.setValue(true); - const addMemberComponent = dialog.getComponent(AddMembers); + it("should call getPotentialMembers method on @update:role", async () => { + const { wrapper } = setup(); - await addMemberComponent.vm.$emit("update:role", { - schoolRole: RoleName.Teacher, - schoolId: "school-id", - }); + const dialog = wrapper.getComponent(VDialog); + await dialog.setValue(true); + const addMemberComponent = dialog.getComponent(AddMembers); - expect(mockRoomMemberCalls.getPotentialMembers).toHaveBeenCalled(); - }); + await addMemberComponent.vm.$emit("update:role", { + schoolRole: RoleName.Teacher, + schoolId: "school-id", }); - }); - describe("MembersTable", () => { - it("should render MembersTable if isLoading false", async () => { - mockRoomMemberCalls.isLoading = ref(false); - const { wrapper } = setup(); + expect(mockRoomMemberCalls.getPotentialMembers).toHaveBeenCalled(); + }); + }); - const membersTable = wrapper.findComponent(MembersTable); - expect(membersTable.exists()).toBe(true); - }); + describe("MembersTable", () => { + it("should render MembersTable if isLoading false", async () => { + mockRoomMemberCalls.isLoading = ref(false); + const { wrapper } = setup(); - it("should not render MembersTable if isLoading true", async () => { - mockRoomMemberCalls.isLoading = ref(true); - const { wrapper } = setup(); + const membersTable = wrapper.findComponent(MembersTable); + expect(membersTable.exists()).toBe(true); + }); - const membersTable = wrapper.findComponent(MembersTable); - expect(membersTable.exists()).toBe(false); - }); + it("should not render MembersTable if isLoading true", async () => { + mockRoomMemberCalls.isLoading = ref(true); + const { wrapper } = setup(); - it("should call remove members method on @remove:members", async () => { - mockRoomMemberCalls.isLoading = ref(false); - const { wrapper } = setup(); + const membersTable = wrapper.findComponent(MembersTable); + expect(membersTable.exists()).toBe(false); + }); - const membersTable = wrapper.findComponent(MembersTable); - await membersTable.vm.$emit("remove:members"); + it("should call remove members method on @remove:members", async () => { + mockRoomMemberCalls.isLoading = ref(false); + const { wrapper } = setup(); + const membersTable = wrapper.findComponent(MembersTable); + await membersTable.vm.$emit("remove:members"); - expect(mockRoomMemberCalls.removeMembers).toHaveBeenCalled(); - }); + expect(mockRoomMemberCalls.removeMembers).toHaveBeenCalled(); }); }); }); diff --git a/src/modules/page/room/RoomMembers.page.vue b/src/modules/page/room/RoomMembers.page.vue index 23e0521aa9..63f48eac98 100644 --- a/src/modules/page/room/RoomMembers.page.vue +++ b/src/modules/page/room/RoomMembers.page.vue @@ -8,9 +8,18 @@ ref="wireframe" >
@@ -30,6 +39,7 @@ @@ -52,6 +62,7 @@ /> + diff --git a/src/modules/ui/kebab-menu/KebabMenuActionLeaveRoom.unit.ts b/src/modules/ui/kebab-menu/KebabMenuActionLeaveRoom.unit.ts new file mode 100644 index 0000000000..039fc41627 --- /dev/null +++ b/src/modules/ui/kebab-menu/KebabMenuActionLeaveRoom.unit.ts @@ -0,0 +1,19 @@ +import { + createTestingI18n, + createTestingVuetify, +} from "@@/tests/test-utils/setup"; +import { mount } from "@vue/test-utils"; +import KebabMenuActionLeaveRoom from "./KebabMenuActionLeaveRoom.vue"; + +describe("KebabMenuActionLeaveRoom", () => { + it("should render the component", async () => { + const wrapper = mount(KebabMenuActionLeaveRoom, { + global: { + plugins: [createTestingVuetify(), createTestingI18n()], + }, + }); + + expect(wrapper.exists()).toBe(true); + expect(wrapper.text()).toBe("pages.rooms.leaveRoom.menu"); + }); +}); diff --git a/src/modules/ui/kebab-menu/KebabMenuActionLeaveRoom.vue b/src/modules/ui/kebab-menu/KebabMenuActionLeaveRoom.vue new file mode 100644 index 0000000000..d1449bdeea --- /dev/null +++ b/src/modules/ui/kebab-menu/KebabMenuActionLeaveRoom.vue @@ -0,0 +1,16 @@ + + + diff --git a/src/modules/ui/kebab-menu/KebabMenuActionRemoveMember.unit.ts b/src/modules/ui/kebab-menu/KebabMenuActionRemoveMember.unit.ts new file mode 100644 index 0000000000..31f07e80c5 --- /dev/null +++ b/src/modules/ui/kebab-menu/KebabMenuActionRemoveMember.unit.ts @@ -0,0 +1,19 @@ +import { + createTestingI18n, + createTestingVuetify, +} from "@@/tests/test-utils/setup"; +import { mount } from "@vue/test-utils"; +import KebabMenuActionRemoveMember from "./KebabMenuActionRemoveMember.vue"; + +describe("KebabMenuActionRemoveMember", () => { + it("should render the component", async () => { + const wrapper = mount(KebabMenuActionRemoveMember, { + global: { + plugins: [createTestingVuetify(), createTestingI18n()], + }, + }); + + expect(wrapper.exists()).toBe(true); + expect(wrapper.text()).toBe("common.actions.remove"); + }); +}); diff --git a/src/modules/ui/kebab-menu/KebabMenuActionRemoveMember.vue b/src/modules/ui/kebab-menu/KebabMenuActionRemoveMember.vue new file mode 100644 index 0000000000..bfd0e8a7ba --- /dev/null +++ b/src/modules/ui/kebab-menu/KebabMenuActionRemoveMember.vue @@ -0,0 +1,15 @@ + + + diff --git a/src/modules/ui/kebab-menu/index.ts b/src/modules/ui/kebab-menu/index.ts index 44a52c923a..2b00f365b2 100644 --- a/src/modules/ui/kebab-menu/index.ts +++ b/src/modules/ui/kebab-menu/index.ts @@ -13,21 +13,27 @@ import KebabMenuActionRename from "./KebabMenuActionRename.vue"; import KebabMenuActionRevert from "./KebabMenuActionRevert.vue"; import KebabMenuActionShare from "./KebabMenuActionShare.vue"; import KebabMenuActionShareLink from "./KebabMenuActionShareLink.vue"; +import KebabMenuActionLeaveRoom from "./KebabMenuActionLeaveRoom.vue"; +import KebabMenuActionChangePermission from "./KebabMenuActionChangePermission.vue"; +import KebabMenuActionRemoveMember from "./KebabMenuActionRemoveMember.vue"; export { KebabMenu, KebabMenuAction, + KebabMenuActionChangeLayout, + KebabMenuActionChangePermission, KebabMenuActionCopy, KebabMenuActionEdit, KebabMenuActionDelete, - KebabMenuActionMoveUp, + KebabMenuActionLeaveRoom, KebabMenuActionMoveDown, KebabMenuActionMoveLeft, KebabMenuActionMoveRight, + KebabMenuActionMoveUp, KebabMenuActionPublish, + KebabMenuActionRemoveMember, + KebabMenuActionRename, KebabMenuActionRevert, KebabMenuActionShare, - KebabMenuActionRename, - KebabMenuActionChangeLayout, KebabMenuActionShareLink, };