diff --git a/config/webpack/webpack.common.js b/config/webpack/webpack.common.js index 14c26a8f89..97ca87b631 100644 --- a/config/webpack/webpack.common.js +++ b/config/webpack/webpack.common.js @@ -145,6 +145,9 @@ module.exports = { "@feature-board-collaborative-text-editor-element": getDir( "src/modules/feature/board-collaborative-text-editor-element" ), + "@feature-board-video-conference-element": getDir( + "src/modules/feature/board-video-conference-element" + ), "@feature-board-deleted-element": getDir( "src/modules/feature/board-deleted-element" ), diff --git a/src/assets/img/index.d.ts b/src/assets/img/index.d.ts index fedf5cbbc2..041d291d64 100644 --- a/src/assets/img/index.d.ts +++ b/src/assets/img/index.d.ts @@ -11,3 +11,7 @@ declare module "@/assets/img/collaborativeEditor.svg" { const value: string; export default value; } +declare module "@/assets/img/videoConference.svg" { + const value: string; + export default value; +} diff --git a/src/assets/img/videoConference.svg b/src/assets/img/videoConference.svg new file mode 100644 index 0000000000..e1bbc5ccc7 --- /dev/null +++ b/src/assets/img/videoConference.svg @@ -0,0 +1,86 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/components/icons/material/index.ts b/src/components/icons/material/index.ts index 5a3ce94511..2bb64c1a26 100644 --- a/src/components/icons/material/index.ts +++ b/src/components/icons/material/index.ts @@ -136,6 +136,7 @@ import { mdiTune, mdiUndo, mdiUndoVariant, + mdiVideo, mdiViewAgendaOutline, mdiViewDashboard, mdiViewDashboardOutline, @@ -282,6 +283,7 @@ export { mdiTune, mdiUndo, mdiUndoVariant, + mdiVideo, mdiViewAgendaOutline, mdiViewDashboard, mdiViewDashboardOutline, diff --git a/src/locales/de.ts b/src/locales/de.ts index c6aae542b5..b4774c7bc6 100644 --- a/src/locales/de.ts +++ b/src/locales/de.ts @@ -167,6 +167,7 @@ export default { "common.words.color.purple": "Violett", "common.words.color.red": "Rot", "common.words.color.turquoise": "Türkis", + "common.words.color.yellow": "Gelb", "common.words.copiedToClipboard": "In die Zwischenablage kopiert", "common.words.courseGroups": "Kursgruppen", "common.words.courses": "Kurse", @@ -353,6 +354,8 @@ export default { "components.board.menu.fileElement": "Datei-Einstellungen", "components.board.menu.linkElement": "Link-Einstellungen", "components.board.menu.submissionElement": "Abgabe-Einstellungen", + "components.board.menu.videoConferenceElement": + "Videokonferenz-Einstellungen", "components.board.menu.deletedElement": "Gelöschter-Inhalt-Einstellungen", "components.board.notifications.errors.fileNameExists": "Es existiert bereits eine Datei mit diesem Namen.", @@ -448,6 +451,9 @@ export default { "components.cardElement.titleElement.validation.required": "Bitte Titel angeben.", "components.cardElement.titleElement": "Titelelement", + "components.cardElement.videoConferenceElement": "Videokonferenz", + "components.cardElement.videoConferenceElement.label": + "Videokonferenz-Titel einfügen", "components.cardElement.deletedElement": "Gelöschter Inhalt", "components.cardElement.deletedElement.warning.externalToolElement": "Tool {toolName} nicht verfügbar. Bitte an Schuladministrator:in wenden.", @@ -472,6 +478,8 @@ export default { "components.elementTypeSelection.elements.submissionElement.subtitle": "Abgabe", "components.elementTypeSelection.elements.textElement.subtitle": "Text", + "components.elementTypeSelection.elements.videoConferenceElement.subtitle": + "Videokonferenz", "components.externalTools.status.deactivated": "Deaktiviert", "components.externalTools.status.incomplete": "Unvollständig", "components.externalTools.status.latest": "Aktuell", @@ -1494,6 +1502,14 @@ export default { "Lehrkraft erfolgreich angelegt!", "pages.administration.teachers.new.title": "Lehrkraft hinzufügen", "pages.administration.teachers.table.edit.ariaLabel": "Lehrkraft bearbeiten", + "pages.common.tools.configureVideoconferenceDialog.title": + "Videokonferenz erstellen", + "pages.common.tools.configureVideoconferenceDialog.text.allModeratorPermission": + "Alle Nutzer:innen nehmen als Moderator:in teil", + "pages.common.tools.configureVideoconferenceDialog.text.mute": + "Teilnehmer:innen beim Betreten stummschalten", + "pages.common.tools.configureVideoconferenceDialog.text.waitingRoom": + "Freigabe durch Moderator:in, bevor der Raum betreten werden kann", "pages.content._id.addToTopic": "Hinzufügen zu", "pages.content._id.collection.selectElements": "Wählen Sie die Elemente, die Sie zum Thema hinzufügen möchten", @@ -1562,14 +1578,6 @@ export default { "pages.courseRooms.tabLabel.groups": "Gruppen", "pages.courseRooms.tabLabel.tools": "Tools", "pages.courseRooms.tabLabel.toolsOld": "Tools", - "pages.courseRooms.tools.configureVideoconferenceDialog.title": - "Videokonferenz {roomName} erstellen", - "pages.courseRooms.tools.configureVideoconferenceDialog.text.allModeratorPermission": - "Alle Nutzer:innen nehmen als Moderator:in teil", - "pages.courseRooms.tools.configureVideoconferenceDialog.text.mute": - "Teilnehmer:innen beim Betreten stummschalten", - "pages.courseRooms.tools.configureVideoconferenceDialog.text.waitingRoom": - "Freigabe durch Moderator:in, bevor der Raum betreten werden kann", "pages.files.overview.courseFiles": "Kurs-Dateien", "pages.files.overview.favorites": "Favoriten", "pages.files.overview.personalFiles": "Meine persönlichen Dateien", @@ -1809,6 +1817,10 @@ export default { "Die Videokonferenz wurde noch nicht gestartet oder du bist nicht berechtigt, an ihr teilzunehmen.", "pages.videoConference.info.notStarted": "Die Videokonferenz wurde noch nicht gestartet.", + "pages.videoConference.info.notEnabledParticipants": + "Das Element ist nicht mehr verfügbar. Bitte an die Lehrkraft wenden.", + "pages.videoConference.info.notEnabledTeacher": + "Das Element ist nicht mehr verfügbar. Bitte an den Admin wenden.", "pages.videoConference.title": "Videokonferenz BigBlueButton", "error.500": "Es gibt Probleme im System. Wir arbeiten daran, das Problem zu beheben. Bitte später erneut versuchen.", diff --git a/src/locales/en.ts b/src/locales/en.ts index e6a8d7ee50..9432425ba4 100644 --- a/src/locales/en.ts +++ b/src/locales/en.ts @@ -168,6 +168,7 @@ export default { "common.words.color.purple": "Purple", "common.words.color.red": "Red", "common.words.color.turquoise": "Turquoise", + "common.words.color.yellow": "Yellow", "common.words.copiedToClipboard": "Copied to the clipboard", "common.words.courseGroups": "Course Groups", "common.words.courses": "Courses", @@ -350,6 +351,7 @@ export default { "components.board.menu.fileElement": "File settings", "components.board.menu.linkElement": "Link settings", "components.board.menu.submissionElement": "Submission settings", + "components.board.menu.videoConferenceElement": "Video conference settings", "components.board.menu.deletedElement": "Deleted content settings", "components.board.notifications.errors.fileNameExists": "A file with this name already exists.", @@ -445,6 +447,9 @@ export default { "components.cardElement.titleElement.validation.required": "Please enter a title.", "components.cardElement.titleElement": "Title element", + "components.cardElement.videoConferenceElement": "Video conference", + "components.cardElement.videoConferenceElement.label": + "Insert video conference title", "components.cardElement.deletedElement": "Deleted content", "components.cardElement.deletedElement.warning.externalToolElement": "Tool {toolName} not available. Please contact the school administrator.", @@ -468,6 +473,8 @@ export default { "components.elementTypeSelection.elements.submissionElement.subtitle": "Submission", "components.elementTypeSelection.elements.textElement.subtitle": "Text", + "components.elementTypeSelection.elements.videoConferenceElement.subtitle": + "Video conference", "components.externalTools.status.deactivated": "Deactivated", "components.externalTools.status.incomplete": "Configuration incomplete", "components.externalTools.status.latest": "Latest", @@ -1472,6 +1479,14 @@ export default { "pages.administration.teachers.new.success": "Teacher successfully created!", "pages.administration.teachers.new.title": "Add teacher", "pages.administration.teachers.table.edit.ariaLabel": "Edit teacher", + "pages.common.tools.configureVideoconferenceDialog.title": + "Create video conference", + "pages.common.tools.configureVideoconferenceDialog.text.allModeratorPermission": + "All users participate as moderators", + "pages.common.tools.configureVideoconferenceDialog.text.mute": + "Mute participants when entering", + "pages.common.tools.configureVideoconferenceDialog.text.waitingRoom": + "Approval by the moderator before the room can be entered", "pages.content._id.addToTopic": "To be added to", "pages.content._id.collection.selectElements": "Select the items you want to add to the topic", @@ -1538,14 +1553,6 @@ export default { "pages.courseRooms.tabLabel.groups": "Groups", "pages.courseRooms.tabLabel.tools": "Tools", "pages.courseRooms.tabLabel.toolsOld": "Tools", - "pages.courseRooms.tools.configureVideoconferenceDialog.title": - "Create video conference {roomName}", - "pages.courseRooms.tools.configureVideoconferenceDialog.text.allModeratorPermission": - "All users participate as moderators", - "pages.courseRooms.tools.configureVideoconferenceDialog.text.mute": - "Mute participants when entering", - "pages.courseRooms.tools.configureVideoconferenceDialog.text.waitingRoom": - "Approval by the moderator before the room can be entered", "pages.files.overview.courseFiles": "Course files", "pages.files.overview.favorites": "Favourites", "pages.files.overview.personalFiles": "My personal files", @@ -1776,6 +1783,10 @@ export default { "The video conference hasn't started yet or you don't have permission to join it.", "pages.videoConference.info.notStarted": "The video conference hasn't started yet.", + "pages.videoConference.info.notEnabledParticipants": + "The element is no longer available. Please contact the teacher.", + "pages.videoConference.info.notEnabledTeacher": + "The element is no longer available. Please contact the admin.", "pages.videoConference.title": "Video conference BigBlueButton", "error.500": "There are problems in the system. We are working on fixing the issue. Please try again later.", diff --git a/src/locales/es.ts b/src/locales/es.ts index 53935427e8..de782f0aac 100644 --- a/src/locales/es.ts +++ b/src/locales/es.ts @@ -170,6 +170,7 @@ export default { "common.words.color.purple": "Violeta", "common.words.color.red": "Roja", "common.words.color.turquoise": "Turquesa", + "common.words.color.yellow": "Amarillo", "common.words.copiedToClipboard": "Copiado en el portapapeles", "common.words.courseGroups": "grupos de cursos", "common.words.courses": "Cursos", @@ -357,6 +358,8 @@ export default { "components.board.menu.fileElement": "Configuración del archivo", "components.board.menu.linkElement": "Configuración del enlace", "components.board.menu.submissionElement": "Configuración del envío", + "components.board.menu.videoConferenceElement": + "Configuración de la videoconferencia", "components.board.menu.deletedElement": "Configuración de contenido eliminado", "components.board.notifications.errors.fileNameExists": @@ -453,6 +456,9 @@ export default { "components.cardElement.titleElement.validation.required": "Por favor ingrese un título.", "components.cardElement.titleElement": "Elemento título", + "components.cardElement.videoConferenceElement": "Videoconferencia", + "components.cardElement.videoConferenceElement.label": + "Insertar título de la videoconferencia", "components.cardElement.deletedElement": "Contenido eliminado", "components.cardElement.deletedElement.warning.externalToolElement": "La herramienta {toolName} no está disponible. Por favor comuníquese con el administrador de la escuela.", @@ -477,6 +483,8 @@ export default { "components.elementTypeSelection.elements.submissionElement.subtitle": "Envíos", "components.elementTypeSelection.elements.textElement.subtitle": "Texto", + "components.elementTypeSelection.elements.videoConferenceElement.subtitle": + "Videoconferencia", "components.externalTools.status.deactivated": "Desactivado", "components.externalTools.status.incomplete": "Configuración incompleta", "components.externalTools.status.latest": "Actual", @@ -1517,6 +1525,14 @@ export default { "¡Profesor creado correctamente!", "pages.administration.teachers.new.title": "Añadir profesor", "pages.administration.teachers.table.edit.ariaLabel": "Editar profesor", + "pages.common.tools.configureVideoconferenceDialog.title": + "Crear videoconferencia", + "pages.common.tools.configureVideoconferenceDialog.text.allModeratorPermission": + "Todas las usuarias participan como moderadoras", + "pages.common.tools.configureVideoconferenceDialog.text.mute": + "Silenciar a las participantes al entrar", + "pages.common.tools.configureVideoconferenceDialog.text.waitingRoom": + "Aprobación del moderador antes de poder ingresar a la sala", "pages.content._id.addToTopic": "Para ser añadido a", "pages.content._id.collection.selectElements": "Selecciona los elementos que deses añadir al tema", @@ -1584,14 +1600,6 @@ export default { "pages.courseRooms.tabLabel.groups": "Grupos", "pages.courseRooms.tabLabel.tools": "Herramientas", "pages.courseRooms.tabLabel.toolsOld": "Herramientas", - "pages.courseRooms.tools.configureVideoconferenceDialog.title": - "Crear videoconferencia {roomName}", - "pages.courseRooms.tools.configureVideoconferenceDialog.text.allModeratorPermission": - "Todas las usuarias participan como moderadoras", - "pages.courseRooms.tools.configureVideoconferenceDialog.text.mute": - "Silenciar a las participantes al entrar", - "pages.courseRooms.tools.configureVideoconferenceDialog.text.waitingRoom": - "Aprobación del moderador antes de poder ingresar a la sala", "pages.files.overview.courseFiles": "Archivos del curso", "pages.files.overview.favorites": "Favoritos", "pages.files.overview.personalFiles": "Archivos personales", @@ -1827,6 +1835,10 @@ export default { "La videoconferencia aún no ha comenzado o no tienes permiso para unirte.", "pages.videoConference.info.notStarted": "La videoconferencia aún no ha comenzado.", + "pages.videoConference.info.notEnabledParticipants": + "El elemento ya no está disponible. Póngase en contacto con el profesor.", + "pages.videoConference.info.notEnabledTeacher": + "El elemento ya no está disponible. Póngase en contacto con el administrador.", "pages.videoConference.title": "Videoconferencia BigBlueButton", "error.500": "Hay problemas en el sistema. Estamos trabajando para solucionar el problema. Vuelva a intentarlo más tarde.", diff --git a/src/locales/uk.ts b/src/locales/uk.ts index 1429cd4309..af0bfd3c40 100644 --- a/src/locales/uk.ts +++ b/src/locales/uk.ts @@ -170,6 +170,7 @@ export default { "common.words.color.purple": "Фіалка", "common.words.color.red": "Червоний", "common.words.color.turquoise": "Бірюза", + "common.words.color.yellow": "Жовтий", "common.words.copiedToClipboard": "Скопійовано в буфер обміну", "common.words.courseGroups": "курсові групи", "common.words.courses": "Мій курс", @@ -358,6 +359,8 @@ export default { "components.board.menu.fileElement": "Налаштування файлу", "components.board.menu.linkElement": "Налаштування посилання", "components.board.menu.submissionElement": "Налаштування Подання", + "components.board.menu.videoConferenceElement": + "Налаштування відеоконференція", "components.board.menu.deletedElement": "Видалені налаштування вмісту", "components.board.notifications.errors.fileNameExists": "Файл з такою назвою вже існує.", @@ -454,6 +457,9 @@ export default { "components.cardElement.titleElement.validation.required": "Будь ласка, введіть назву.", "components.cardElement.titleElement": "Елемент заголовка", + "components.cardElement.videoConferenceElement": "Відеоконференція", + "components.cardElement.videoConferenceElement.label": + "Вставити назву відеоконференції", "components.cardElement.deletedElement": "Видалений вміст", "components.cardElement.deletedElement.warning.externalToolElement": "Інструмент {toolName} недоступний. Будь ласка, зверніться до адміністратора школи.", @@ -476,6 +482,8 @@ export default { "components.elementTypeSelection.elements.submissionElement.subtitle": "Подання", "components.elementTypeSelection.elements.textElement.subtitle": "Текст", + "components.elementTypeSelection.elements.videoConferenceElement.subtitle": + "Відеоконференція", "components.externalTools.status.deactivated": "Деактивовано", "components.externalTools.status.incomplete": "Конфігурація не завершена", "components.externalTools.status.latest": "Останній", @@ -1500,6 +1508,14 @@ export default { "pages.administration.teachers.new.success": "Викладача успішно створено!", "pages.administration.teachers.new.title": "Додати викладача", "pages.administration.teachers.table.edit.ariaLabel": "Редагування вчителя", + "pages.common.tools.configureVideoconferenceDialog.title": + "Створити відеоконференцію", + "pages.common.tools.configureVideoconferenceDialog.text.allModeratorPermission": + "Усі користувачі беруть участь як модератори", + "pages.common.tools.configureVideoconferenceDialog.text.mute": + "Вимкнення звуку учасників при вході", + "pages.common.tools.configureVideoconferenceDialog.text.waitingRoom": + "Схвалення модератором перед входом до кімнати", "pages.content._id.addToTopic": "Для додавання в", "pages.content._id.collection.selectElements": "Виберіть елементи, які треба додати до теми", @@ -1566,14 +1582,6 @@ export default { "pages.courseRooms.tabLabel.groups": "Групи", "pages.courseRooms.tabLabel.tools": "Інструмент", "pages.courseRooms.tabLabel.toolsOld": "Інструмент", - "pages.courseRooms.tools.configureVideoconferenceDialog.title": - "Створити відеоконференцію {roomName}", - "pages.courseRooms.tools.configureVideoconferenceDialog.text.allModeratorPermission": - "Усі користувачі беруть участь як модератори", - "pages.courseRooms.tools.configureVideoconferenceDialog.text.mute": - "Вимкнення звуку учасників при вході", - "pages.courseRooms.tools.configureVideoconferenceDialog.text.waitingRoom": - "Схвалення модератором перед входом до кімнати", "pages.files.overview.courseFiles": "Файли курсу", "pages.files.overview.favorites": "Обрані", "pages.files.overview.personalFiles": "Мої особисті справи", @@ -1803,6 +1811,10 @@ export default { "pages.videoConference.info.noPermission": "Відеоконференція ще не почалася або у вас немає дозволу приєднатися до неї.", "pages.videoConference.info.notStarted": "Відеоконференція ще не почалася.", + "pages.videoConference.info.notEnabledParticipants": + "Елемент більше не доступний. Будь ласка, зверніться до викладача.", + "pages.videoConference.info.notEnabledTeacher": + "Елемент більше не доступний. Будь ласка, зверніться з адміністратором.", "pages.videoConference.title": "Відеоконференція BigBlueButton", "error.500": "Є проблеми в системі. Ми працюємо над усуненням проблеми. Будь ласка, спробуйте пізніше.", diff --git a/src/modules/data/board/BoardApi.composable.ts b/src/modules/data/board/BoardApi.composable.ts index f3e1d06ce2..033fdfaedc 100644 --- a/src/modules/data/board/BoardApi.composable.ts +++ b/src/modules/data/board/BoardApi.composable.ts @@ -17,6 +17,7 @@ import { RichTextElementContentBody, RoomApiFactory, SubmissionContainerElementContentBody, + VideoConferenceElementContentBody, } from "@/serverApi/v3"; import { BoardContextType } from "@/types/board/BoardContext"; import { AnyContentElement } from "@/types/board/ContentElement"; @@ -124,6 +125,13 @@ export const useBoardApi = () => { type: ContentElementType.Drawing, }; } + + if (element.type === ContentElementType.VideoConference) { + return { + content: element.content, + type: ContentElementType.VideoConference, + } as VideoConferenceElementContentBody; + } throw new Error("element.type mapping is undefined for updateElementCall"); }; diff --git a/src/modules/data/board/BoardApi.composable.unit.ts b/src/modules/data/board/BoardApi.composable.unit.ts index 2d6a275dd5..56a896b404 100644 --- a/src/modules/data/board/BoardApi.composable.unit.ts +++ b/src/modules/data/board/BoardApi.composable.unit.ts @@ -267,6 +267,29 @@ describe("BoardApi.composable", () => { ); }); + it("should call elementControllerUpdateElement api with VideoConferenceElement", async () => { + const { updateElementCall } = useBoardApi(); + const payload: serverApi.VideoConferenceElementResponse = { + id: "video-conference-element-id", + type: ContentElementType.VideoConference, + content: { + title: "Some title", + }, + timestamps: timestampsResponseFactory.build(), + }; + const data = { + content: payload.content, + type: ContentElementType.VideoConference, + }; + + await updateElementCall(payload); + + expect(elementApi.elementControllerUpdateElement).toHaveBeenCalledWith( + payload.id, + { data } + ); + }); + it("should throw error for unkown element type", async () => { const { updateElementCall } = useBoardApi(); const payload = { diff --git a/src/modules/data/room/roomMembers/roomMembers.composable.ts b/src/modules/data/room/roomMembers/roomMembers.composable.ts index b5fceb22f1..d500b19ea3 100644 --- a/src/modules/data/room/roomMembers/roomMembers.composable.ts +++ b/src/modules/data/room/roomMembers/roomMembers.composable.ts @@ -6,13 +6,12 @@ import { SchoolApiFactory, RoomMemberResponse, SchoolForExternalInviteResponse, - UserIdAndRole, - UserIdAndRoleRoleNameEnum, } from "@/serverApi/v3"; import { $axios } from "@/utils/api"; import { useI18n } from "vue-i18n"; import { useBoardNotifier } from "@util-board"; import { schoolsModule } from "@/store"; +import { authModule } from "@/store/store-accessor"; export const useRoomMembers = (roomId: string) => { const roomMembers: Ref = ref([]); @@ -25,9 +24,13 @@ export const useRoomMembers = (roomId: string) => { id: schoolsModule.getSchool.id, name: schoolsModule.getSchool.name, }; + const currentUserId = authModule.getUser?.id ?? ""; const userRoles: Record = { + [RoleName.Roomowner]: t("common.labels.teacher"), + [RoleName.Roomadmin]: t("common.labels.teacher"), [RoleName.Roomeditor]: t("common.labels.teacher"), + [RoleName.Roomviewer]: t("common.labels.teacher"), }; const roomApi = RoomApiFactory(undefined, "/v3", $axios); @@ -42,6 +45,10 @@ export const useRoomMembers = (roomId: string) => { return { ...member, displayRoleName: userRoles[member.roleName], + isSelectable: !( + member.userId === currentUserId || + member.roleName === RoleName.Roomowner + ), }; }); isLoading.value = false; @@ -103,15 +110,8 @@ export const useRoomMembers = (roomId: string) => { userIds.includes(member.userId) ); - const userIdsAndRoles: UserIdAndRole[] = newMembers.map((member) => ({ - userId: member.userId, - roleName: UserIdAndRoleRoleNameEnum.Roomeditor, - })); - try { - await roomApi.roomControllerAddMembers(roomId, { - userIdsAndRoles, - }); + await roomApi.roomControllerAddMembers(roomId, { userIds }); roomMembers.value.push( ...newMembers.map((member) => ({ ...member, diff --git a/src/modules/data/room/roomMembers/roomMembers.composable.unit.ts b/src/modules/data/room/roomMembers/roomMembers.composable.unit.ts index c065523b7c..cfe59bb320 100644 --- a/src/modules/data/room/roomMembers/roomMembers.composable.unit.ts +++ b/src/modules/data/room/roomMembers/roomMembers.composable.unit.ts @@ -1,9 +1,10 @@ import { roomMemberListFactory, mockApiResponse, - roomMemberResponseFactory, + roomMemberFactory, roomMemberSchoolResponseFactory, schoolFactory, + meResponseFactory, } from "@@/tests/test-utils"; import { createMock, DeepMocked } from "@golevelup/ts-jest"; import * as serverApi from "@/serverApi/v3/api"; @@ -15,11 +16,11 @@ import { RoleName, RoomMemberResponse, SchoolUserListResponse, - UserIdAndRoleRoleNameEnum, } from "@/serverApi/v3/api"; import { useBoardNotifier } from "@util-board"; -import { schoolsModule } from "@/store"; +import { schoolsModule, authModule } from "@/store"; import SchoolsModule from "@/store/schools"; +import AuthModule from "@/store/auth"; import setupStores from "@@/tests/test-utils/setupStores"; jest.mock("vue-i18n"); @@ -54,6 +55,7 @@ describe("useRoomMembers", () => { setupStores({ schoolsModule: SchoolsModule, + authModule: AuthModule, }); schoolsModule.setSchool( @@ -62,6 +64,9 @@ describe("useRoomMembers", () => { name: "Paul-Gerhardt-Gymnasium", }) ); + + const mockMe = meResponseFactory.build(); + authModule.setMe(mockMe); }); afterEach(() => { @@ -70,24 +75,52 @@ describe("useRoomMembers", () => { }); describe("fetchMembers", () => { - it("should fetch members and map members with role names", async () => { - const { fetchMembers, roomMembers } = useRoomMembers(roomId); - const membersMock = roomMemberResponseFactory.buildList(3); - - roomApiMock.roomControllerGetMembers.mockResolvedValue( - mockApiResponse({ - data: { data: membersMock }, - }) - ); + describe("when the user is not room owner", () => { + it("should fetch members and map members with role names", async () => { + const { fetchMembers, roomMembers } = useRoomMembers(roomId); + const membersMock = roomMemberFactory(RoleName.Roomeditor).buildList(3); + + roomApiMock.roomControllerGetMembers.mockResolvedValue( + mockApiResponse({ + data: { data: membersMock }, + }) + ); + + await fetchMembers(); + + expect(roomMembers.value).toEqual( + membersMock.map((member) => ({ + ...member, + displayRoleName: "common.labels.teacher", + isSelectable: true, + })) + ); + }); + }); - await fetchMembers(); + describe("when the user is room owner", () => { + it("should fetch members and map members with role names", async () => { + const { fetchMembers, roomMembers } = useRoomMembers(roomId); + const membersMock = roomMemberFactory(RoleName.Roomowner).buildList(3); - expect(roomMembers.value).toEqual( - membersMock.map((member) => ({ - ...member, - displayRoleName: "common.labels.teacher", - })) - ); + roomApiMock.roomControllerGetMembers.mockResolvedValue( + mockApiResponse({ + data: { + data: membersMock, + }, + }) + ); + + await fetchMembers(); + + expect(roomMembers.value).toEqual( + membersMock.map((member) => ({ + ...member, + displayRoleName: "common.labels.teacher", + isSelectable: false, + })) + ); + }); }); it("should throw an error if the API call fails", async () => { @@ -151,7 +184,9 @@ describe("useRoomMembers", () => { const { getPotentialMembers, potentialRoomMembers, roomMembers } = useRoomMembers(roomId); - const membersMock: RoomMemberResponse = roomMemberResponseFactory.build(); + const membersMock: RoomMemberResponse = roomMemberFactory( + RoleName.Roomeditor + ).build(); roomMembers.value = [membersMock]; @@ -242,12 +277,7 @@ describe("useRoomMembers", () => { expect(roomApiMock.roomControllerAddMembers).toHaveBeenCalledWith( roomId, { - userIdsAndRoles: [ - { - userId: firstPotentialMember.userId, - roleName: UserIdAndRoleRoleNameEnum.Roomeditor, - }, - ], + userIds: [firstPotentialMember.userId], } ); expect(roomMembers.value).toEqual([ @@ -280,7 +310,7 @@ describe("useRoomMembers", () => { mockApiResponse({}) ); - const membersMock = roomMemberResponseFactory.buildList(3); + const membersMock = roomMemberFactory(RoleName.Roomeditor).buildList(3); roomMembers.value = membersMock; const firstMember = membersMock[0]; diff --git a/src/modules/feature/board-video-conference-element/components/VideoConferenceContentElement.unit.ts b/src/modules/feature/board-video-conference-element/components/VideoConferenceContentElement.unit.ts new file mode 100644 index 0000000000..991d498b12 --- /dev/null +++ b/src/modules/feature/board-video-conference-element/components/VideoConferenceContentElement.unit.ts @@ -0,0 +1,609 @@ +import { + AUTH_MODULE_KEY, + ENV_CONFIG_MODULE_KEY, + NOTIFIER_MODULE_KEY, + VIDEO_CONFERENCE_MODULE_KEY, +} from "@/utils/inject"; + +import { videoConferenceElementResponseFactory } from "@@/tests/test-utils/factory/videoConferenceElementResponseFactory"; +import { + useBoardFocusHandler, + useBoardPermissions, + useContentElementState, +} from "@data-board"; +import { createMock, DeepMocked } from "@golevelup/ts-jest"; +import { shallowMount } from "@vue/test-utils"; +import { computed, ref } from "vue"; +import NotifierModule from "@/store/notifier"; +import { createModuleMocks } from "@@/tests/test-utils/mock-store-module"; +import { + ConfigResponse, + VideoConferenceElementContent, + VideoConferenceScope, +} from "@/serverApi/v3/api"; +import VideoConferenceContentElementDisplay from "./VideoConferenceContentElementDisplay.vue"; +import VideoConferenceContentElementCreate from "./VideoConferenceContentElementCreate.vue"; +import { videoConferenceElementContentFactory } from "@@/tests/test-utils/factory/videoConferenceElementContentFactory"; +import { + createTestingI18n, + createTestingVuetify, +} from "@@/tests/test-utils/setup"; +import { + BoardMenu, + BoardMenuActionDelete, + BoardMenuActionMoveDown, + BoardMenuActionMoveUp, +} from "@ui-board"; +import { VideoConferenceContentElement } from "@feature-board-video-conference-element"; +import AuthModule from "@/store/auth"; +import VideoConferenceModule from "@/store/video-conference"; +import EnvConfigModule from "@/store/env-config"; +import { Router, useRoute, useRouter } from "vue-router"; +import { VideoConferenceState } from "@/store/types/video-conference"; +import { VDialog } from "vuetify/lib/components/index.mjs"; + +jest.mock("@data-board/ContentElementState.composable"); +jest.mock("@data-board/BoardFocusHandler.composable"); +jest.mock("@data-board/BoardPermissions.composable"); + +window.open = jest.fn(); + +jest.mock("vue-router"); +const useRouterMock = useRouter; +const useRouteMock = useRoute; +useRouteMock.mockReturnValue({ params: { id: "room-id" } }); + +const mockedUseContentElementState = jest.mocked(useContentElementState); + +let defaultElement = videoConferenceElementResponseFactory.build(); +const mockedEnvConfigModule = createModuleMocks(EnvConfigModule, { + getEnv: createMock({ + FEATURE_COLUMN_BOARD_VIDEOCONFERENCE_ENABLED: true, + }), +}); + +describe("VideoConferenceContentElement", () => { + let router: DeepMocked; + let route: DeepMocked>; + let useBoardFocusHandlerMock: DeepMocked< + ReturnType + >; + let useBoardPermissionsMock: DeepMocked< + ReturnType + >; + + beforeEach(() => { + route = createMock>(); + useRouteMock.mockReturnValue(route); + useRouteMock.mockReturnValue({ params: { id: "room-id" } }); + + router = createMock(); + useRouterMock.mockReturnValue(router); + + useBoardFocusHandlerMock = + createMock>(); + useBoardPermissionsMock = createMock< + ReturnType + >({ + isTeacher: true, + hasMovePermission: false, + hasCreateCardPermission: false, + hasCreateColumnPermission: false, + hasEditPermission: false, + hasDeletePermission: false, + isStudent: false, + }); + + jest.mocked(useBoardFocusHandler).mockReturnValue(useBoardFocusHandlerMock); + + jest.mocked(useBoardPermissions).mockReturnValue(useBoardPermissionsMock); + defaultElement = videoConferenceElementResponseFactory.build(); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + const setupWrapper = ( + options: { + content?: VideoConferenceElementContent; + isEditMode: boolean; + role?: "teacher" | "student"; + columnIndex?: number; + rowIndex?: number; + elementIndex?: number; + videoConferenceModuleGetter?: Partial; + } = { + content: undefined, + isEditMode: true, + role: "teacher", + columnIndex: 0, + rowIndex: 1, + elementIndex: 2, + } + ) => { + const { + content, + isEditMode, + role = "teacher", + columnIndex = 0, + rowIndex = 1, + elementIndex = 2, + videoConferenceModuleGetter, + } = options; + + const element = { + ...defaultElement, + content: videoConferenceElementContentFactory.build({ + title: undefined, + ...content, + }), + }; + + mockedUseContentElementState.mockReturnValue({ + modelValue: ref(element.content), + computedElement: computed(() => element), + isLoading: ref(false), + }); + + const notifierModule = createModuleMocks(NotifierModule); + const authModule = createModuleMocks(AuthModule, { + getUserRoles: [role], + }); + + const videoConferenceModule = createModuleMocks(VideoConferenceModule, { + getVideoConferenceInfo: { + state: VideoConferenceState.NOT_STARTED, + options: { + everyAttendeeJoinsMuted: false, + moderatorMustApproveJoinRequests: false, + everybodyJoinsAsModerator: false, + }, + }, + getLoading: false, + ...videoConferenceModuleGetter, + }); + + const joinVideoConferenceMock = jest + .fn() + .mockResolvedValueOnce({ url: "https://example.com" }); + videoConferenceModule.joinVideoConference = joinVideoConferenceMock; + + const wrapper = shallowMount(VideoConferenceContentElement, { + global: { + plugins: [createTestingVuetify(), createTestingI18n()], + provide: { + [NOTIFIER_MODULE_KEY.valueOf()]: notifierModule, + [ENV_CONFIG_MODULE_KEY.valueOf()]: mockedEnvConfigModule, + [AUTH_MODULE_KEY.valueOf()]: authModule, + [VIDEO_CONFERENCE_MODULE_KEY.valueOf()]: videoConferenceModule, + }, + }, + props: { + element, + isEditMode, + columnIndex, + rowIndex, + elementIndex, + }, + }); + + return { + element, + videoConferenceModule, + wrapper, + }; + }; + + describe("when video conference element is displayed", () => { + describe("and content title is undefined", () => { + it("should not render display of video conference content", () => { + const { wrapper } = setupWrapper({ + isEditMode: false, + }); + + const videoConferenceElementDisplay = wrapper.findComponent( + VideoConferenceContentElementDisplay + ); + + expect(videoConferenceElementDisplay.exists()).toBe(false); + }); + + it("should not render video conference element menu", () => { + const { wrapper } = setupWrapper({ + isEditMode: false, + }); + + const videoConferenceElementMenu = wrapper.findComponent(BoardMenu); + + expect(videoConferenceElementMenu.exists()).toBe(false); + }); + }); + + describe("and content title is defined", () => { + it("should render display of video conference content with correct props", () => { + const videoConferenceElementContent = + videoConferenceElementContentFactory.build({ title: "test-title" }); + const { wrapper, element } = setupWrapper({ + content: videoConferenceElementContent, + isEditMode: false, + }); + + const videoConferenceElementDisplay = wrapper.findComponent( + VideoConferenceContentElementDisplay + ); + + expect(videoConferenceElementDisplay.props().title).toEqual( + element.content.title + ); + expect(videoConferenceElementDisplay.props().isEditMode).toEqual(false); + expect(videoConferenceElementDisplay.props().canStart).toEqual(true); + expect( + videoConferenceElementDisplay.props().hasParticipationPermission + ).toEqual(true); + expect(videoConferenceElementDisplay.props().isRunning).toEqual(false); + }); + + it("should have the correct aria-label", () => { + const videoConferenceElementContent = + videoConferenceElementContentFactory.build(); + const { wrapper } = setupWrapper({ + content: videoConferenceElementContent, + isEditMode: false, + }); + + const videoConferenceElement = wrapper.findComponent( + '[data-testid="video-conference-element"]' + ); + + expect(videoConferenceElement.attributes("aria-label")).toEqual( + "components.cardElement.videoConferenceElement, common.ariaLabel.newTab" + ); + }); + + describe("and element is in edit mode", () => { + it.each(["up", "down"])( + "should 'emit move-keyboard:edit' when arrow key %s is pressed", + async (key) => { + const videoConferenceElementContent = + videoConferenceElementContentFactory.build(); + const { wrapper } = setupWrapper({ + content: videoConferenceElementContent, + isEditMode: true, + }); + + const videoConferenceElement = wrapper.findComponent( + '[data-testid="video-conference-element"]' + ); + + await videoConferenceElement.trigger(`keydown.${key}`); + + expect(wrapper.emitted()).toHaveProperty("move-keyboard:edit"); + } + ); + }); + + describe("and element is in view mode", () => { + it.each(["up", "down"])( + "should not 'emit move-keyboard:edit' when arrow key %s is pressed", + async (key) => { + const videoConferenceElementContent = + videoConferenceElementContentFactory.build(); + const { wrapper } = setupWrapper({ + content: videoConferenceElementContent, + isEditMode: false, + }); + + const videoConferenceElement = wrapper.findComponent( + '[data-testid="video-conference-element"]' + ); + + await videoConferenceElement.trigger(`keydown.${key}`); + + expect(wrapper.emitted()).not.toHaveProperty("move-keyboard:edit"); + } + ); + }); + + describe("video conference element menu", () => { + it("should render video conference element menu", () => { + const videoConferenceElementContent = + videoConferenceElementContentFactory.build(); + const { wrapper } = setupWrapper({ + content: videoConferenceElementContent, + isEditMode: true, + }); + + const videoConferenceElementMenu = wrapper.findComponent(BoardMenu); + + expect(videoConferenceElementMenu.exists()).toBe(true); + }); + + it("should emit 'move-down:edit' event when move down menu item is clicked", async () => { + const videoConferenceElementContent = + videoConferenceElementContentFactory.build(); + const { wrapper } = setupWrapper({ + content: videoConferenceElementContent, + isEditMode: true, + }); + + const menuItem = wrapper.findComponent(BoardMenuActionMoveDown); + await menuItem.trigger("click"); + + expect(wrapper.emitted()).toHaveProperty("move-down:edit"); + }); + + it("should emit 'move-up:edit' event when move up menu item is clicked", async () => { + const videoConferenceElementContent = + videoConferenceElementContentFactory.build(); + const { wrapper } = setupWrapper({ + content: videoConferenceElementContent, + isEditMode: true, + }); + + const menuItem = wrapper.findComponent(BoardMenuActionMoveUp); + await menuItem.trigger("click"); + + expect(wrapper.emitted()).toHaveProperty("move-up:edit"); + }); + + it("should emit 'delete:element' event when delete menu item is clicked", async () => { + const videoConferenceElementContent = + videoConferenceElementContentFactory.build(); + const { wrapper } = setupWrapper({ + content: videoConferenceElementContent, + isEditMode: true, + }); + + const menuItem = wrapper.findComponent(BoardMenuActionDelete); + await menuItem.trigger("click"); + + expect(wrapper.emitted()).toHaveProperty("delete:element"); + }); + }); + + describe("onElementClick", () => { + describe("and video conference is not running", () => { + it("should open the configuration dialog", async () => { + const { wrapper } = setupWrapper({ + content: videoConferenceElementContentFactory.build(), + isEditMode: false, + }); + + const videoConferenceElementDisplay = wrapper.findComponent( + VideoConferenceContentElementDisplay + ); + await videoConferenceElementDisplay.vm.$emit("click"); + + const configurationDialog = wrapper.findComponent( + '[data-testid="videoconference-config-dialog"]' + ); + + expect(configurationDialog.props("modelValue")).toBe(true); + }); + }); + + describe("and video conference is running", () => { + it("should call joinVideoConference", async () => { + const { element, videoConferenceModule, wrapper } = setupWrapper({ + content: videoConferenceElementContentFactory.build(), + isEditMode: false, + videoConferenceModuleGetter: { + getVideoConferenceInfo: { + state: VideoConferenceState.RUNNING, + options: { + everyAttendeeJoinsMuted: false, + moderatorMustApproveJoinRequests: false, + everybodyJoinsAsModerator: false, + }, + }, + getLoading: true, + }, + }); + + const videoConferenceElementDisplay = wrapper.findComponent( + VideoConferenceContentElementDisplay + ); + await videoConferenceElementDisplay.vm.$emit("click"); + + expect( + videoConferenceModule.joinVideoConference + ).toHaveBeenCalledWith({ + scope: VideoConferenceScope.VideoConferenceElement, + scopeId: element.id, + }); + }); + }); + }); + + describe("onRefresh", () => { + it("should call fetchVideoConferenceInfo", async () => { + const { element, videoConferenceModule, wrapper } = setupWrapper({ + content: videoConferenceElementContentFactory.build(), + isEditMode: false, + }); + + const videoConferenceElementDisplay = wrapper.findComponent( + VideoConferenceContentElementDisplay + ); + await videoConferenceElementDisplay.vm.$emit("refresh"); + + expect( + videoConferenceModule.fetchVideoConferenceInfo + ).toHaveBeenCalledWith({ + scope: VideoConferenceScope.VideoConferenceElement, + scopeId: element.id, + }); + }); + }); + }); + }); + + describe("when video conference element is being created", () => { + describe("and no title was entered", () => { + it("should hide video conference element in view mode", () => { + const { wrapper } = setupWrapper({ + isEditMode: false, + }); + + const videoConferenceElement = wrapper.findComponent( + '[data-testid="video-conference-element"]' + ); + + expect(videoConferenceElement.attributes("class")).toContain("d-none"); + }); + + it("should not render video conference element menu in view mode", () => { + const { wrapper } = setupWrapper({ + isEditMode: false, + }); + + const videoConferenceElementMenu = wrapper.findComponent(BoardMenu); + + expect(videoConferenceElementMenu.exists()).toBe(false); + }); + }); + + describe("and element is in edit mode", () => { + it("should render VideoConferenceContentElementCreate component", () => { + const { wrapper } = setupWrapper({ isEditMode: true }); + + const videoConferenceCreateComponent = wrapper.findComponent( + VideoConferenceContentElementCreate + ); + + expect(videoConferenceCreateComponent.exists()).toBe(true); + }); + + it.each(["up", "down"])( + "should not 'emit move-keyboard:edit' when arrow key %s is pressed", + async (key) => { + const { wrapper } = setupWrapper({ + isEditMode: true, + }); + + const videoConferenceElement = wrapper.findComponent( + '[data-testid="video-conference-element"]' + ); + + await videoConferenceElement.trigger(`keydown.${key}`); + + expect(wrapper.emitted()).not.toHaveProperty("move-keyboard:edit"); + } + ); + + describe("video conference element menu", () => { + it("should render video conference element menu", () => { + const { wrapper } = setupWrapper({ + isEditMode: true, + }); + + const videoConferenceElementMenu = wrapper.findComponent(BoardMenu); + + expect(videoConferenceElementMenu.exists()).toBe(true); + }); + + it("should emit 'move-down:edit' event when move down menu item is clicked", async () => { + const { wrapper } = setupWrapper({ + isEditMode: true, + }); + + const menuItem = wrapper.findComponent(BoardMenuActionMoveDown); + await menuItem.trigger("click"); + + expect(wrapper.emitted()).toHaveProperty("move-down:edit"); + }); + + it("should emit 'move-up:edit' event when move up menu item is clicked", async () => { + const { wrapper } = setupWrapper({ + isEditMode: true, + }); + + const menuItem = wrapper.findComponent(BoardMenuActionMoveUp); + await menuItem.trigger("click"); + + expect(wrapper.emitted()).toHaveProperty("move-up:edit"); + }); + + it("should emit 'delete:element' event when delete menu item is clicked", async () => { + const { wrapper } = setupWrapper({ + isEditMode: true, + }); + + const menuItem = wrapper.findComponent(BoardMenuActionDelete); + await menuItem.trigger("click"); + + expect(wrapper.emitted()).toHaveProperty("delete:element"); + }); + }); + }); + + describe("onCreateTitle", () => { + describe("and title was provided", () => { + it("should display the title ", async () => { + const videoConferenceTitle = "Very specific vc title"; + const { wrapper } = setupWrapper({ + content: videoConferenceElementContentFactory.build({ + title: videoConferenceTitle, + }), + isEditMode: false, + }); + + expect(wrapper.html()).toEqual( + expect.stringContaining(videoConferenceTitle) + ); + }); + }); + }); + }); + describe("when a videoconference is started or joined", () => { + describe("and an error occurs", () => { + it("should display an error dialog", async () => { + const error = jest.fn(() => { + throw new Error(); + }); + const { wrapper } = setupWrapper({ + content: videoConferenceElementContentFactory.build(), + isEditMode: false, + videoConferenceModuleGetter: { + getError: error, + }, + }); + + const videoConferenceElement = wrapper.findComponent( + '[data-testid="video-conference-element"]' + ); + await videoConferenceElement.trigger("click"); + + const dialog = wrapper.findComponent({ + ref: "vDialog", + }); + + expect(dialog.props("modelValue")).toBe(true); + }); + }); + + describe("and no error occurs", () => { + it("should not display an error dialog", async () => { + const { wrapper } = setupWrapper({ + content: videoConferenceElementContentFactory.build(), + isEditMode: false, + videoConferenceModuleGetter: { + getError: null, + }, + }); + + const videoConferenceElement = wrapper.findComponent( + '[data-testid="video-conference-element"]' + ); + await videoConferenceElement.trigger("click"); + + const dialog = wrapper.findComponent({ + ref: "vDialog", + }); + + expect(dialog.props("modelValue")).toBe(false); + }); + }); + }); +}); diff --git a/src/modules/feature/board-video-conference-element/components/VideoConferenceContentElement.vue b/src/modules/feature/board-video-conference-element/components/VideoConferenceContentElement.vue new file mode 100644 index 0000000000..dcb52f0873 --- /dev/null +++ b/src/modules/feature/board-video-conference-element/components/VideoConferenceContentElement.vue @@ -0,0 +1,377 @@ + + + diff --git a/src/modules/feature/board-video-conference-element/components/VideoConferenceContentElementCreate.unit.ts b/src/modules/feature/board-video-conference-element/components/VideoConferenceContentElementCreate.unit.ts new file mode 100644 index 0000000000..2dab55985b --- /dev/null +++ b/src/modules/feature/board-video-conference-element/components/VideoConferenceContentElementCreate.unit.ts @@ -0,0 +1,72 @@ +import { + createTestingI18n, + createTestingVuetify, +} from "@@/tests/test-utils/setup"; +import { mount } from "@vue/test-utils"; +import VideoConferenceContentElementCreate from "./VideoConferenceContentElementCreate.vue"; + +const setupWrapper = () => { + const wrapper = mount(VideoConferenceContentElementCreate, { + global: { + plugins: [createTestingVuetify(), createTestingI18n()], + }, + }); + + return wrapper; +}; + +const title = "video conference title"; + +describe("VideoConferenceContentElementCreate", () => { + afterEach(() => { + jest.resetAllMocks(); + }); + + describe("when a valid title is entered", () => { + describe("and enter is pressed", () => { + it("should not show error-message", async () => { + const wrapper = setupWrapper(); + + await wrapper.findComponent({ name: "VTextarea" }).setValue(title); + await wrapper.find("form").trigger("submit.prevent"); + + const alerts = wrapper.find('[role="alert"]'); + expect(alerts.text()).toBe(""); + }); + + it("should emit create:title event", async () => { + const wrapper = setupWrapper(); + + await wrapper.findComponent({ name: "VTextarea" }).setValue(title); + await wrapper + .findComponent({ name: "VTextarea" }) + .trigger("keydown.enter"); + + expect(wrapper.emitted("create:title")).toEqual([[title]]); + }); + }); + }); + + describe("when the title field is empty", () => { + describe("and the submit button is clicked", () => { + it("should show required-error-message", async () => { + const wrapper = setupWrapper(); + + await wrapper.findComponent({ name: "VTextarea" }).setValue(""); + await wrapper.find("form").trigger("submit.prevent"); + + const alerts = wrapper.find('[role="alert"]').text(); + expect(alerts).toEqual("common.validation.required2"); + }); + + it("should not emit create:title event", async () => { + const wrapper = setupWrapper(); + + await wrapper.findComponent({ name: "VTextarea" }).setValue(""); + await wrapper.find("form").trigger("submit.prevent"); + + expect(wrapper.emitted("create:title")).toBeUndefined(); + }); + }); + }); +}); diff --git a/src/modules/feature/board-video-conference-element/components/VideoConferenceContentElementCreate.vue b/src/modules/feature/board-video-conference-element/components/VideoConferenceContentElementCreate.vue new file mode 100644 index 0000000000..21a0800882 --- /dev/null +++ b/src/modules/feature/board-video-conference-element/components/VideoConferenceContentElementCreate.vue @@ -0,0 +1,85 @@ + + + + + diff --git a/src/modules/feature/board-video-conference-element/components/VideoConferenceContentElementDisplay.unit.ts b/src/modules/feature/board-video-conference-element/components/VideoConferenceContentElementDisplay.unit.ts new file mode 100644 index 0000000000..291efc4b1e --- /dev/null +++ b/src/modules/feature/board-video-conference-element/components/VideoConferenceContentElementDisplay.unit.ts @@ -0,0 +1,248 @@ +import { mount, VueWrapper } from "@vue/test-utils"; +import { + createTestingI18n, + createTestingVuetify, +} from "@@/tests/test-utils/setup"; +import VideoConferenceContentElementDisplay from "./VideoConferenceContentElementDisplay.vue"; +import { BOARD_IS_LIST_LAYOUT } from "@util-board"; +import { ENV_CONFIG_MODULE_KEY } from "@/utils/inject"; +import { createModuleMocks } from "@@/tests/test-utils/mock-store-module"; +import { envConfigModule } from "@/store"; +import EnvConfigModule from "@/store/env-config"; +import { createMock } from "@golevelup/ts-jest"; +import { ConfigResponse } from "@/serverApi/v3"; +import setupStores from "@@/tests/test-utils/setupStores"; +import { envsFactory } from "@@/tests/test-utils"; + +const mockedEnvConfigModule = createModuleMocks(EnvConfigModule, { + getEnv: createMock({ + FEATURE_COLUMN_BOARD_VIDEOCONFERENCE_ENABLED: true, + }), +}); + +const setupWrapper = ({ + propsData = {}, + envOverrides = {}, +}: { + propsData?: object; + envOverrides?: Partial; +} = {}) => { + const envs = envsFactory.build({ + FEATURE_COLUMN_BOARD_VIDEOCONFERENCE_ENABLED: true, + ...envOverrides, + }); + envConfigModule.setEnvs(envs); + + const wrapper = mount(VideoConferenceContentElementDisplay, { + global: { + plugins: [createTestingVuetify(), createTestingI18n()], + provide: { + [BOARD_IS_LIST_LAYOUT as symbol]: false, + [ENV_CONFIG_MODULE_KEY.valueOf()]: mockedEnvConfigModule, + }, + }, + props: { + isEditMode: false, + isRunning: false, + hasParticipationPermission: false, + canStart: false, + title: "", + ...propsData, + }, + }); + + return wrapper; +}; + +describe("VideoConferenceContentElementDisplay", () => { + beforeEach(() => { + setupStores({ envConfigModule: EnvConfigModule }); + }); + + afterEach(() => { + jest.resetAllMocks(); + }); + + describe("Title", () => { + it("should display a title", () => { + const title = "video conference"; + const wrapper = setupWrapper({ + propsData: { + isEditMode: false, + isRunning: false, + hasParticipationPermission: true, + canStart: true, + title, + }, + }); + + const titleElement = wrapper.find( + '[data-testid="content-element-title-slot"]' + ); + expect(titleElement.text()).toEqual(title); + }); + }); + + describe("Alerts", () => { + describe("and the feature is disabled", () => { + it("should show 'not enabled for teacher' alert", () => { + const wrapper = setupWrapper({ + propsData: { + isRunning: false, + hasParticipationPermission: true, + canStart: true, + title: "video conference", + }, + envOverrides: { + FEATURE_COLUMN_BOARD_VIDEOCONFERENCE_ENABLED: false, + }, + }); + + const alert = wrapper.findComponent( + '[data-testid="vc-info-box-no-feature"]' + ); + const text = alert.find("span.my-auto"); + expect(text.text()).toEqual( + "pages.videoConference.info.notEnabledTeacher" + ); + }); + }); + + describe("and video conference is not running", () => { + it("should show 'not started' alert for a participant", () => { + const wrapper = setupWrapper({ + propsData: { + isRunning: false, + hasParticipationPermission: true, + canStart: false, + title: "video conference", + }, + }); + + const alert = wrapper.findComponent('[data-testid="vc-info-box"]'); + const text = alert.find("span.my-auto"); + expect(text.text()).toEqual("pages.videoConference.info.notStarted"); + }); + + it("should show 'no permission' alert for a user with no participation permission", () => { + const wrapper = setupWrapper({ + propsData: { + isRunning: false, + hasParticipationPermission: false, + canStart: false, + title: "video conference", + }, + }); + + const alert = wrapper.findComponent('[data-testid="vc-info-box"]'); + const text = alert.find("span.my-auto"); + expect(text.text()).toEqual("pages.videoConference.info.noPermission"); + }); + + it("should not display any alert when the user is a teacher", () => { + const wrapper = setupWrapper({ + propsData: { + isRunning: false, + hasParticipationPermission: true, + canStart: true, + title: "video conference", + }, + }); + + const alert = wrapper.find('[data-testid="vc-info-box-show"]'); + expect(alert.exists()).toEqual(false); + }); + }); + + describe("and video conference is running", () => { + it("should show 'no permission' alert when user has no participation permission", () => { + const wrapper = setupWrapper({ + propsData: { + isRunning: true, + hasParticipationPermission: false, + canStart: false, + title: "video conference", + }, + }); + + const alert = wrapper.findComponent( + '[data-testid="vc-info-box-no-permission"]' + ); + const text = alert.find("span.my-auto"); + expect(text.text()).toEqual("pages.videoConference.info.noPermission"); + }); + + it("should display a pulsating dot when the user has permission", () => { + const wrapper = setupWrapper({ + propsData: { + isRunning: true, + hasParticipationPermission: true, + canStart: true, + title: "video conference", + }, + }); + + const dot = wrapper.find('[data-testid="vc-pulsating-dot"]'); + expect(dot.exists()).toEqual(true); + }); + }); + }); + + describe("Events", () => { + const triggerClick = async (wrapper: VueWrapper) => { + const element = wrapper.find( + '[data-testid="board-video-conference-element"]' + ); + await element.trigger("click"); + }; + + describe("and video conference is running", () => { + it("should emit a click event when the user can join and the conference is running", async () => { + const wrapper = setupWrapper({ + propsData: { + isEditMode: false, + isRunning: true, + hasParticipationPermission: true, + canStart: false, + title: "video conference", + }, + }); + + await triggerClick(wrapper); + expect(wrapper.emitted("click")).toBeDefined(); + }); + }); + + describe("and video conference is not running", () => { + it("should emit a refresh event when the user can join", async () => { + const wrapper = setupWrapper({ + propsData: { + isEditMode: false, + isRunning: false, + hasParticipationPermission: true, + canStart: false, + title: "video conference", + }, + }); + + await triggerClick(wrapper); + expect(wrapper.emitted("refresh")).toBeDefined(); + }); + + it("should emit a click event when the user is a teacher", async () => { + const wrapper = setupWrapper({ + propsData: { + isEditMode: false, + isRunning: false, + hasParticipationPermission: true, + canStart: true, + title: "video conference", + }, + }); + + await triggerClick(wrapper); + expect(wrapper.emitted("click")).toBeDefined(); + }); + }); + }); +}); diff --git a/src/modules/feature/board-video-conference-element/components/VideoConferenceContentElementDisplay.vue b/src/modules/feature/board-video-conference-element/components/VideoConferenceContentElementDisplay.vue new file mode 100644 index 0000000000..e863102603 --- /dev/null +++ b/src/modules/feature/board-video-conference-element/components/VideoConferenceContentElementDisplay.vue @@ -0,0 +1,213 @@ + + + + + diff --git a/src/modules/feature/board-video-conference-element/index.ts b/src/modules/feature/board-video-conference-element/index.ts new file mode 100644 index 0000000000..678738239b --- /dev/null +++ b/src/modules/feature/board-video-conference-element/index.ts @@ -0,0 +1,3 @@ +import VideoConferenceContentElement from "./components/VideoConferenceContentElement.vue"; + +export { VideoConferenceContentElement }; diff --git a/src/modules/feature/board/card/ContentElementList.unit.ts b/src/modules/feature/board/card/ContentElementList.unit.ts index c616528806..ba50b62e47 100644 --- a/src/modules/feature/board/card/ContentElementList.unit.ts +++ b/src/modules/feature/board/card/ContentElementList.unit.ts @@ -19,6 +19,7 @@ import { RichTextContentElement } from "@feature-board-text-element"; import { createMock } from "@golevelup/ts-jest"; import { shallowMount } from "@vue/test-utils"; import ContentElementList from "./ContentElementList.vue"; +import { VideoConferenceContentElement } from "@feature-board-video-conference-element"; describe("ContentElementList", () => { const setup = (props: { @@ -44,7 +45,7 @@ describe("ContentElementList", () => { [ENV_CONFIG_MODULE_KEY.valueOf()]: mockedEnvConfigModule, }, }, - props: { ...props }, + props: { ...props, rowIndex: 0, columnIndex: 0 }, }); return { wrapper }; @@ -93,6 +94,10 @@ describe("ContentElementList", () => { elementType: ContentElementType.Deleted, component: DeletedElement, }, + { + elementType: ContentElementType.VideoConference, + component: VideoConferenceContentElement, + }, ]; it.each(elementComponents)( diff --git a/src/modules/feature/board/card/ContentElementList.vue b/src/modules/feature/board/card/ContentElementList.vue index 940a4dc4ff..abbb4ab1ad 100644 --- a/src/modules/feature/board/card/ContentElementList.vue +++ b/src/modules/feature/board/card/ContentElementList.vue @@ -89,6 +89,19 @@ @move-up:edit="onMoveElementUp(index, element)" @delete:element="onDeleteElement" /> + { + return element.type === ContentElementType.VideoConference; +}; + +const showVideoConferenceElement = ( + element: AnyContentElement +): element is VideoConferenceElementResponse => { + return ( + envConfigModule.getEnv.FEATURE_COLUMN_BOARD_VIDEOCONFERENCE_ENABLED && + isVideoConferenceElementResponse(element) + ); +}; + const onMoveElementDown = ( elementIndex: number, element: AnyContentElement diff --git a/src/modules/feature/board/shared/AddElementDialog.composable.ts b/src/modules/feature/board/shared/AddElementDialog.composable.ts index 7fc101c201..41b07a8a7d 100644 --- a/src/modules/feature/board/shared/AddElementDialog.composable.ts +++ b/src/modules/feature/board/shared/AddElementDialog.composable.ts @@ -10,6 +10,7 @@ import { mdiPuzzleOutline, mdiTextBoxEditOutline, mdiTrayArrowUp, + mdiVideo, } from "@icons/material"; import { useBoardNotifier } from "@util-board"; import { useI18n } from "vue-i18n"; @@ -158,6 +159,17 @@ export const useAddElementDialog = ( }); } + if (envConfigModule.getEnv.FEATURE_COLUMN_BOARD_VIDEOCONFERENCE_ENABLED) { + options.push({ + icon: mdiVideo, + label: t( + "components.elementTypeSelection.elements.videoConferenceElement.subtitle" + ), + action: () => onElementClick(ContentElementType.VideoConference), + testId: "create-element-video-conference", + }); + } + const askType = () => { elementTypeOptions.value = options; isDialogOpen.value = true; diff --git a/src/modules/feature/board/shared/AddElementDialog.composable.unit.ts b/src/modules/feature/board/shared/AddElementDialog.composable.unit.ts index c98f39be04..8531c6486f 100644 --- a/src/modules/feature/board/shared/AddElementDialog.composable.unit.ts +++ b/src/modules/feature/board/shared/AddElementDialog.composable.unit.ts @@ -103,7 +103,10 @@ describe("ElementTypeSelection Composable", () => { await onElementClick(elementType); expect(addElementMock).toHaveBeenCalledTimes(1); - expect(addElementMock).toBeCalledWith({ type: elementType, cardId }); + expect(addElementMock).toHaveBeenCalledWith({ + type: elementType, + cardId, + }); expect(isDialogOpen.value).toBe(false); }); @@ -210,7 +213,7 @@ describe("ElementTypeSelection Composable", () => { await onElementClick(elementType); - expect(showCustomNotifierMock).toBeCalledTimes(0); + expect(showCustomNotifierMock).toHaveBeenCalledTimes(0); }); }); @@ -288,6 +291,7 @@ describe("ElementTypeSelection Composable", () => { FEATURE_TLDRAW_ENABLED: true, FEATURE_COLUMN_BOARD_COLLABORATIVE_TEXT_EDITOR_ENABLED: true, FEATURE_PREFERRED_CTL_TOOLS_ENABLED: true, + FEATURE_COLUMN_BOARD_VIDEOCONFERENCE_ENABLED: true, } ) => { const cardId = "cardId"; @@ -335,8 +339,8 @@ describe("ElementTypeSelection Composable", () => { const action = elementTypeOptions.value[0].action; action(); - expect(addElementMock).toBeCalledTimes(1); - expect(addElementMock).toBeCalledWith({ + expect(addElementMock).toHaveBeenCalledTimes(1); + expect(addElementMock).toHaveBeenCalledWith({ type: ContentElementType.RichText, cardId, }); @@ -351,7 +355,7 @@ describe("ElementTypeSelection Composable", () => { const action = elementTypeOptions.value[0].action; action(); - expect(closeDialogMock).toBeCalledTimes(1); + expect(closeDialogMock).toHaveBeenCalledTimes(1); }); }); @@ -365,8 +369,8 @@ describe("ElementTypeSelection Composable", () => { const action = elementTypeOptions.value[1].action; action(); - expect(addElementMock).toBeCalledTimes(1); - expect(addElementMock).toBeCalledWith({ + expect(addElementMock).toHaveBeenCalledTimes(1); + expect(addElementMock).toHaveBeenCalledWith({ type: ContentElementType.File, cardId, }); @@ -382,7 +386,7 @@ describe("ElementTypeSelection Composable", () => { const action = elementTypeOptions.value[1].action; action(); - expect(closeDialogMock).toBeCalledTimes(1); + expect(closeDialogMock).toHaveBeenCalledTimes(1); }); }); @@ -395,8 +399,8 @@ describe("ElementTypeSelection Composable", () => { const action = elementTypeOptions.value[2].action; action(); - expect(addElementMock).toBeCalledTimes(1); - expect(addElementMock).toBeCalledWith({ + expect(addElementMock).toHaveBeenCalledTimes(1); + expect(addElementMock).toHaveBeenCalledWith({ type: ContentElementType.SubmissionContainer, cardId, }); @@ -411,7 +415,7 @@ describe("ElementTypeSelection Composable", () => { const action = elementTypeOptions.value[2].action; action(); - expect(closeDialogMock).toBeCalledTimes(1); + expect(closeDialogMock).toHaveBeenCalledTimes(1); }); }); @@ -425,8 +429,8 @@ describe("ElementTypeSelection Composable", () => { const action = elementTypeOptions.value[3].action; action(); - expect(addElementMock).toBeCalledTimes(1); - expect(addElementMock).toBeCalledWith({ + expect(addElementMock).toHaveBeenCalledTimes(1); + expect(addElementMock).toHaveBeenCalledWith({ type: ContentElementType.ExternalTool, cardId, }); @@ -442,7 +446,7 @@ describe("ElementTypeSelection Composable", () => { const action = elementTypeOptions.value[3].action; action(); - expect(closeDialogMock).toBeCalledTimes(1); + expect(closeDialogMock).toHaveBeenCalledTimes(1); }); }); describe("when the DrawingElement action is called", () => { @@ -455,8 +459,8 @@ describe("ElementTypeSelection Composable", () => { const action = elementTypeOptions.value[4].action; action(); - expect(addElementMock).toBeCalledTimes(1); - expect(addElementMock).toBeCalledWith({ + expect(addElementMock).toHaveBeenCalledTimes(1); + expect(addElementMock).toHaveBeenCalledWith({ type: ContentElementType.Drawing, cardId, }); @@ -472,7 +476,7 @@ describe("ElementTypeSelection Composable", () => { const action = elementTypeOptions.value[4].action; action(); - expect(closeDialogMock).toBeCalledTimes(1); + expect(closeDialogMock).toHaveBeenCalledTimes(1); }); }); describe("when the CollaborativeTextEditorElement action is called", () => { @@ -485,8 +489,8 @@ describe("ElementTypeSelection Composable", () => { const action = elementTypeOptions.value[5].action; action(); - expect(addElementMock).toBeCalledTimes(1); - expect(addElementMock).toBeCalledWith({ + expect(addElementMock).toHaveBeenCalledTimes(1); + expect(addElementMock).toHaveBeenCalledWith({ type: ContentElementType.CollaborativeTextEditor, cardId, }); @@ -502,7 +506,38 @@ describe("ElementTypeSelection Composable", () => { const action = elementTypeOptions.value[5].action; action(); - expect(closeDialogMock).toBeCalledTimes(1); + expect(closeDialogMock).toHaveBeenCalledTimes(1); + }); + }); + describe("when the VideoConference action is called", () => { + it("should call video conference element function with right argument", async () => { + const { elementTypeOptions, addElementMock, cardId } = setup(); + const { askType } = useAddElementDialog(addElementMock, cardId); + + askType(); + + console.log(elementTypeOptions.value); + const action = elementTypeOptions.value[7].action; + action(); + + expect(addElementMock).toHaveBeenCalledTimes(1); + expect(addElementMock).toHaveBeenCalledWith({ + type: ContentElementType.VideoConference, + cardId, + }); + }); + + it("should set isDialogOpen to false", async () => { + const { elementTypeOptions, addElementMock, closeDialogMock, cardId } = + setup(); + const { askType } = useAddElementDialog(addElementMock, cardId); + + askType(); + + const action = elementTypeOptions.value[7].action; + action(); + + expect(closeDialogMock).toHaveBeenCalledTimes(1); }); }); }); diff --git a/src/modules/feature/board/shared/AddElementDialog.vue b/src/modules/feature/board/shared/AddElementDialog.vue index 186d9eb050..c42dc76d72 100644 --- a/src/modules/feature/board/shared/AddElementDialog.vue +++ b/src/modules/feature/board/shared/AddElementDialog.vue @@ -6,11 +6,9 @@ > - {{ $t("components.elementTypeSelection.dialog.title") }} + {{ t("components.elementTypeSelection.dialog.title") }} - + - {{ $t("common.labels.close") }} + {{ t("common.labels.close") }} @@ -37,10 +35,13 @@ import { ExtendedIconBtn } from "@ui-extended-icon-btn"; import { computed, ComputedRef } from "vue"; import { useSharedElementTypeSelection } from "./SharedElementTypeSelection.composable"; +import { useI18n } from "vue-i18n"; const { isDialogOpen, closeDialog, elementTypeOptions } = useSharedElementTypeSelection(); +const { t } = useI18n(); + const dialogWidth: ComputedRef = computed(() => elementTypeOptions.value.length >= 3 ? 426 : 320 ); diff --git a/src/modules/feature/room/RoomMembers/MembersTable.unit.ts b/src/modules/feature/room/RoomMembers/MembersTable.unit.ts index a0da092c36..e087fe58a5 100644 --- a/src/modules/feature/room/RoomMembers/MembersTable.unit.ts +++ b/src/modules/feature/room/RoomMembers/MembersTable.unit.ts @@ -3,13 +3,14 @@ import { createTestingVuetify, } from "@@/tests/test-utils/setup"; import MembersTable from "./MembersTable.vue"; -import { ref } from "vue"; +import { nextTick, ref } from "vue"; import { mdiMenuDown, mdiMenuUp, mdiMagnify } from "@icons/material"; -import { roomMemberResponseFactory } from "@@/tests/test-utils"; +import { roomMemberFactory } from "@@/tests/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"; +import { RoleName } from "@/serverApi/v3"; jest.mock("@ui-confirmation-dialog"); const mockedUseRemoveConfirmationDialog = jest.mocked(useConfirmationDialog); @@ -37,7 +38,7 @@ describe("MembersTable", () => { ]; const setup = () => { - const mockMembers = roomMemberResponseFactory.buildList(3); + const mockMembers = roomMemberFactory(RoleName.Roomeditor).buildList(3); const wrapper = mount(MembersTable, { attachTo: document.body, global: { @@ -344,6 +345,41 @@ describe("MembersTable", () => { expect(wrapper.emitted()).not.toHaveProperty("remove:members"); }); + + describe("when members are 'roomowner'", () => { + const ownerMembers = roomMemberFactory(RoleName.Roomowner) + .buildList(3) + .map((member) => ({ ...member, isSelectable: false })); + + it("should not render remove button for room owner", async () => { + const { wrapper } = setup(); + + wrapper.setProps({ members: ownerMembers }); + await nextTick(); + + const dataTable = wrapper.getComponent(VDataTable); + const removeButton = dataTable.findComponent( + "[data-testid=remove-member-0]" + ); + + expect(removeButton.exists()).toBe(false); + }); + + it("members should not be selectable", async () => { + const { wrapper } = setup(); + + wrapper.setProps({ members: ownerMembers }); + await nextTick(); + + const dataTable = wrapper.getComponent(VDataTable); + + const checkboxes = dataTable.findAllComponents({ + name: "VSelectionControl", + }); + + expect(checkboxes[1].vm.disabled).toBe(true); + }); + }); }); }); diff --git a/src/modules/feature/room/RoomMembers/MembersTable.vue b/src/modules/feature/room/RoomMembers/MembersTable.vue index d8ba934427..8a2ba88e60 100644 --- a/src/modules/feature/room/RoomMembers/MembersTable.vue +++ b/src/modules/feature/room/RoomMembers/MembersTable.vue @@ -56,6 +56,7 @@ item-value="userId" mobile-breakpoint="sm" :items="memberList" + item-selectable="isSelectable" :headers="tableHeader" :items-per-page-options="[5, 10, 25, 50, 100]" :items-per-page="50" @@ -69,6 +70,7 @@