diff --git a/package-lock.json b/package-lock.json index b9d080c85e..475ad48429 100644 --- a/package-lock.json +++ b/package-lock.json @@ -32,7 +32,7 @@ "vue-router": "^4.2.4", "vue3-mq": "^3.1.3", "vuedraggable": "^4.1.0", - "vuetify": "^3.7.1", + "vuetify": "^3.7.5", "vuex": "^4.0.2" }, "devDependencies": { @@ -16737,9 +16737,9 @@ "license": "MIT" }, "node_modules/vuetify": { - "version": "3.7.2", - "resolved": "https://registry.npmjs.org/vuetify/-/vuetify-3.7.2.tgz", - "integrity": "sha512-q0WTcRG977+a9Dqhb8TOaPm+Xmvj0oVhnBJhAdHWFSov3HhHTTxlH2nXP/GBTXZuuMHDbBeIWFuUR2/1Fx0PPw==", + "version": "3.7.5", + "resolved": "https://registry.npmjs.org/vuetify/-/vuetify-3.7.5.tgz", + "integrity": "sha512-5aiSz8WJyGzYe3yfgDbzxsFATwHvKtdvFAaUJEDTx7xRv55s3YiOho/MFhs5iTbmh2VT4ToRgP0imBUP660UOw==", "license": "MIT", "engines": { "node": "^12.20 || >=14.13" diff --git a/package.json b/package.json index ed84f3b7dd..a2123ee00d 100644 --- a/package.json +++ b/package.json @@ -42,7 +42,7 @@ "vue-router": "^4.2.4", "vue3-mq": "^3.1.3", "vuedraggable": "^4.1.0", - "vuetify": "^3.7.1", + "vuetify": "^3.7.5", "vuex": "^4.0.2" }, "devDependencies": { diff --git a/src/components/copy-result-modal/CopyResultModal.unit.ts b/src/components/copy-result-modal/CopyResultModal.unit.ts index b2e019eba3..eedd0d63f2 100644 --- a/src/components/copy-result-modal/CopyResultModal.unit.ts +++ b/src/components/copy-result-modal/CopyResultModal.unit.ts @@ -151,53 +151,77 @@ describe("@/components/copy-result-modal/CopyResultModal", () => { ); }); - it("should render ctl tools info if root item is a Course and has no failed file ", () => { - const copyResultItems = mockLessonResultItems([]); - - const envs = envsFactory.build({ - FEATURE_CTL_TOOLS_TAB_ENABLED: true, - }); - envConfigModule.setEnvs(envs); - const wrapper = createWrapper({ - isOpen: true, - copyResultItems, - copyResultRootItemType: CopyApiResponseTypeEnum.Course, - }); - - const dialog = wrapper.findComponent(vCustomDialog); - const content = dialog.findComponent(".v-card-text").text(); - - expect(content).toContain( - "components.molecules.copyResult.ctlTools.info" - ); - }); - - describe("when root item is a Course, has no failed file and CTL_TOOLS_COPY feature flag is enabled", () => { - const setup = () => { - const copyResultItems = mockLessonResultItems([]); - const wrapper = createWrapper({ - isOpen: true, - copyResultItems, - copyResultRootItemType: CopyApiResponseTypeEnum.Course, + describe("when there is no failed file and CTL_TOOLS_COPY & CTL_TOOLS_TAB_ENABLED feature flag is enabled", () => { + describe("when the item has element of type external tool", () => { + const setup = () => { + const envs = envsFactory.build({ + FEATURE_CTL_TOOLS_TAB_ENABLED: true, + FEATURE_CTL_TOOLS_COPY_ENABLED: true, + }); + envConfigModule.setEnvs(envs); + + const copyResultItems = mockLessonResultItems([]); + copyResultItems[0].elements.push({ + title: "Course External Tool", + type: CopyApiResponseTypeEnum.ExternalTool, + }); + copyResultItems[0].type = CopyApiResponseTypeEnum.Course; + + const wrapper = createWrapper({ + isOpen: true, + copyResultItems, + copyResultRootItemType: CopyApiResponseTypeEnum.Course, + }); + + return { wrapper }; + }; + + it("should show the warning text for non-copyable course external tools", () => { + const { wrapper } = setup(); + + const dialog = wrapper.findComponent(vCustomDialog); + const content = dialog.findComponent(".v-card-text").text(); + + expect(content).toContain( + "components.molecules.copyResult.ctlTools.withFeature.info" + ); }); + }); - return { wrapper }; - }; - - it("should render ctl tools copy info ", () => { - const envs = envsFactory.build({ - FEATURE_CTL_TOOLS_TAB_ENABLED: true, - FEATURE_CTL_TOOLS_COPY_ENABLED: true, + describe("when there is an item of type ExternalToolElement", () => { + const setup = () => { + const envs = envsFactory.build({ + FEATURE_CTL_TOOLS_TAB_ENABLED: true, + FEATURE_CTL_TOOLS_COPY_ENABLED: true, + }); + envConfigModule.setEnvs(envs); + + const copyResultItems = mockLessonResultItems([]); + copyResultItems[0].elements.push({ + title: "Board External Tool Element", + type: CopyApiResponseTypeEnum.ExternalToolElement, + }); + copyResultItems[0].type = CopyApiResponseTypeEnum.Course; + + const wrapper = createWrapper({ + isOpen: true, + copyResultItems, + copyResultRootItemType: CopyApiResponseTypeEnum.Course, + }); + + return { wrapper }; + }; + + it("should show the warning text for non-copyable course external tools", () => { + const { wrapper } = setup(); + + const dialog = wrapper.findComponent(vCustomDialog); + const content = dialog.findComponent(".v-card-text").text(); + + expect(content).toContain( + "components.molecules.copyResult.ctlTools.withFeature.info" + ); }); - envConfigModule.setEnvs(envs); - const { wrapper } = setup(); - - const dialog = wrapper.findComponent(vCustomDialog); - const content = dialog.findComponent(".v-card-text").text(); - - expect(content).toContain( - "components.molecules.copyResult.ctlTools.withFeature.info" - ); }); }); diff --git a/src/components/copy-result-modal/CopyResultModal.vue b/src/components/copy-result-modal/CopyResultModal.vue index 7082db4f53..942a81b468 100644 --- a/src/components/copy-result-modal/CopyResultModal.vue +++ b/src/components/copy-result-modal/CopyResultModal.vue @@ -128,7 +128,9 @@ export default { title: this.$t("components.molecules.copyResult.label.files"), }, { - isShow: this.hasFeatureCtlsToolsenabled, + isShow: + this.isFeatureCtlToolsEnabled && + (this.hasExternalTool || this.hasExternalToolElement), text: this.externalToolsInfoText, title: this.$t("components.molecules.copyResult.label.externalTools"), }, @@ -178,7 +180,7 @@ export default { CopyApiResponseTypeEnum.CoursegroupGroup ); }, - hasFeatureCtlsToolsenabled() { + isFeatureCtlToolsEnabled() { return envConfigModule.getCtlToolsTabEnabled; }, hasErrors() { @@ -206,6 +208,18 @@ export default { ? this.$t("components.molecules.copyResult.ctlTools.withFeature.info") : this.$t("components.molecules.copyResult.ctlTools.info"); }, + hasExternalTool() { + return this.hasElementOfType( + this.items, + CopyApiResponseTypeEnum.ExternalTool + ); + }, + hasExternalToolElement() { + return this.hasElementOfType( + this.items, + CopyApiResponseTypeEnum.ExternalToolElement + ); + }, }, methods: { hasElementOfType(items, types) { diff --git a/src/components/copy-result-modal/CopyResultModalListItem.unit.ts b/src/components/copy-result-modal/CopyResultModalListItem.unit.ts index a554d6f531..046ccd5bf1 100644 --- a/src/components/copy-result-modal/CopyResultModalListItem.unit.ts +++ b/src/components/copy-result-modal/CopyResultModalListItem.unit.ts @@ -161,6 +161,10 @@ describe("@/components/copy-result-modal/CopyResultModalListItem", () => { CopyApiResponseTypeEnum.CollaborativeTextEditorElement, "components.molecules.copyResult.label.etherpad", ], + [ + CopyApiResponseTypeEnum.ExternalToolElement, + "components.molecules.copyResult.label.toolElements", + ], ]; map.forEach(([constant, languageConstant]) => { diff --git a/src/components/copy-result-modal/CopyResultModalListItem.vue b/src/components/copy-result-modal/CopyResultModalListItem.vue index 46a05e60d2..b791e5e1f2 100644 --- a/src/components/copy-result-modal/CopyResultModalListItem.vue +++ b/src/components/copy-result-modal/CopyResultModalListItem.vue @@ -9,6 +9,7 @@ v-for="element in aggregatedElements()" :key="element.type" class="element-info" + data-testid="copy-result-list-item-element-info" > {{ element.count }} {{ element.type }} @@ -121,6 +122,8 @@ export default { return this.$t("components.molecules.copyResult.label.columnBoard"); case CopyApiResponseTypeEnum.DrawingElement: return this.$t("components.molecules.copyResult.label.tldraw"); + case CopyApiResponseTypeEnum.ExternalToolElement: + return this.$t("components.molecules.copyResult.label.toolElements"); default: return this.$t("components.molecules.copyResult.label.unknown"); } diff --git a/src/components/rooms/RoomExternalToolCard.vue b/src/components/rooms/RoomExternalToolCard.vue index b73ed359e6..ff973dabee 100644 --- a/src/components/rooms/RoomExternalToolCard.vue +++ b/src/components/rooms/RoomExternalToolCard.vue @@ -1,6 +1,7 @@ diff --git a/src/components/share/SelectCourseModal.unit.ts b/src/components/share/SelectDestinationModal.unit.ts similarity index 89% rename from src/components/share/SelectCourseModal.unit.ts rename to src/components/share/SelectDestinationModal.unit.ts index a4ffe00ad4..fb36ce9a4a 100644 --- a/src/components/share/SelectCourseModal.unit.ts +++ b/src/components/share/SelectDestinationModal.unit.ts @@ -1,11 +1,11 @@ import { mount } from "@vue/test-utils"; -import SelectCourseModal from "@/components/share/SelectCourseModal.vue"; +import SelectDestinationModal from "@/components/share/SelectDestinationModal.vue"; import { createTestingI18n, createTestingVuetify, } from "@@/tests/test-utils/setup"; -describe("@components/share/SelectCourseModal", () => { +describe("@components/share/SelectDestinationModal", () => { const course = { id: "1234", title: "Mathe", @@ -20,7 +20,7 @@ describe("@components/share/SelectCourseModal", () => { }; const setup = () => { - const wrapper = mount(SelectCourseModal, { + const wrapper = mount(SelectDestinationModal, { global: { plugins: [createTestingVuetify(), createTestingI18n()], }, diff --git a/src/components/share/SelectCourseModal.vue b/src/components/share/SelectDestinationModal.vue similarity index 50% rename from src/components/share/SelectCourseModal.vue rename to src/components/share/SelectDestinationModal.vue index f447e51327..dc0989f859 100644 --- a/src/components/share/SelectCourseModal.vue +++ b/src/components/share/SelectDestinationModal.vue @@ -21,25 +21,19 @@ {{ mdiInformation }}
- {{ - t( - `components.molecules.import.${parentType}.options.selectCourse.infoText` - ) - }} + {{ infoText }}
@@ -50,32 +44,64 @@ diff --git a/src/components/share/ShareModalResult.vue b/src/components/share/ShareModalResult.vue index 72e99c42dd..bde60a4dd6 100644 --- a/src/components/share/ShareModalResult.vue +++ b/src/components/share/ShareModalResult.vue @@ -5,6 +5,7 @@ :model-value="shareUrl" readonly :label="`${t(`components.molecules.share.${type}.result.linkLabel`)}`" + data-testid="share-course-result-url" />
import RoomTaskCard from "@/components/molecules/RoomTaskCard.vue"; -import vCustomEmptyState from "@/components/molecules/vCustomEmptyState"; +import vCustomEmptyState from "@/components/molecules/vCustomEmptyState.vue"; import vCustomDialog from "@/components/organisms/vCustomDialog.vue"; import ShareModal from "@/components/share/ShareModal.vue"; import { diff --git a/src/locales/de.ts b/src/locales/de.ts index 3f07e734de..d4b6f53794 100644 --- a/src/locales/de.ts +++ b/src/locales/de.ts @@ -325,6 +325,7 @@ export default { "components.base.showPassword": "Passwort anzeigen", "components.board.action.addCard": "Karte hinzufügen", "components.board.action.delete": "Löschen", + "components.board.action.deleteFromSection": "Aus Abschnitt entfernen", "components.board.action.detail-view": "Detailansicht", "components.board.action.download": "Herunterladen", "components.board.action.moveDown": "Nach unten verschieben", @@ -521,7 +522,7 @@ export default { "components.molecules.copyResult.ctlTools.info": "Externe Tools, die dem Kurs oder Karten im Bereich zugeordnet sind, werden nicht kopiert.", "components.molecules.copyResult.ctlTools.withFeature.info": - "Geschützte Teile der Tool-Konfigurationen werden nicht kopiert.", + "In Zielschule nicht verfügbare externe tools und geschützte Teile der Tool-Konfigurationen werden nicht kopiert.", "components.molecules.copyResult.etherpadCopy.info": "Inhalte werden aus Datenschutzgründen nicht kopiert und müssen neu hinzugefügt werden.", "components.molecules.copyResult.failedCopy": @@ -554,6 +555,7 @@ export default { "components.molecules.copyResult.label.timeGroup": "Zeitgruppe", "components.molecules.copyResult.label.unknown": "Unbekannt", "components.molecules.copyResult.label.userGroup": "Benutzergruppe", + "components.molecules.copyResult.label.toolElements": "Tool-Element", "components.molecules.copyResult.metadata": "Allgemeine Informationen", "components.molecules.copyResult.nexboardCopy.info": "Inhalte werden aus Datenschutzgründen nicht kopiert und müssen neu hinzugefügt werden.", @@ -582,6 +584,8 @@ export default { "components.molecules.EdusharingFooter.img_alt": "edusharing-logo", "components.molecules.EdusharingFooter.text": "powered by", "components.molecules.import.columnBoard.label": "Titel des Bereichs", + "components.molecules.import.columnBoard.rename": + "Bei Bedarf kann der Name des Bereiches umbenannt werden: ", "components.molecules.import.columnBoard.options.infoText": "Der Bereich kann im Folgenden umbenannt werden. ", "components.molecules.import.columnBoard.options.title": @@ -589,15 +593,16 @@ export default { "components.molecules.import.columnBoard.options.selectCourse": "Kurs wählen", "components.molecules.import.columnBoard.options.selectCourse.infoText": "Der Kurs, in den der Bereich importiert werden soll, muss im Folgenden ausgewählt werden.", - "components.molecules.import.courses.label": "Kurs", - "components.molecules.import.courses.options.ctlTools.infoText": - "Es wird eine Kopie erstellt.
Personenbezogene Daten werden nicht importiert.
Externe Tools werden nicht kopiert.
Der Kurs kann im Folgenden umbenannt werden.", - "components.molecules.import.courses.options.infoText": - "Es wird eine Kopie erstellt. Personenbezogene Daten werden nicht importiert. Der Kurs kann im Folgenden umbenannt werden.", - "components.molecules.import.courses.options.title": "Kurs importieren", + "components.molecules.import.courses.label": "Kurs-Name", + "components.molecules.import.columnBoard.options.selectRoom": "Raum wählen", + "components.molecules.import.columnBoard.options.selectRoom.infoText": + "Der Raum, in den der Bereich importiert werden soll, muss im Folgenden ausgewählt werden.", + "components.molecules.import.courses.rename": + "Bei Bedarf kann der Name des Kurses umbenannt werden: ", + "components.molecules.import.courses.options.title": "Kurs-Kopie importieren", "components.molecules.import.lessons.label": "Thema", - "components.molecules.import.lessons.options.infoText": - "Es wird eine Kopie erstellt. Personenbezogene Daten werden nicht importiert. Das Thema kann im Folgenden umbenannt werden.", + "components.molecules.import.lessons.rename": + "Bei Bedarf kann der Name des Themas umbenannt werden: ", "components.molecules.import.lessons.options.selectCourse.infoText": "Der Kurs, in den das Thema importiert werden soll, muss im Folgenden ausgewählt werden.", "components.molecules.import.lessons.options.selectCourse": "Kurs wählen", @@ -611,9 +616,11 @@ export default { "components.molecules.import.options.loadingMessage": "Import läuft...", "components.molecules.import.options.success": "{name} wurde erfolgreich importiert", + "components.molecules.import.options.tableHeader.InfoText": + "Folgende Inhalte werden nicht importiert:", "components.molecules.import.tasks.label": "Aufgabe", - "components.molecules.import.tasks.options.infoText": - "Es wird eine Kopie erstellt. Personenbezogene Daten werden nicht importiert. Die Aufgabe kann im Folgenden umbenannt werden.", + "components.molecules.import.tasks.rename": + "Bei Bedarf kann der Name der Aufgabe umbenannt werden: ", "components.molecules.import.tasks.options.selectCourse.infoText": "Der Kurs, in den die Aufgabe importiert werden soll, muss im Folgenden ausgewählt werden.", "components.molecules.export.options.info": @@ -652,20 +659,34 @@ export default { "Organisationsleitung", "components.molecules.MintEcFooter.chapters": "Kapitelübersicht", "components.molecules.share.columnBoard.options.infoText": - "Mit dem folgenden Link kann der Bereich als Kopie von anderen Lehrkräften importiert werden. Personenbezogene Daten werden dabei nicht importiert.", + "Mit dem folgenden Link kann der Bereich als Kopie von anderen Lehrkräften importiert werden.", "components.molecules.share.columnBoard.result.linkLabel": "Link Bereich-Kopie", "components.molecules.share.courses.mail.body": "Link zum Kurs:", "components.molecules.share.courses.mail.subject": "Kurs zum Importieren", - "components.molecules.share.courses.options.ctlTools.infotext": - "Externe Tools, die dem Kurs oder Karten im Bereich zugeordnet sind, werden nicht kopiert.", + "components.molecules.shareImport.options.ctlTools.infoText.unavailable": + "In Zielschule nicht verfügbare, externe Tools", + "components.molecules.shareImport.options.ctlTools.infoText.protected": + "Geschützte Einstellungen externer Tools", "components.molecules.share.courses.options.infoText": - "Mit dem folgenden Link kann der Kurs als Kopie von anderen Lehrkräften importiert werden. Personenbezogene Daten werden dabei nicht importiert.", + "Mit dem folgenden Link kann der Kurs als Kopie von anderen Lehrkräften importiert werden.", + "components.molecules.shareImport.options.restrictions.infoText.personalData": + "Personenbezogene Daten", + "components.molecules.shareImport.options.restrictions.infoText.courseFiles": + "Dateien unter Kurs-Dateien", + "components.molecules.shareImport.options.restrictions.infoText.etherpad": + "Inhalte aus Etherpads", + "components.molecules.shareImport.options.restrictions.infoText.geogebra": + "Geogebra IDs und", + "components.molecules.shareImport.options.restrictions.infoText.courseGroups": + "Kursgruppen", + "components.molecules.share.options.tableHeader.InfoText": + "Folgende Inhalte werden nicht kopiert:", "components.molecules.share.courses.result.linkLabel": "Link Kurskopie", "components.molecules.share.lessons.mail.body": "Link zum Thema:", "components.molecules.share.lessons.mail.subject": "Thema zum Importieren", "components.molecules.share.lessons.options.infoText": - "Mit dem folgenden Link kann das Thema als Kopie von anderen Lehrkräften importiert werden. Personenbezogene Daten werden dabei nicht importiert.", + "Mit dem folgenden Link kann das Thema als Kopie von anderen Lehrkräften importiert werden.", "components.molecules.share.lessons.result.linkLabel": "Link Themakopie", "components.molecules.share.options.expiresInDays": "Link läuft nach 21 Tagen ab", @@ -679,7 +700,7 @@ export default { "components.molecules.share.tasks.mail.body": "Link zur Aufgabe:", "components.molecules.share.tasks.mail.subject": "Aufgabe zum Importieren", "components.molecules.share.tasks.options.infoText": - "Mit dem folgenden Link kann die Aufgabe als Kopie von anderen Lehrkräften importiert werden. Personenbezogene Daten werden dabei nicht importiert.", + "Mit dem folgenden Link kann die Aufgabe als Kopie von anderen Lehrkräften importiert werden.", "components.molecules.share.tasks.result.linkLabel": "Link Aufgabekopie", "components.molecules.TaskItemMenu.confirmDelete.text": 'Bist du dir sicher, dass du die Aufgabe "{taskTitle}" löschen möchtest?', @@ -1685,10 +1706,16 @@ export default { "pages.rooms.members.infoText.moreInformation": "weitere Informationen", "pages.rooms.members.label": "Teilnehmende", "pages.rooms.members.add": "Teilnehmende hinzufügen", - "pages.rooms.members.manage": "Teilnehmende verwalten", + "pages.rooms.members.manage": "Raum-Teilnehmende", "pages.rooms.members.remove.ariaLabel": "{memberName} aus Raum entfernen", + "pages.rooms.members.resetSelection.ariaLabel": + "Ausgewählte Teilnehmer aus der Liste zurücksetzen", + "pages.rooms.members.multipleRemove.ariaLabel": + "Mehrere Teilnehmer aus dem Raum entfernen", "pages.rooms.members.remove.confirmation": "{memberName} wirklich aus dem Raum entfernen?", + "pages.rooms.members.multipleRemove.confirmation": + "Ausgewählte Teilnehmende wirklich aus dem Raum entfernen?", "pages.rooms.members.roles.editor": "Raumeditor", "pages.rooms.members.roles.viewer": "Raumbetrachter", "pages.rooms.title": "Räume", diff --git a/src/locales/en.ts b/src/locales/en.ts index 935d2da5f3..398187b2a5 100644 --- a/src/locales/en.ts +++ b/src/locales/en.ts @@ -323,6 +323,7 @@ export default { "components.base.showPassword": "Show password", "components.board.action.addCard": "Add card", "components.board.action.delete": "Delete", + "components.board.action.deleteFromSection": "Remove from section", "components.board.action.detail-view": "Detail view", "components.board.action.download": "Download", "components.board.action.moveDown": "Move down", @@ -516,7 +517,7 @@ export default { "components.molecules.copyResult.ctlTools.info": "External tools associated with the course and boarding cards are not copied.", "components.molecules.copyResult.ctlTools.withFeature.info": - "Protected parts of the tool configurations are not copied.", + "External tools and protected parts of the tool configurations that are not available in the target school are not copied.", "components.molecules.copyResult.etherpadCopy.info": "Content is not copied for data protection reasons and must be added again.", "components.molecules.copyResult.failedCopy": @@ -547,8 +548,9 @@ export default { "components.molecules.copyResult.label.tldraw": "Whiteboard", "components.molecules.copyResult.label.link": "Link", "components.molecules.copyResult.label.timeGroup": "Time Group", - "components.molecules.copyResult.label.unknown": "Unkown", + "components.molecules.copyResult.label.unknown": "Unknown", "components.molecules.copyResult.label.userGroup": "User Group", + "components.molecules.copyResult.label.toolElements": "Tool Element", "components.molecules.copyResult.metadata": "General Information", "components.molecules.copyResult.nexboardCopy.info": "Content is not copied for data protection reasons and must be added again.", @@ -577,6 +579,8 @@ export default { "components.molecules.EdusharingFooter.img_alt": "edusharing-logo", "components.molecules.EdusharingFooter.text": "powered by", "components.molecules.import.columnBoard.label": "Board title", + "components.molecules.import.columnBoard.rename": + "If necessary, the name of the board can be renamed: ", "components.molecules.import.columnBoard.options.infoText": "The board can be renamed below.", "components.molecules.import.columnBoard.options.title": "Import board", @@ -584,15 +588,16 @@ export default { "Select course", "components.molecules.import.columnBoard.options.selectCourse.infoText": "Please select the course into which you would like to import the board.", - "components.molecules.import.courses.label": "Course", - "components.molecules.import.courses.options.ctlTools.infoText": - "A copy will be created.
Personal data will not be imported.
External tools will not be copied.
The course can be renamed below.", - "components.molecules.import.courses.options.infoText": - "Participant-related data will not be copied. The course can be renamed below.", - "components.molecules.import.courses.options.title": "Import course", + "components.molecules.import.courses.label": "Course name", + "components.molecules.import.columnBoard.options.selectRoom": "Select room", + "components.molecules.import.columnBoard.options.selectRoom.infoText": + "Please select the room into which you would like to import the board.", + "components.molecules.import.courses.rename": + "If necessary, the name of the course can be renamed: ", + "components.molecules.import.courses.options.title": "Import course copy", "components.molecules.import.lessons.label": "Topic", - "components.molecules.import.lessons.options.infoText": - "Participant-related data will not be copied. The topic can be renamed below.", + "components.molecules.import.lessons.rename": + "If necessary, the name of the topic can be renamed: ", "components.molecules.import.lessons.options.selectCourse.infoText": "Please select the course into which you would like to import the topic.", "components.molecules.import.lessons.options.selectCourse": "Select course", @@ -605,9 +610,11 @@ export default { "Unfortunately, the necessary authorization is missing.", "components.molecules.import.options.loadingMessage": "Import in progress...", "components.molecules.import.options.success": "{name} imported successfully", + "components.molecules.import.options.tableHeader.InfoText": + "The following content will not be imported:", "components.molecules.import.tasks.label": "Task", - "components.molecules.import.tasks.options.infoText": - "Participant-related data will not be copied. The task can be renamed below.", + "components.molecules.import.tasks.rename": + "If necessary, the name of the task can be renamed: ", "components.molecules.import.tasks.options.selectCourse.infoText": "Please select the course into which you would like to import the task.", "components.molecules.import.tasks.options.selectCourse": "Select course", @@ -640,18 +647,32 @@ export default { "components.molecules.MintEcFooter.chapters": "Chapter overview", "components.molecules.share.courses.mail.body": "Link to the course:", "components.molecules.share.courses.mail.subject": "Course you can import", - "components.molecules.share.courses.options.ctlTools.infotext": - "External tools associated with the course or boarding cards will not be copied.", + "components.molecules.shareImport.options.ctlTools.infoText.unavailable": + "External tools not available in the target school", + "components.molecules.shareImport.options.ctlTools.infoText.protected": + "Protected settings of external tools", "components.molecules.share.courses.options.infoText": - "With the following link, the course can be imported as a copy by other teachers. Personal data will not be imported.", + "With the following link, the course can be imported as a copy by other teachers.", + "components.molecules.shareImport.options.restrictions.infoText.personalData": + "Personal data", + "components.molecules.shareImport.options.restrictions.infoText.courseFiles": + "Files under Course Files", + "components.molecules.shareImport.options.restrictions.infoText.etherpad": + "Content from Etherpads", + "components.molecules.shareImport.options.restrictions.infoText.geogebra": + "Geogebra IDs and", + "components.molecules.shareImport.options.restrictions.infoText.courseGroups": + "Course groups", + "components.molecules.share.options.tableHeader.InfoText": + "The following content will not be copied:", "components.molecules.share.courses.result.linkLabel": "Link course copy", "components.molecules.share.lessons.mail.body": "Link to the topic:", "components.molecules.share.lessons.mail.subject": "Topic you can import", "components.molecules.share.lessons.options.infoText": - "With the following link, the topic can be imported as a copy by other teachers. Personal data will not be imported.", + "With the following link, the topic can be imported as a copy by other teachers.", "components.molecules.share.lessons.result.linkLabel": "Link topic copy", "components.molecules.share.columnBoard.options.infoText": - "With the following link, the board can be imported as a copy by other teachers. Personal data will not be imported.", + "With the following link, the board can be imported as a copy by other teachers.", "components.molecules.share.columnBoard.result.linkLabel": "Link to Board copy", "components.molecules.share.options.expiresInDays": @@ -1654,10 +1675,16 @@ export default { "pages.rooms.members.infoText.moreInformation": "more information", "pages.rooms.members.label": "Participants", "pages.rooms.members.add": "Add participants", - "pages.rooms.members.manage": "Manage participants", + "pages.rooms.members.manage": "Room Participants", "pages.rooms.members.remove.ariaLabel": "Remove {memberName} from the room", + "pages.rooms.members.resetSelection.ariaLabel": + "Reset selected participants from the list", + "pages.rooms.members.multipleRemove.ariaLabel": + "Remove multiple participants from the room", "pages.rooms.members.remove.confirmation": "Remove {memberName} from the room?", + "pages.rooms.members.multipleRemove.confirmation": + "Remove selected participants from the room?", "pages.rooms.members.roles.editor": "Room editor", "pages.rooms.members.roles.viewer": "Room viewer", "pages.rooms.title": "Rooms", diff --git a/src/locales/es.ts b/src/locales/es.ts index ee77001475..956d32f1e6 100644 --- a/src/locales/es.ts +++ b/src/locales/es.ts @@ -328,6 +328,7 @@ export default { "components.base.showPassword": "Mostrar contraseña", "components.board.action.addCard": "Añadir tarjeta", "components.board.action.delete": "Eliminar", + "components.board.action.deleteFromSection": "Quitar de la sección", "components.board.action.detail-view": "Vista detallada", "components.board.action.download": "Descargar", "components.board.action.moveDown": "Bajar", @@ -526,7 +527,7 @@ export default { "components.molecules.copyResult.ctlTools.info": "Las herramientas externas asociadas al curso y las tarjetas de embarque no se copian.", "components.molecules.copyResult.ctlTools.withFeature.info": - "Las partes protegidas de las configuraciones de herramientas no se copian.", + "Las herramientas externas y las partes protegidas de las configuraciones de herramientas que no están disponibles en la escuela de destino no se copian.", "components.molecules.copyResult.etherpadCopy.info": "El contenido no se copia por razones de protección de datos y debe agregarse nuevamente.", "components.molecules.copyResult.failedCopy": @@ -562,6 +563,8 @@ export default { "components.molecules.copyResult.label.timeGroup": "Grupo de tiempo", "components.molecules.copyResult.label.unknown": "Desconocido", "components.molecules.copyResult.label.userGroup": "Grupo de usuario", + "components.molecules.copyResult.label.toolElements": + "Elemento de herramienta", "components.molecules.copyResult.metadata": "Información general", "components.molecules.copyResult.nexboardCopy.info": "El contenido no se copia por razones de protección de datos y debe agregarse nuevamente.", @@ -590,6 +593,8 @@ export default { "components.molecules.EdusharingFooter.img_alt": "edusharing-logotipo", "components.molecules.EdusharingFooter.text": "desarrollado por", "components.molecules.import.columnBoard.label": "Título del tablero", + "components.molecules.import.columnBoard.rename": + "Si es necesario, se puede cambiar el nombre del tablero: ", "components.molecules.import.columnBoard.options.infoText": "Puede cambiar el nombre del tablero a continuación.", "components.molecules.import.columnBoard.options.title": "Importar tablero", @@ -597,15 +602,17 @@ export default { "Elija el curso", "components.molecules.import.columnBoard.options.selectCourse.infoText": "Seleccione el curso al que desea importar el tablero.", - "components.molecules.import.courses.label": "Curso", - "components.molecules.import.courses.options.ctlTools.infoText": - "Se creará una copia.
No se importarán datos personales.
No se copiarán herramientas externas.
Se puede cambiar el nombre del curso a continuación.", - "components.molecules.import.courses.options.infoText": - "Los datos relacionados con los participantes no se copiarán. El curso se puede renombrar a continuación.", - "components.molecules.import.courses.options.title": "Importar curso", + "components.molecules.import.courses.label": "Nombre del curso", + "components.molecules.import.columnBoard.options.selectRoom": + "Seleccionar sala", + "components.molecules.import.columnBoard.options.selectRoom.infoText": + "Seleccione la sala en la que desea importar el tablero.", + "components.molecules.import.courses.rename": + "Si es necesario, se puede cambiar el nombre del curso: ", + "components.molecules.import.courses.options.title": "Importar copia nuestra", "components.molecules.import.lessons.label": "Tema", - "components.molecules.import.lessons.options.infoText": - "Los datos relacionados con los participantes no se copiarán. El tema se puede renombrar a continuación.", + "components.molecules.import.lessons.rename": + "Si es necesario, se puede cambiar el nombre del tema: ", "components.molecules.import.lessons.options.selectCourse.infoText": "Seleccione el curso al que desea importar el tema.", "components.molecules.import.lessons.options.selectCourse": "Elija el curso", @@ -619,9 +626,11 @@ export default { "components.molecules.import.options.loadingMessage": "Importación en curso...", "components.molecules.import.options.success": "{name} importado con éxito", + "components.molecules.import.options.tableHeader.InfoText": + "No se importará el siguiente contenido:", "components.molecules.import.tasks.label": "Tarea", - "components.molecules.import.tasks.options.infoText": - "Los datos relacionados con los participantes no se copiarán. La tarea se puede renombrar a continuación.", + "components.molecules.import.tasks.rename": + "Si es necesario, se puede cambiar el nombre de la tarea: ", "components.molecules.import.tasks.options.selectCourse.infoText": "Seleccione el curso al que desea importar la tarea.", "components.molecules.import.tasks.options.selectCourse": "Elija el curso", @@ -658,16 +667,32 @@ export default { "Enlace a la copia del tablón", "components.molecules.share.courses.mail.body": "Enlace al curso:", "components.molecules.share.courses.mail.subject": "Curso de importación", - "components.molecules.share.courses.options.ctlTools.infotext": + "components.molecules.share.courses.options.ctlTools.infoText": "No se copiarán herramientas externas asociadas al curso ni tarjetas de embarque.", + "components.molecules.shareImport.options.ctlTools.infoText.unavailable": + "Herramientas externas no disponibles en la escuela de destino", + "components.molecules.shareImport.options.ctlTools.infoText.protected": + "Configuraciones protegidas de herramientas externas", "components.molecules.share.courses.options.infoText": - "Con el siguiente enlace, el curso puede ser importado como copia por otros profesores. Los datos personales no se importarán.", + "Utilizando el siguiente enlace, otros profesores pueden importar el curso como una copia.", + "components.molecules.shareImport.options.restrictions.infoText.personalData": + "Datos personales", + "components.molecules.shareImport.options.restrictions.infoText.courseFiles": + "Archivos en Archivos de curso", + "components.molecules.shareImport.options.restrictions.infoText.etherpad": + "Contenido de Etherpads", + "components.molecules.shareImport.options.restrictions.infoText.geogebra": + "ID de Geogebra y", + "components.molecules.shareImport.options.restrictions.infoText.courseGroups": + "Grupos de cursos", + "components.molecules.share.options.tableHeader.InfoText": + "No se copiará el siguiente contenido:", "components.molecules.share.courses.result.linkLabel": "Enlace a la copia del curso", "components.molecules.share.lessons.mail.body": "Enlace al tema:", "components.molecules.share.lessons.mail.subject": "Tema de importación", "components.molecules.share.lessons.options.infoText": - "Con el siguiente enlace, el tema puede ser importado como copia por otros profesores. Los datos personales no se importarán.", + "Con el siguiente enlace, el tema puede ser importado como copia por otros profesores.", "components.molecules.share.lessons.result.linkLabel": "Enlace a la copia del tema", "components.molecules.share.options.expiresInDays": @@ -682,7 +707,7 @@ export default { "components.molecules.share.tasks.mail.body": "Enlace a la tarea:", "components.molecules.share.tasks.mail.subject": "Tarea de importación", "components.molecules.share.tasks.options.infoText": - "Con el siguiente enlace, la tarea puede ser importado como copia por otros profesores. Los datos personales no se importarán.", + "Con el siguiente enlace, la tarea puede ser importado como copia por otros profesores.", "components.molecules.share.tasks.result.linkLabel": "Enlace a la copia de la tarea", "components.molecules.TaskItemMenu.confirmDelete.text": @@ -1311,7 +1336,7 @@ export default { "pages.administration.school.index.usedFileStorage": "Almacenamiento de archivos usados en la nube", "pages.administration.select": "seleccionad{'@'}s", - "pages.administration.selected": "seleccinad{'@'}(s)", + "pages.administration.selected": "seleccionado(s)", "pages.administration.sendMail.alreadyRegistered": "El correo electrónico de registro no se ha enviado porque el registro ya ha tenido lugar", "pages.administration.sendMail.error": @@ -1699,10 +1724,16 @@ export default { "pages.rooms.members.infoText.moreInformation": "más información", "pages.rooms.members.label": "Participantes", "pages.rooms.members.add": "Añadir participantes", - "pages.rooms.members.manage": "Administrar participantes", + "pages.rooms.members.manage": "Participantes de la sala", "pages.rooms.members.remove.ariaLabel": "Eliminar {memberName} de la sala", + "pages.rooms.members.resetSelection.ariaLabel": + "Restablecer las participantes seleccionadas de la lista", + "pages.rooms.members.multipleRemove.ariaLabel": + "Eliminar varios participantes de la sala", "pages.rooms.members.remove.confirmation": "¿Eliminar {memberName} de la sala?", + "pages.rooms.members.multipleRemove.confirmation": + "¿Eliminar participantes seleccionadas de la sala?", "pages.rooms.members.roles.editor": "Editor de salas", "pages.rooms.members.roles.viewer": "Visor de salas", "pages.rooms.title": "Salas", diff --git a/src/locales/uk.ts b/src/locales/uk.ts index 5b119966ca..6771d83768 100644 --- a/src/locales/uk.ts +++ b/src/locales/uk.ts @@ -331,6 +331,7 @@ export default { "components.base.showPassword": "Показати пароль", "components.board.action.addCard": "Додати картка", "components.board.action.delete": "Видалити", + "components.board.action.deleteFromSection": "Видалити з розділу", "components.board.action.detail-view": "Детальний вигляд", "components.board.action.download": "Завантажити", "components.board.action.moveDown": "Рухатися вниз", @@ -524,7 +525,7 @@ export default { "components.molecules.copyResult.ctlTools.info": "Зовнішні інструменти, пов’язані з курсом, і посадкові картки не копіюються.", "components.molecules.copyResult.ctlTools.withFeature.info": - "Захищені частини конфігурацій інструменту не копіюються.", + "Зовнішні інструменти та захищені частини конфігурацій інструментів, які недоступні в цільовій школі, не копіюються.", "components.molecules.copyResult.etherpadCopy.info": "Вміст не копіюється з міркувань захисту даних і повинен бути доданий повторно.", "components.molecules.copyResult.failedCopy": @@ -558,6 +559,8 @@ export default { "components.molecules.copyResult.label.timeGroup": "Група часу", "components.molecules.copyResult.label.unknown": "Невідомий", "components.molecules.copyResult.label.userGroup": "Група користувачів", + "components.molecules.copyResult.label.toolElements": + "Інструментальний елемент", "components.molecules.copyResult.metadata": "Загальна інформація", "components.molecules.copyResult.nexboardCopy.info": "Вміст не копіюється з міркувань захисту даних і повинен бути доданий повторно.", @@ -586,6 +589,8 @@ export default { "components.molecules.EdusharingFooter.img_alt": "логотип edusharing", "components.molecules.EdusharingFooter.text": "на платформі", "components.molecules.import.columnBoard.label": "Назва дошки", + "components.molecules.import.columnBoard.rename": + "При необхідності назву дошки можна змінити: ", "components.molecules.import.columnBoard.options.infoText": "Ви можете перейменувати дошку нижче", "components.molecules.import.columnBoard.options.title": "Дошка імпорту", @@ -593,15 +598,18 @@ export default { "Оберіть курс", "components.molecules.import.columnBoard.options.selectCourse.infoText": "Виберіть курс, до якого ви бажаєте імпортувати дошку.", - "components.molecules.import.courses.label": "Курс", - "components.molecules.import.courses.options.ctlTools.infoText": - "Буде створено копію.
собисті дані не будуть імпортовані.
Зовнішні інструменти не будуть скопійовані.
Курс можна перейменувати нижче.", - "components.molecules.import.courses.options.infoText": - "Дані учасників не будуть скопійовані. Курс можна перейменувати нижче.", - "components.molecules.import.courses.options.title": "Курс імпорту", + "components.molecules.import.courses.label": "Назва курсу", + "components.molecules.import.columnBoard.options.selectRoom": + "Оберіть кімнату", + "components.molecules.import.columnBoard.options.selectRoom.infoText": + "Виберіть кімнату, до якого ви бажаєте імпортувати дошку.", + "components.molecules.import.courses.rename": + "При необхідності назву курсу можна перейменувати: ", + "components.molecules.import.courses.options.title": + "Імпортувати копію курсу", "components.molecules.import.lessons.label": "Тема", - "components.molecules.import.lessons.options.infoText": - "Дані учасників не будуть скопійовані. Тема можна перейменувати нижче.", + "components.molecules.import.lessons.rename": + "При необхідності назву теми можна перейменувати: ", "components.molecules.import.lessons.options.selectCourse.infoText": "Будь ласка, оберіть курс з якого ви хочете імпортувати тему", "components.molecules.import.lessons.options.selectCourse": "Оберіть курс", @@ -615,9 +623,11 @@ export default { "components.molecules.import.options.loadingMessage": "Виконується імпорту...", "components.molecules.import.options.success": "{name} успішно імпортовано", + "components.molecules.import.options.tableHeader.InfoText": + "Наступний вміст не буде імпортовано:", "components.molecules.import.tasks.label": "Завдання", - "components.molecules.import.tasks.options.infoText": - "Дані, що стосуються учасників, не копіюються. Завдання можна перейменувати нижче.", + "components.molecules.import.tasks.rename": + "При необхідності назву завдання можна перейменувати: ", "components.molecules.import.tasks.options.selectCourse.infoText": "Виберіть курс, до якого ви хочете імпортувати завдання.", "components.molecules.import.tasks.options.selectCourse": "Оберіть курс", @@ -655,17 +665,33 @@ export default { "Посилання на копію дошки", "components.molecules.share.courses.mail.body": "Посилання на курс:", "components.molecules.share.courses.mail.subject": "Курс імпорту", - "components.molecules.share.courses.options.ctlTools.infotext": + "components.molecules.share.courses.options.ctlTools.infoText": "Зовнішні інструменти, пов’язані з курсом або посадочними картками, не будуть скопійовані.", + "components.molecules.shareImport.options.ctlTools.infoText.unavailable": + "Зовнішні інструменти недоступні в цільовій школі", + "components.molecules.shareImport.options.ctlTools.infoText.protected": + "Захищені налаштування зовнішніх інструментів", "components.molecules.share.courses.options.infoText": - "За наступним посиланням курс може бути імпортований як копія іншими викладачами. Персональні дані не імпортуються.", + "Використовуючи наступне посилання, курс може бути імпортований як копія іншими викладачами.", + "components.molecules.shareImport.options.restrictions.infoText.personalData": + "Персональні дані", + "components.molecules.shareImport.options.restrictions.infoText.courseFiles": + "Файли в розділі Файли курсу", + "components.molecules.shareImport.options.restrictions.infoText.etherpad": + "Вміст із Etherpads", + "components.molecules.shareImport.options.restrictions.infoText.geogebra": + "Ідентифікатори Geogebra та", + "components.molecules.shareImport.options.restrictions.infoText.courseGroups": + "Групи курсів", + "components.molecules.share.options.tableHeader.InfoText": + "Наступний вміст не буде скопійовано:", "components.molecules.share.courses.result.linkLabel": "Посилання на копію курсу", "components.molecules.share.lessons.mail.body": "Посилання на курс:", "components.molecules.share.lessons.mail.subject": "Теми, які можна імпортувати", "components.molecules.share.lessons.options.infoText": - "За наступним посиланням тему можуть імпортувати як копію інші вчителі. Особисті дані не будуть імпортовані.", + "За наступним посиланням тему можуть імпортувати як копію інші вчителі.", "components.molecules.share.lessons.result.linkLabel": "Копія теми посилання", "components.molecules.share.options.expiresInDays": "Термін дії посилання закінчується через 21 днів", @@ -680,7 +706,7 @@ export default { "components.molecules.share.tasks.mail.subject": "Завдання, які можна імпортувати", "components.molecules.share.tasks.options.infoText": - "За наступним посиланням завдання можуть імпортувати як копію інші вчителі. Особисті дані не будуть імпортовані.", + "За наступним посиланням завдання можуть імпортувати як копію інші вчителі.", "components.molecules.share.tasks.result.linkLabel": "Зв'язати копію завдання", "components.molecules.TaskItemMenu.confirmDelete.text": @@ -1671,15 +1697,22 @@ export default { "pages.rooms.members.error.load": "Не вдалося завантажити список учасників.", "pages.rooms.members.error.add": "Не вдалося додати учасників.", "pages.rooms.members.error.remove": "Не вдалося видалити учасників.", + "pages.rooms.members.infoText": "Додайте учасників до кімнати. Вчителі з інших шкіл можуть бути додані, якщо вони активували видимість у центральному каталозі у своєму профілі ({0}).", "pages.rooms.members.infoText.moreInformation": "більше інформації", "pages.rooms.members.label": "Учасники", "pages.rooms.members.add": "Додайте учасників", - "pages.rooms.members.manage": "Керувати учасниками", + "pages.rooms.members.manage": "Кімната Учасники", "pages.rooms.members.remove.ariaLabel": "Видалити {memberName} з кімнати", + "pages.rooms.members.resetSelection.ariaLabel": + "Скинути вибраних учасників зі списку", + "pages.rooms.members.multipleRemove.ariaLabel": + "Видалити кількох учасників із кімнати", "pages.rooms.members.remove.confirmation": "{memberName} буде видалено з цієї кімнати. Ви впевнені, що хочете видалити?", + "pages.rooms.members.multipleRemove.confirmation": + "Видалити вибраних учасників із кімнати?", "pages.rooms.members.roles.editor": "Редактор кімнати", "pages.rooms.members.roles.viewer": "Переглядач кімнати", "pages.rooms.title": "Кімнати", diff --git a/src/modules/data/board/BoardPageInformation.composable.ts b/src/modules/data/board/BoardPageInformation.composable.ts index f34c2a96d0..ea192b5632 100644 --- a/src/modules/data/board/BoardPageInformation.composable.ts +++ b/src/modules/data/board/BoardPageInformation.composable.ts @@ -1,7 +1,7 @@ import { Breadcrumb } from "@/components/templates/default-wireframe.types"; import { BoardContextType } from "@/types/board/BoardContext"; +import { createTestableSharedComposable } from "@/utils/create-shared-composable"; import { buildPageTitle } from "@/utils/pageTitle"; -import { createSharedComposable } from "@vueuse/core"; import { computed, ref, unref } from "vue"; import { useI18n } from "vue-i18n"; import { useBoardApi } from "./BoardApi.composable"; @@ -13,18 +13,27 @@ const useBoardPageInformation = () => { const boardContext = ref>>(); + const roomId = computed(() => boardContext.value?.id); + const contextType = computed(() => boardContext.value?.type); + const pageTitle = computed(() => { - const courseName = boardContext.value?.name; - const courseNameForPageTitle = courseName ? ", " + courseName : ""; + const roomName = unref(boardContext)?.name; + const roomNameForPageTitle = roomName ? ", " + roomName : ""; + const type = unref(boardContext)?.type; - return buildPageTitle( - `${t("pages.room.boardCard.label.courseBoard")}${courseNameForPageTitle}` - ); + if (type === BoardContextType.Course) { + return buildPageTitle( + `${t("pages.room.boardCard.label.courseBoard")}${roomNameForPageTitle}` + ); + } + if (type === BoardContextType.Room) { + return buildPageTitle( + `${t("pages.roomDetails.board.defaultName")}${roomNameForPageTitle}` + ); + } + return ""; }); - const roomId = computed(() => boardContext.value?.id); - const contextType = computed(() => boardContext.value?.type); - const breadcrumbs = computed((): Breadcrumb[] => { const id = unref(boardContext)?.id; const type = unref(boardContext)?.type; @@ -69,15 +78,20 @@ const useBoardPageInformation = () => { boardContext.value = await getContextInfo(id); }; + const resetPageInformation = (): void => { + boardContext.value = undefined; + }; + return { createPageInformation, breadcrumbs, contextType, pageTitle, roomId, + resetPageInformation, }; }; -export const useSharedBoardPageInformation = createSharedComposable( +export const useSharedBoardPageInformation = createTestableSharedComposable( useBoardPageInformation ); diff --git a/src/modules/data/board/BoardPageInformation.composable.unit.ts b/src/modules/data/board/BoardPageInformation.composable.unit.ts index fdfe1001d7..60f95e282d 100644 --- a/src/modules/data/board/BoardPageInformation.composable.unit.ts +++ b/src/modules/data/board/BoardPageInformation.composable.unit.ts @@ -1,8 +1,8 @@ +import { BoardContextType } from "@/types/board/BoardContext"; import { mountComposable } from "@@/tests/test-utils/mountComposable"; import { createMock, DeepMocked } from "@golevelup/ts-jest"; import { useBoardApi } from "./BoardApi.composable"; import { useSharedBoardPageInformation } from "./BoardPageInformation.composable"; -import { BoardContextType } from "@/types/board/BoardContext"; jest.mock("./BoardApi.composable"); const mockedUseBoardApi = jest.mocked(useBoardApi); @@ -33,77 +33,145 @@ describe("BoardPageInformation.composable", () => { mockedUseBoardApi.mockReturnValue(mockedBoardApiCalls); }); + afterEach(() => { + jest.clearAllMocks(); + }); + describe("when board context exists", () => { - const setup = () => { - mockedBoardApiCalls.getContextInfo.mockResolvedValue({ - id: "courseId", - type: BoardContextType.Course, - name: "Course #1", + describe("when board context is a course", () => { + const setup = () => { + mockedBoardApiCalls.getContextInfo.mockResolvedValue({ + id: "courseId", + type: BoardContextType.Course, + name: "Course #1", + }); + + const { + createPageInformation, + breadcrumbs, + contextType, + pageTitle, + roomId, + } = mountComposable(() => useSharedBoardPageInformation()); + + return { + createPageInformation, + breadcrumbs, + contextType, + pageTitle, + roomId, + }; + }; + + it("should return two breadcrumbs: 1. course page and and 2. course-overview page", async () => { + const { createPageInformation, breadcrumbs } = setup(); + + const fakeId = "abc123-1"; + + await createPageInformation(fakeId); + + expect(breadcrumbs.value).toHaveLength(2); }); - const { - createPageInformation, - breadcrumbs, - contextType, - pageTitle, - roomId, - } = mountComposable(() => useSharedBoardPageInformation()); - - return { - createPageInformation, - breadcrumbs, - contextType, - pageTitle, - roomId, - }; - }; + it("should set page title", async () => { + const { createPageInformation, pageTitle } = setup(); - it("should return two breadcrumbs: 1. course page and and 2. course-overview page", async () => { - const { createPageInformation, breadcrumbs } = setup(); + const fakeId = "abc123-2"; - const fakeId = "abc123-1"; + await createPageInformation(fakeId); - await createPageInformation(fakeId); + expect(pageTitle.value).toContain("Course #1"); + }); - expect(breadcrumbs.value).toHaveLength(2); - }); + it("should set room id", async () => { + const { createPageInformation, roomId } = setup(); - it("should set page title", async () => { - const { createPageInformation, pageTitle } = setup(); + const fakeId = "abc123-2"; - const fakeId = "abc123-2"; + await createPageInformation(fakeId); - await createPageInformation(fakeId); + expect(roomId.value).toEqual("courseId"); + }); + + it("should set context type", async () => { + const { createPageInformation, contextType } = setup(); + + const fakeId = "abc123-2"; - expect(pageTitle.value).toContain("Course #1"); + await createPageInformation(fakeId); + + expect(contextType.value).toEqual(BoardContextType.Course); + }); }); - it("should set room id", async () => { - const { createPageInformation, roomId } = setup(); + describe("when board context is a room", () => { + const setup = () => { + mockedBoardApiCalls.getContextInfo.mockResolvedValue({ + id: "roomId", + type: BoardContextType.Room, + name: "Room #1", + }); + + const { + createPageInformation, + breadcrumbs, + contextType, + pageTitle, + roomId, + } = mountComposable(() => useSharedBoardPageInformation()); + + return { + createPageInformation, + breadcrumbs, + contextType, + pageTitle, + roomId, + }; + }; - const fakeId = "abc123-2"; + it("should return two breadcrumbs: 1. room page and and 2. room-overview page", async () => { + const { createPageInformation, breadcrumbs } = setup(); - await createPageInformation(fakeId); + const fakeId = "abc123-1"; - expect(roomId.value).toEqual("courseId"); - }); + await createPageInformation(fakeId); - it("should set context type", async () => { - const { createPageInformation, contextType } = setup(); + expect(breadcrumbs.value).toHaveLength(2); + }); - const fakeId = "abc123-2"; + it("should set page title", async () => { + const { createPageInformation, pageTitle } = setup(); - await createPageInformation(fakeId); + const fakeId = "abc123-2"; + + await createPageInformation(fakeId); + + expect(pageTitle.value).toContain("Room #1"); + }); + + it("should set room id", async () => { + const { createPageInformation, roomId } = setup(); - expect(contextType.value).toEqual(BoardContextType.Course); + const fakeId = "abc123-2"; + + await createPageInformation(fakeId); + + expect(roomId.value).toEqual("roomId"); + }); + + it("should set context type", async () => { + const { createPageInformation, contextType } = setup(); + + const fakeId = "abc123-2"; + + await createPageInformation(fakeId); + + expect(contextType.value).toEqual(BoardContextType.Room); + }); }); }); describe("when board context does not exist", () => { - beforeEach(() => { - jest.resetAllMocks(); - }); - const setup = () => { mockedBoardApiCalls.getContextInfo.mockResolvedValue(undefined); @@ -124,14 +192,14 @@ describe("BoardPageInformation.composable", () => { expect(breadcrumbs.value).toEqual([]); }); - it("should not add course name to page title", async () => { + it("should generate empty page title", async () => { const { createPageInformation, pageTitle } = setup(); const fakeId = "abc123"; await createPageInformation(fakeId); - expect(pageTitle.value).toEqual("pages.room.boardCard.label.courseBoard"); + expect(pageTitle.value).toEqual(""); }); }); }); diff --git a/src/modules/data/board/BoardPermissions.composable.ts b/src/modules/data/board/BoardPermissions.composable.ts index 3caf45794b..d6c472a13a 100644 --- a/src/modules/data/board/BoardPermissions.composable.ts +++ b/src/modules/data/board/BoardPermissions.composable.ts @@ -1,6 +1,6 @@ -import { createSharedComposable } from "@vueuse/core"; import { authModule } from "@/store"; import { BoardPermissionChecks } from "@/types/board/Permissions"; +import { createSharedComposable } from "@vueuse/core"; const boardPermissions = (): BoardPermissionChecks => { const permissions = authModule?.getUserPermissions || []; @@ -10,6 +10,7 @@ const boardPermissions = (): BoardPermissionChecks => { hasMovePermission: permissions.includes("course_create"), hasCreateCardPermission: permissions.includes("course_create"), hasCreateColumnPermission: permissions.includes("course_create"), + hasCreateToolPermission: permissions.includes("context_tool_admin"), hasEditPermission: permissions.includes("course_edit"), hasDeletePermission: permissions.includes("course_remove"), isTeacher: userRoles.includes("teacher"), diff --git a/src/modules/data/external-tool/ExternalToolApi.composable.unit.ts b/src/modules/data/external-tool/ExternalToolApi.composable.unit.ts index 55d0e62c39..002795c80c 100644 --- a/src/modules/data/external-tool/ExternalToolApi.composable.unit.ts +++ b/src/modules/data/external-tool/ExternalToolApi.composable.unit.ts @@ -1,6 +1,7 @@ import * as serverApi from "@/serverApi/v3/api"; import { ContextExternalToolBodyParams, + LaunchType, ToolContextType, ToolLaunchRequestResponse, } from "@/serverApi/v3/api"; @@ -67,7 +68,7 @@ describe("ExternalToolApi.composable", () => { payload: launchRequest.payload, method: ToolLaunchRequestMethodEnum.Get, openNewTab: launchRequest.openNewTab, - isDeepLink: false, + launchType: LaunchType.Basic, }); }); }); @@ -119,7 +120,7 @@ describe("ExternalToolApi.composable", () => { payload: launchRequest.payload, method: ToolLaunchRequestMethodEnum.Get, openNewTab: launchRequest.openNewTab, - isDeepLink: false, + launchType: LaunchType.Basic, }); }); }); diff --git a/src/modules/data/external-tool/ExternalToolLaunchState.composable.ts b/src/modules/data/external-tool/ExternalToolLaunchState.composable.ts index 9b1e8658fe..0bed810dab 100644 --- a/src/modules/data/external-tool/ExternalToolLaunchState.composable.ts +++ b/src/modules/data/external-tool/ExternalToolLaunchState.composable.ts @@ -1,4 +1,4 @@ -import { ContextExternalToolBodyParams } from "@/serverApi/v3"; +import { ContextExternalToolBodyParams, LaunchType } from "@/serverApi/v3"; import { ToolLaunchRequest, ToolLaunchRequestMethodEnum, @@ -6,10 +6,13 @@ import { import { BusinessError } from "@/store/types/commons"; import { HttpStatusCode } from "@/store/types/http-status-code.enum"; import { mapAxiosErrorToResponseError } from "@/utils/api"; -import { ref, Ref } from "vue"; +import { uniqueId } from "lodash"; +import { onUnmounted, ref, Ref } from "vue"; import { useExternalToolApi } from "./ExternalToolApi.composable"; -export const useExternalToolLaunchState = () => { +export const useExternalToolLaunchState = ( + refreshCallback?: () => Promise | void +) => { const { fetchContextLaunchDataCall, fetchSchoolLaunchDataCall } = useExternalToolApi(); @@ -17,6 +20,9 @@ export const useExternalToolLaunchState = () => { const error: Ref = ref(); const toolLaunchRequest: Ref = ref(); + const windowRef: Ref = ref(null); + const windowIntervalHandle: Ref = ref(); + const fetchContextLaunchRequest = async ( contextExternalToolId: string ): Promise => { @@ -103,9 +109,11 @@ export const useExternalToolLaunchState = () => { const form: HTMLFormElement = document.createElement("form"); form.method = "POST"; form.action = toolLaunch.url; - form.target = toolLaunch.openNewTab ? "_blank" : "_self"; form.id = "launch-form"; + const target = uniqueId(); + form.target = toolLaunch.openNewTab ? target : "_self"; + const payload = JSON.parse(toolLaunch.payload || "{}"); for (const key in payload) { @@ -121,9 +129,26 @@ export const useExternalToolLaunchState = () => { document.body.appendChild(form); + windowRef.value = window.open(undefined, form.target); + form.submit(); + + if (toolLaunch.launchType === LaunchType.Lti11ContentItemSelection) { + windowIntervalHandle.value = setInterval(async () => { + if (windowRef.value?.closed) { + await refreshCallback?.(); + + windowRef.value = null; + clearInterval(windowIntervalHandle.value); + } + }, 1000); + } }; + onUnmounted(() => { + clearInterval(windowIntervalHandle.value); + }); + return { toolLaunchRequest, error, diff --git a/src/modules/data/external-tool/ExternalToolLaunchState.composable.unit.ts b/src/modules/data/external-tool/ExternalToolLaunchState.composable.unit.ts index 7c2c45dcbc..8428dc2f47 100644 --- a/src/modules/data/external-tool/ExternalToolLaunchState.composable.unit.ts +++ b/src/modules/data/external-tool/ExternalToolLaunchState.composable.unit.ts @@ -1,4 +1,8 @@ -import { ContextExternalToolBodyParams, ToolContextType } from "@/serverApi/v3"; +import { + ContextExternalToolBodyParams, + LaunchType, + ToolContextType, +} from "@/serverApi/v3"; import { ToolLaunchRequest, ToolLaunchRequestMethodEnum, @@ -11,6 +15,7 @@ import { toolLaunchRequestFactory } from "@@/tests/test-utils/factory/toolLaunch import { createMock, DeepMocked } from "@golevelup/ts-jest"; import { useExternalToolApi } from "./ExternalToolApi.composable"; import { useExternalToolLaunchState } from "./ExternalToolLaunchState.composable"; +import { nextTick } from "vue"; jest.mock("@data-external-tool/ExternalToolApi.composable"); @@ -64,7 +69,7 @@ describe("ExternalToolLaunchState.composable", () => { url: response.url, payload: response.payload, openNewTab: response.openNewTab, - isDeepLink: false, + launchType: LaunchType.Basic, }); }); @@ -152,7 +157,7 @@ describe("ExternalToolLaunchState.composable", () => { url: response.url, payload: response.payload, openNewTab: response.openNewTab, - isDeepLink: false, + launchType: response.launchType, }); }); @@ -279,11 +284,12 @@ describe("ExternalToolLaunchState.composable", () => { }); describe("when launching a tool with post method", () => { - describe("when opening in the same tab", () => { + describe("when opening in a new tab", () => { const setup = () => { const launchRequest = toolLaunchRequestFactory.build({ method: ToolLaunchRequestMethodEnum.Post, - openNewTab: false, + openNewTab: true, + payload: "", }); const composable = useExternalToolLaunchState(); @@ -295,7 +301,7 @@ describe("ExternalToolLaunchState.composable", () => { }; }; - it("should create a launch form with target _self", () => { + it("should create a launch form with a number as the target ", () => { const { launchRequest, launchTool } = setup(); launchTool(); @@ -303,17 +309,16 @@ describe("ExternalToolLaunchState.composable", () => { const form = document.getElementById("launch-form"); expect(form?.outerHTML).toEqual( - `
` + `
` ); }); }); - describe("when opening in a new tab", () => { + describe("when opening in the same tab", () => { const setup = () => { const launchRequest = toolLaunchRequestFactory.build({ method: ToolLaunchRequestMethodEnum.Post, - openNewTab: true, - payload: "", + openNewTab: false, }); const composable = useExternalToolLaunchState(); @@ -325,7 +330,7 @@ describe("ExternalToolLaunchState.composable", () => { }; }; - it("should create a launch form with target _blank", () => { + it("should create a launch form with target _self", () => { const { launchRequest, launchTool } = setup(); launchTool(); @@ -333,7 +338,7 @@ describe("ExternalToolLaunchState.composable", () => { const form = document.getElementById("launch-form"); expect(form?.outerHTML).toEqual( - `
` + `
` ); }); }); @@ -403,5 +408,84 @@ describe("ExternalToolLaunchState.composable", () => { expect(window.open).not.toHaveBeenCalled(); }); }); + + describe("when the launch is with launchType Lti11ContentItemSelection", () => { + const setup = () => { + const refreshCallback = jest.fn(); + const launchRequest = toolLaunchRequestFactory.build({ + method: ToolLaunchRequestMethodEnum.Post, + openNewTab: true, + launchType: LaunchType.Lti11ContentItemSelection, + }); + + const composable = useExternalToolLaunchState(refreshCallback); + composable.toolLaunchRequest.value = launchRequest; + + const mockWindow = { + closed: false, + }; + + jest + .spyOn(window, "open") + .mockReturnValue(mockWindow as unknown as Window); + + const setInterval = jest.spyOn(window, "setInterval"); + const clearInterval = jest.spyOn(window, "clearInterval"); + + return { + ...composable, + refreshCallback, + setInterval, + clearInterval, + mockWindow, + }; + }; + + afterEach(() => { + jest.useRealTimers(); + jest.restoreAllMocks(); + }); + + it("should call refreshCallback", async () => { + jest.useFakeTimers(); + const { + launchTool, + refreshCallback, + mockWindow, + setInterval, + clearInterval, + } = setup(); + + launchTool(); + + expect(setInterval).toHaveBeenCalled(); + expect(mockWindow.closed).toBe(false); + + mockWindow.closed = true; + + jest.advanceTimersByTime(1000); + await nextTick(); + + expect(refreshCallback).toHaveBeenCalled(); + expect(clearInterval).toHaveBeenCalled(); + }); + + it("should not call refreshCallback", async () => { + jest.useFakeTimers(); + const { launchTool, refreshCallback, mockWindow } = setup(); + + launchTool(); + + expect(setInterval).toHaveBeenCalled(); + + mockWindow.closed = false; + + jest.advanceTimersByTime(1000); + await nextTick(); + + expect(refreshCallback).not.toHaveBeenCalled(); + expect(clearInterval).not.toHaveBeenCalled(); + }); + }); }); }); diff --git a/src/modules/data/external-tool/externalToolReferenceApi.composable.unit.ts b/src/modules/data/external-tool/externalToolReferenceApi.composable.unit.ts index ca726747ec..0950a903af 100644 --- a/src/modules/data/external-tool/externalToolReferenceApi.composable.unit.ts +++ b/src/modules/data/external-tool/externalToolReferenceApi.composable.unit.ts @@ -62,6 +62,7 @@ describe("externalToolReferenceApi.composable", () => { logoUrl: displayData.logoUrl, status: contextExternalToolConfigurationStatusFactory.build(), openInNewTab: displayData.openInNewTab, + isLtiDeepLinkingTool: displayData.isLtiDeepLinkingTool, }); }); }); @@ -109,6 +110,7 @@ describe("externalToolReferenceApi.composable", () => { logoUrl: displayData.logoUrl, status: contextExternalToolConfigurationStatusFactory.build(), openInNewTab: displayData.openInNewTab, + isLtiDeepLinkingTool: displayData.isLtiDeepLinkingTool, }, ]); }); diff --git a/src/modules/data/external-tool/types/external-tool-display-data.ts b/src/modules/data/external-tool/types/external-tool-display-data.ts index b60703e06b..c19d436ec0 100644 --- a/src/modules/data/external-tool/types/external-tool-display-data.ts +++ b/src/modules/data/external-tool/types/external-tool-display-data.ts @@ -1,3 +1,4 @@ +import { LtiDeepLinkResponse } from "@/serverApi/v3"; import { ContextExternalToolConfigurationStatus } from "./context-external-tool-configuration-status"; export type ExternalToolDisplayData = { @@ -14,4 +15,8 @@ export type ExternalToolDisplayData = { openInNewTab: boolean; status: ContextExternalToolConfigurationStatus; + + isLtiDeepLinkingTool: boolean; + + ltiDeepLink?: LtiDeepLinkResponse; }; diff --git a/src/modules/data/room/RoomCreate.state.unit.ts b/src/modules/data/room/RoomCreate.state.unit.ts new file mode 100644 index 0000000000..8f17f278b3 --- /dev/null +++ b/src/modules/data/room/RoomCreate.state.unit.ts @@ -0,0 +1,105 @@ +import { createMock, DeepMocked } from "@golevelup/ts-jest"; +import { useRoomCreateState } from "./RoomCreate.state"; +import * as serverApi from "@/serverApi/v3/api"; +import { AxiosInstance } from "axios"; +import { useApplicationError } from "@/composables/application-error.composable"; +import { initializeAxios, mapAxiosErrorToResponseError } from "@/utils/api"; +import setupStores from "@@/tests/test-utils/setupStores"; +import ApplicationErrorModule from "@/store/application-error"; +import { RoomCreateParams } from "@/types/room/Room"; +import { ref } from "vue"; +import { + apiResponseErrorFactory, + axiosErrorFactory, +} from "@@/tests/test-utils"; + +jest.mock("@/utils/api"); +const mockedMapAxiosErrorToResponseError = jest.mocked( + mapAxiosErrorToResponseError +); + +jest.mock("@/composables/application-error.composable"); +const mockedCreateApplicationError = jest.mocked(useApplicationError); + +const setupErrorResponse = (message = "NOT_FOUND", code = 404) => { + const expectedPayload = apiResponseErrorFactory.build({ + message, + code, + }); + const responseError = axiosErrorFactory.build({ + response: { data: expectedPayload }, + }); + + return { + responseError, + expectedPayload, + }; +}; + +describe("useRoomCreateState", () => { + let roomApiMock: DeepMocked; + let axiosMock: DeepMocked; + let mockedCreateApplicationErrorCalls: ReturnType; + + beforeEach(() => { + roomApiMock = createMock(); + axiosMock = createMock(); + + jest.spyOn(serverApi, "RoomApiFactory").mockReturnValue(roomApiMock); + initializeAxios(axiosMock); + + mockedCreateApplicationErrorCalls = + createMock>(); + mockedCreateApplicationError.mockReturnValue( + mockedCreateApplicationErrorCalls + ); + + setupStores({ + applicationErrorModule: ApplicationErrorModule, + }); + }); + + afterEach(() => { + jest.resetAllMocks(); + }); + + const setup = () => { + const { createRoom, isLoading, roomData } = useRoomCreateState(); + const { expectedPayload } = setupErrorResponse(); + mockedMapAxiosErrorToResponseError.mockReturnValueOnce(expectedPayload); + return { createRoom, isLoading, roomData }; + }; + + describe("createRoom", () => { + const roomData = ref({ + name: "Room 1", + color: serverApi.RoomColor.BlueGrey, + startDate: undefined, + endDate: undefined, + }); + + it("should call roomApi.roomControllerCreateRoom with the provided params", async () => { + const { createRoom, isLoading } = setup(); + expect(isLoading.value).toBe(true); + + await createRoom(roomData.value); + expect(roomApiMock.roomControllerCreateRoom).toHaveBeenCalledWith( + roomData.value + ); + expect(isLoading.value).toBe(false); + }); + + it("should throw an error when fetching room data fails", async () => { + const { createRoom, isLoading } = setup(); + expect(isLoading.value).toBe(true); + roomApiMock.roomControllerCreateRoom.mockRejectedValue({ code: 404 }); + + await createRoom(roomData.value).catch(() => { + expect(mockedMapAxiosErrorToResponseError).toHaveBeenCalledWith({ + code: 404, + }); + }); + expect(isLoading.value).toBe(false); + }); + }); +}); diff --git a/src/modules/data/room/RoomDetails.store.unit.ts b/src/modules/data/room/RoomDetails.store.unit.ts new file mode 100644 index 0000000000..ae0bca7fa4 --- /dev/null +++ b/src/modules/data/room/RoomDetails.store.unit.ts @@ -0,0 +1,138 @@ +import { createPinia, setActivePinia } from "pinia"; +import { useRoomDetailsStore, RoomVariant } from "./RoomDetails.store"; +import { createMock, DeepMocked } from "@golevelup/ts-jest"; +import { AxiosInstance } from "axios"; +import * as serverApi from "@/serverApi/v3/api"; +import { useApplicationError } from "@/composables/application-error.composable"; +import { initializeAxios, mapAxiosErrorToResponseError } from "@/utils/api"; +import { + apiResponseErrorFactory, + axiosErrorFactory, +} from "@@/tests/test-utils"; + +jest.mock("@/utils/api"); +const mockedMapAxiosErrorToResponseError = jest.mocked( + mapAxiosErrorToResponseError +); + +jest.mock("@/composables/application-error.composable"); +const mockedCreateApplicationError = jest.mocked(useApplicationError); + +const setupErrorResponse = (message = "NOT_FOUND", code = 404) => { + const expectedPayload = apiResponseErrorFactory.build({ + message, + code, + }); + const responseError = axiosErrorFactory.build({ + response: { data: expectedPayload }, + }); + + return { + responseError, + expectedPayload, + }; +}; + +describe("useRoomDetailsStore", () => { + let roomApiMock: DeepMocked; + let axiosMock: DeepMocked; + let mockedCreateApplicationErrorCalls: ReturnType; + + beforeEach(() => { + setActivePinia(createPinia()); + roomApiMock = createMock(); + axiosMock = createMock(); + mockedCreateApplicationErrorCalls = + createMock>(); + mockedCreateApplicationError.mockReturnValue( + mockedCreateApplicationErrorCalls + ); + + jest.spyOn(serverApi, "RoomApiFactory").mockReturnValue(roomApiMock); + initializeAxios(axiosMock); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + const setup = ( + options: { errorCode: number } = { + errorCode: 404, + } + ) => { + const store = useRoomDetailsStore(); + + const { expectedPayload } = setupErrorResponse(); + if (options.errorCode !== 404) { + expectedPayload.code = options.errorCode; + } + + mockedMapAxiosErrorToResponseError.mockReturnValue(expectedPayload); + + return { store }; + }; + + describe("fetchRoom", () => { + it("should call fetchRoom api", async () => { + const { store } = setup(); + + expect(store.isLoading).toBe(true); + await store.fetchRoom("room-id"); + + expect(roomApiMock.roomControllerGetRoomDetails).toHaveBeenCalledWith( + "room-id" + ); + expect(roomApiMock.roomControllerGetRoomBoards).toHaveBeenCalledWith( + "room-id" + ); + expect(store.isLoading).toBe(false); + }); + + describe("when fetching room fails with 404", () => { + it("should set roomVariant to COURSE_ROOM", async () => { + const { store } = setup(); + expect(store.isLoading).toBe(true); + roomApiMock.roomControllerGetRoomDetails.mockRejectedValue({ + code: 404, + }); + + await store.fetchRoom("room-id"); + + expect(store.roomVariant).toBe(RoomVariant.COURSE_ROOM); + expect(store.isLoading).toBe(false); + }); + }); + + describe("when fetching room fails with other errors", () => { + it("should throw an error", async () => { + const { store } = setup({ errorCode: 401 }); + expect(store.isLoading).toBe(true); + roomApiMock.roomControllerGetRoomDetails.mockRejectedValue({ + code: 401, + }); + + await expect(store.fetchRoom("room-id")).rejects.toThrow(); + expect(store.isLoading).toBe(false); + }); + }); + }); + + describe("resetState", () => { + it("should reset the state", () => { + const { store } = setup(); + store.resetState(); + expect(store.isLoading).toBe(true); + expect(store.room).toBeUndefined(); + }); + }); + + describe("deactivateRoom", () => { + it("should reset the state", () => { + const { store } = setup(); + store.deactivateRoom(); + expect(store.isLoading).toBe(false); + expect(store.room).toBeUndefined(); + }); + }); +}); diff --git a/src/modules/data/room/RoomEdit.state.unit.ts b/src/modules/data/room/RoomEdit.state.unit.ts new file mode 100644 index 0000000000..52bf187f5b --- /dev/null +++ b/src/modules/data/room/RoomEdit.state.unit.ts @@ -0,0 +1,142 @@ +import { useRoomEditState } from "./RoomEdit.state"; +import * as serverApi from "@/serverApi/v3/api"; +import { initializeAxios, mapAxiosErrorToResponseError } from "@/utils/api"; +import { createMock, DeepMocked } from "@golevelup/ts-jest"; +import { AxiosInstance } from "axios"; +import setupStores from "@@/tests/test-utils/setupStores"; +import ApplicationErrorModule from "@/store/application-error"; +import { useApplicationError } from "@/composables/application-error.composable"; +import { + apiResponseErrorFactory, + axiosErrorFactory, +} from "@@/tests/test-utils"; + +jest.mock("@/utils/api"); +const mockedMapAxiosErrorToResponseError = jest.mocked( + mapAxiosErrorToResponseError +); + +jest.mock("@/composables/application-error.composable"); +const mockedCreateApplicationError = jest.mocked(useApplicationError); + +const setupErrorResponse = (message = "NOT_FOUND", code = 404) => { + const expectedPayload = apiResponseErrorFactory.build({ + message, + code, + }); + const responseError = axiosErrorFactory.build({ + response: { data: expectedPayload }, + }); + + return { + responseError, + expectedPayload, + }; +}; + +describe("useRoomEditState", () => { + let roomApiMock: DeepMocked; + let axiosMock: DeepMocked; + let mockedCreateApplicationErrorCalls: ReturnType; + + beforeEach(() => { + roomApiMock = createMock(); + axiosMock = createMock(); + + jest.spyOn(serverApi, "RoomApiFactory").mockReturnValue(roomApiMock); + initializeAxios(axiosMock); + + mockedCreateApplicationErrorCalls = + createMock>(); + mockedCreateApplicationError.mockReturnValue( + mockedCreateApplicationErrorCalls + ); + + setupStores({ + applicationErrorModule: ApplicationErrorModule, + }); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + const setup = () => { + const { fetchRoom, isLoading, updateRoom, roomData } = useRoomEditState(); + + const { expectedPayload } = setupErrorResponse(); + mockedMapAxiosErrorToResponseError.mockReturnValueOnce(expectedPayload); + + return { + fetchRoom, + isLoading, + updateRoom, + roomData, + }; + }; + + describe("fetchRoom", () => { + it("should fetchRoom api", async () => { + const { fetchRoom, isLoading } = setup(); + expect(isLoading.value).toBe(true); + + await fetchRoom("room-id"); + expect(roomApiMock.roomControllerGetRoomDetails).toHaveBeenCalledWith( + "room-id" + ); + expect(isLoading.value).toBe(false); + }); + + it("should throw an error when fetching room data fails", async () => { + const { fetchRoom, isLoading } = setup(); + roomApiMock.roomControllerGetRoomDetails.mockRejectedValue({ code: 404 }); + + expect(roomApiMock.roomControllerGetRoomDetails).not.toHaveBeenCalledWith( + "room-id" + ); + await expect(fetchRoom("room-id")).rejects.toThrow(); + expect(isLoading.value).toBe(false); + }); + }); + + describe("updateRoom", () => { + it("should call updateRoom api", async () => { + const { updateRoom, isLoading } = setup(); + expect(isLoading.value).toBe(true); + const params = { + name: "room-name", + color: serverApi.RoomColor.BlueGrey, + }; + + await updateRoom("room-id", params); + + expect(roomApiMock.roomControllerUpdateRoom).toHaveBeenCalledWith( + "room-id", + params + ); + + expect(isLoading.value).toBe(false); + }); + }); + + it("should throw an error when updating room data fails", async () => { + const { updateRoom, isLoading } = setup(); + const params = { + name: "room-name", + color: serverApi.RoomColor.BlueGrey, + }; + roomApiMock.roomControllerUpdateRoom.mockRejectedValue({ code: 404 }); + + expect(roomApiMock.roomControllerUpdateRoom).not.toHaveBeenCalledWith( + "room-id", + params + ); + + await updateRoom("room-id", params).catch(() => { + expect(mockedMapAxiosErrorToResponseError).toHaveBeenCalledWith({ + code: 404, + }); + }); + expect(isLoading.value).toBe(false); + }); +}); diff --git a/src/modules/data/room/Rooms.state.unit.ts b/src/modules/data/room/Rooms.state.unit.ts new file mode 100644 index 0000000000..b5ad2d8f0f --- /dev/null +++ b/src/modules/data/room/Rooms.state.unit.ts @@ -0,0 +1,137 @@ +import { createMock, DeepMocked } from "@golevelup/ts-jest"; +import { useRoomsState } from "./Rooms.state"; +import * as serverApi from "@/serverApi/v3/api"; +import { AxiosInstance } from "axios"; +import { useApplicationError } from "@/composables/application-error.composable"; +import { initializeAxios, mapAxiosErrorToResponseError } from "@/utils/api"; +import setupStores from "@@/tests/test-utils/setupStores"; +import ApplicationErrorModule from "@/store/application-error"; +import { + apiResponseErrorFactory, + axiosErrorFactory, +} from "@@/tests/test-utils"; + +jest.mock("@/utils/api"); +const mockedMapAxiosErrorToResponseError = jest.mocked( + mapAxiosErrorToResponseError +); + +jest.mock("@/composables/application-error.composable"); +const mockedCreateApplicationError = jest.mocked(useApplicationError); + +const setupErrorResponse = (message = "NOT_FOUND", code = 404) => { + const expectedPayload = apiResponseErrorFactory.build({ + message, + code, + }); + const responseError = axiosErrorFactory.build({ + response: { data: expectedPayload }, + }); + + return { + responseError, + expectedPayload, + }; +}; + +describe("useRoomsState", () => { + let roomApiMock: DeepMocked; + let axiosMock: DeepMocked; + let mockedCreateApplicationErrorCalls: ReturnType; + + beforeEach(() => { + roomApiMock = createMock(); + axiosMock = createMock(); + + jest.spyOn(serverApi, "RoomApiFactory").mockReturnValue(roomApiMock); + initializeAxios(axiosMock); + + mockedCreateApplicationErrorCalls = + createMock>(); + mockedCreateApplicationError.mockReturnValue( + mockedCreateApplicationErrorCalls + ); + + setupStores({ + applicationErrorModule: ApplicationErrorModule, + }); + }); + + afterEach(() => { + jest.resetAllMocks(); + }); + + const setup = () => { + const { fetchRooms, isLoading, deleteRoom } = useRoomsState(); + const { expectedPayload } = setupErrorResponse(); + mockedMapAxiosErrorToResponseError.mockReturnValueOnce(expectedPayload); + + return { fetchRooms, isLoading, deleteRoom }; + }; + + describe("fetchRooms", () => { + it("should call fetchRooms api", async () => { + const { fetchRooms, isLoading } = setup(); + expect(isLoading.value).toBe(true); + + await fetchRooms(); + + expect(roomApiMock.roomControllerGetRooms).toHaveBeenCalled(); + expect(isLoading.value).toBe(false); + }); + + it("should throw an error when fetching room data fails", async () => { + const { fetchRooms, isLoading } = setup(); + expect(isLoading.value).toBe(true); + roomApiMock.roomControllerGetRooms.mockRejectedValue({ code: 404 }); + + await expect(fetchRooms()).rejects.toThrow(); + expect(isLoading.value).toBe(false); + }); + }); + + describe("deleteRoom", () => { + it("should call deleteRoom api", async () => { + const { deleteRoom, isLoading } = setup(); + expect(isLoading.value).toBe(true); + + await deleteRoom("room-id"); + expect(roomApiMock.roomControllerDeleteRoom).toHaveBeenCalledWith( + "room-id" + ); + expect(isLoading.value).toBe(false); + }); + + it("should throw an error when fetching room data fails", async () => { + const { deleteRoom, isLoading } = setup(); + expect(isLoading.value).toBe(true); + roomApiMock.roomControllerDeleteRoom.mockRejectedValue({ code: 404 }); + + await expect(deleteRoom("room-id")).rejects.toThrow(); + expect(isLoading.value).toBe(false); + }); + }); + + describe("isEmpty", () => { + it("should return true when there are no rooms", () => { + const { isEmpty, rooms } = useRoomsState(); + rooms.value = []; + expect(isEmpty.value).toBe(true); + }); + + it("should return false when there are rooms", () => { + const { isEmpty, rooms } = useRoomsState(); + rooms.value = [ + { + id: "1", + name: "Room 1", + color: serverApi.RoomColor.BlueGrey, + schoolId: "6749dd4e657d98af622e370c", + createdAt: "2024.11.18", + updatedAt: "2024.11.18", + }, + ]; + expect(isEmpty.value).toBe(false); + }); + }); +}); diff --git a/src/modules/data/room/roomMembers/roomMembers.composable.ts b/src/modules/data/room/roomMembers/roomMembers.composable.ts index 5fdc72c7f6..b5fceb22f1 100644 --- a/src/modules/data/room/roomMembers/roomMembers.composable.ts +++ b/src/modules/data/room/roomMembers/roomMembers.composable.ts @@ -27,7 +27,7 @@ export const useRoomMembers = (roomId: string) => { }; const userRoles: Record = { - [RoleName.RoomEditor]: t("common.labels.teacher"), + [RoleName.Roomeditor]: t("common.labels.teacher"), }; const roomApi = RoomApiFactory(undefined, "/v3", $axios); @@ -55,7 +55,7 @@ export const useRoomMembers = (roomId: string) => { payload: { role: RoleName; schoolId?: string; - } = { role: RoleName.RoomEditor, schoolId: ownSchool.id } + } = { role: RoleName.Roomeditor, schoolId: ownSchool.id } ) => { try { const result = ( @@ -70,7 +70,7 @@ export const useRoomMembers = (roomId: string) => { ...user, userId: user.id, fullName: `${user.lastName}, ${user.firstName}`, - roleName: RoleName.RoomEditor, + roleName: RoleName.Roomeditor, }; }) .filter((user) => { @@ -105,7 +105,7 @@ export const useRoomMembers = (roomId: string) => { const userIdsAndRoles: UserIdAndRole[] = newMembers.map((member) => ({ userId: member.userId, - roleName: UserIdAndRoleRoleNameEnum.Editor, + roleName: UserIdAndRoleRoleNameEnum.Roomeditor, })); try { diff --git a/src/modules/data/room/roomMembers/roomMembers.composable.unit.ts b/src/modules/data/room/roomMembers/roomMembers.composable.unit.ts index 02429baeaf..c065523b7c 100644 --- a/src/modules/data/room/roomMembers/roomMembers.composable.unit.ts +++ b/src/modules/data/room/roomMembers/roomMembers.composable.unit.ts @@ -141,7 +141,7 @@ describe("useRoomMembers", () => { ...user, userId: user.id, fullName: `${user.lastName}, ${user.firstName}`, - roleName: RoleName.RoomEditor, + roleName: RoleName.Roomeditor, schoolName: "Paul-Gerhardt-Gymnasium", })) ); @@ -245,7 +245,7 @@ describe("useRoomMembers", () => { userIdsAndRoles: [ { userId: firstPotentialMember.userId, - roleName: UserIdAndRoleRoleNameEnum.Editor, + roleName: UserIdAndRoleRoleNameEnum.Roomeditor, }, ], } diff --git a/src/modules/feature/board-deleted-element/DeletedElement.vue b/src/modules/feature/board-deleted-element/DeletedElement.vue index 3460ff13d0..454a4923cb 100644 --- a/src/modules/feature/board-deleted-element/DeletedElement.vue +++ b/src/modules/feature/board-deleted-element/DeletedElement.vue @@ -8,20 +8,6 @@ ref="deletedElement" :ripple="false" > - - {{ - $t( - "components.cardElement.deletedElement.warning.externalToolElement", - { - toolName: element.content.title, - } - ) - }} - + + {{ + $t( + "components.cardElement.deletedElement.warning.externalToolElement", + { + toolName: element.content.title, + } + ) + }} + diff --git a/src/modules/feature/board-external-tool-element/ExternalToolElement.unit.ts b/src/modules/feature/board-external-tool-element/ExternalToolElement.unit.ts index 6251585991..2600daa23f 100644 --- a/src/modules/feature/board-external-tool-element/ExternalToolElement.unit.ts +++ b/src/modules/feature/board-external-tool-element/ExternalToolElement.unit.ts @@ -8,6 +8,7 @@ import { contextExternalToolFactory, externalToolDisplayDataFactory, externalToolElementResponseFactory, + ltiDeepLinkResponseFactory, schoolToolConfigurationStatusFactory, } from "@@/tests/test-utils"; import { @@ -22,8 +23,6 @@ import { useExternalToolDisplayState, useExternalToolLaunchState, } from "@data-external-tool"; -import { ToolLaunchRequest } from "@/store/external-tool"; -import { toolLaunchRequestFactory } from "@@/tests/test-utils/factory/toolLaunchRequestFactory"; import { createMock, DeepMocked } from "@golevelup/ts-jest"; import { mdiPuzzleOutline } from "@icons/material"; import { useSharedLastCreatedElement } from "@util-board"; @@ -104,12 +103,10 @@ describe("ExternalToolElement", () => { element: ExternalToolElementResponse; isEditMode: boolean; }, - displayData?: ExternalToolDisplayData, - toolLaunchRequest?: ToolLaunchRequest + displayData?: ExternalToolDisplayData ) => { useContentElementStateMock.modelValue = ref(propsData.element.content); useExternalToolElementDisplayStateMock.displayData.value = displayData; - useExternalToolLaunchStateMock.toolLaunchRequest.value = toolLaunchRequest; const refreshTime = 299000; const envConfigModuleMock = createModuleMocks(EnvConfigModule, { @@ -155,6 +152,7 @@ describe("ExternalToolElement", () => { }, externalToolDisplayDataFactory.build({ status: schoolToolConfigurationStatusFactory.build(), + isLtiDeepLinkingTool: false, }) ); @@ -399,16 +397,19 @@ describe("ExternalToolElement", () => { }); }); - describe("when deeplinking tool is selected", () => { + describe("when a deeplinking tool without a deeplink is selected", () => { describe("when not in edit mode", () => { const setup = () => { const { wrapper } = getWrapper( { - element: externalToolElementResponseFactory.build(), + element: externalToolElementResponseFactory.build({ + content: { contextExternalToolId: "contextExternalToolId" }, + }), isEditMode: false, }, - undefined, - toolLaunchRequestFactory.build({ isDeepLink: true }) + externalToolDisplayDataFactory.build({ + isLtiDeepLinkingTool: true, + }) ); return { @@ -436,8 +437,8 @@ describe("ExternalToolElement", () => { }, externalToolDisplayDataFactory.build({ status: schoolToolConfigurationStatusFactory.build(), - }), - toolLaunchRequestFactory.build({ isDeepLink: true }) + isLtiDeepLinkingTool: true, + }) ); return { @@ -452,15 +453,65 @@ describe("ExternalToolElement", () => { expect(element.isVisible()).toEqual(true); }); + }); + }); - it("should load the launch request", async () => { - setup(); + describe("when a deeplinking tool with a deeplink is selected", () => { + describe("when not in edit mode", () => { + const setup = () => { + const { wrapper } = getWrapper( + { + element: externalToolElementResponseFactory.build({ + content: { contextExternalToolId: "contextExternalToolId" }, + }), + isEditMode: false, + }, + externalToolDisplayDataFactory.build({ + isLtiDeepLinkingTool: true, + ltiDeepLink: ltiDeepLinkResponseFactory.build(), + }) + ); - await nextTick(); + return { + wrapper, + }; + }; - expect( - useExternalToolLaunchStateMock.fetchContextLaunchRequest - ).toHaveBeenCalledWith("contextExternalToolId"); + it("should show the element", () => { + const { wrapper } = setup(); + + const element = wrapper.findComponent({ ref: "externalToolElement" }); + + expect(element.isVisible()).toEqual(true); + }); + }); + + describe("when in edit mode", () => { + const setup = () => { + const { wrapper } = getWrapper( + { + element: externalToolElementResponseFactory.build({ + content: { contextExternalToolId: "contextExternalToolId" }, + }), + isEditMode: true, + }, + externalToolDisplayDataFactory.build({ + isLtiDeepLinkingTool: true, + ltiDeepLink: ltiDeepLinkResponseFactory.build(), + }) + ); + + return { + wrapper, + }; + }; + + it("should show the element", () => { + const { wrapper } = setup(); + + const element = wrapper.findComponent({ ref: "externalToolElement" }); + + expect(element.isVisible()).toEqual(true); }); }); }); @@ -562,6 +613,11 @@ describe("ExternalToolElement", () => { describe("when the component has finished loading", () => { const setup = () => { const contextExternalToolId = "context-external-tool-id"; + const displayData = externalToolDisplayDataFactory.build({ + contextExternalToolId, + isLtiDeepLinkingTool: true, + ltiDeepLink: ltiDeepLinkResponseFactory.build(), + }); useExternalToolElementDisplayStateMock.isLoading = ref(false); @@ -572,7 +628,7 @@ describe("ExternalToolElement", () => { }), isEditMode: false, }, - externalToolDisplayDataFactory.build({ contextExternalToolId }) + displayData ); return { @@ -725,8 +781,9 @@ describe("ExternalToolElement", () => { }), isEditMode: true, }, - undefined, - toolLaunchRequestFactory.build({ isDeepLink: true }) + externalToolDisplayDataFactory.build({ + isLtiDeepLinkingTool: true, + }) ); return { diff --git a/src/modules/feature/board-external-tool-element/ExternalToolElement.vue b/src/modules/feature/board-external-tool-element/ExternalToolElement.vue index 714be5b7c0..b7504cf429 100644 --- a/src/modules/feature/board-external-tool-element/ExternalToolElement.vue +++ b/src/modules/feature/board-external-tool-element/ExternalToolElement.vue @@ -117,8 +117,7 @@ const { launchTool, fetchContextLaunchRequest, error: launchError, - toolLaunchRequest, -} = useExternalToolLaunchState(); +} = useExternalToolLaunchState(() => loadCardData()); const autofocus: Ref = ref(false); const element: Ref = toRef(props, "element"); @@ -141,16 +140,20 @@ const hasLinkedTool: ComputedRef = computed( () => !!modelValue.value.contextExternalToolId ); -const isDeepLinkingTool: ComputedRef = computed( - () => toolLaunchRequest.value?.isDeepLink +const isDeepLinkingTool: ComputedRef = computed( + () => !!displayData.value?.isLtiDeepLinkingTool +); + +const hasDeepLink: ComputedRef = computed( + () => !!displayData.value?.ltiDeepLink ); const showTool: ComputedRef = computed(() => { - if (!toolLaunchRequest.value) { + if (!displayData.value || !hasLinkedTool.value) { return false; } - return toolLaunchRequest.value.isDeepLink ? false : hasLinkedTool.value; + return isDeepLinkingTool.value ? hasDeepLink.value : true; }); const toolDisplayName: ComputedRef = computed( @@ -187,20 +190,19 @@ const isLoading = computed( const isConfigurationDialogOpen: Ref = ref(false); const toolTitle: ComputedRef = computed(() => { - if (hasLinkedTool.value) { - if (isDeepLinkingTool.value) { - return t( - "feature-board-external-tool-element.placeholder.selectContent", - { - toolName: toolDisplayName.value, - } - ); - } + if (!hasLinkedTool.value) { + return t("feature-board-external-tool-element.placeholder.selectTool"); + } - return toolDisplayName.value; + if (isDeepLinkingTool.value) { + return hasDeepLink.value + ? toolDisplayName.value + : t("feature-board-external-tool-element.placeholder.selectContent", { + toolName: toolDisplayName.value, + }); } - return t("feature-board-external-tool-element.placeholder.selectTool"); + return toolDisplayName.value; }); const onKeydownArrow = (event: KeyboardEvent) => { diff --git a/src/modules/feature/board/board/Board.unit.ts b/src/modules/feature/board/board/Board.unit.ts index 425ae99558..b7339b7fef 100644 --- a/src/modules/feature/board/board/Board.unit.ts +++ b/src/modules/feature/board/board/Board.unit.ts @@ -1,4 +1,5 @@ import CopyResultModal from "@/components/copy-result-modal/CopyResultModal.vue"; +import { useApplicationError } from "@/composables/application-error.composable"; import { useCopy } from "@/composables/copy"; import { ConfigResponse, @@ -6,7 +7,7 @@ import { CopyApiResponseTypeEnum, ShareTokenBodyParamsParentTypeEnum, } from "@/serverApi/v3"; -import { envConfigModule, applicationErrorModule } from "@/store"; +import { applicationErrorModule, envConfigModule } from "@/store"; import ApplicationErrorModule from "@/store/application-error"; import CopyModule from "@/store/copy"; import CourseRoomDetailsModule from "@/store/course-room-details"; @@ -15,11 +16,13 @@ import LoadingStateModule from "@/store/loading-state"; import NotifierModule from "@/store/notifier"; import SchoolExternalToolsModule from "@/store/school-external-tools"; import ShareModule from "@/store/share"; +import { HttpStatusCode } from "@/store/types/http-status-code.enum"; import { Board } from "@/types/board/Board"; import { BoardPermissionChecks, defaultPermissions, } from "@/types/board/Permissions"; +import { createApplicationError } from "@/utils/create-application-error.factory"; import { COPY_MODULE_KEY, COURSE_ROOM_DETAILS_MODULE_KEY, @@ -28,7 +31,6 @@ import { SCHOOL_EXTERNAL_TOOLS_MODULE_KEY, SHARE_MODULE_KEY, } from "@/utils/inject"; -import { createModuleMocks } from "@@/tests/test-utils/mock-store-module"; import { mockedPiniaStoreTyping } from "@@/tests/test-utils"; import { boardResponseFactory, @@ -36,6 +38,7 @@ import { columnResponseFactory, envsFactory, } from "@@/tests/test-utils/factory"; +import { createModuleMocks } from "@@/tests/test-utils/mock-store-module"; import { createTestingI18n, createTestingVuetify, @@ -63,9 +66,6 @@ import { Router, useRoute, useRouter } from "vue-router"; import BoardVue from "./Board.vue"; import BoardColumnVue from "./BoardColumn.vue"; import BoardHeaderVue from "./BoardHeader.vue"; -import { useApplicationError } from "@/composables/application-error.composable"; -import { HttpStatusCode } from "@/store/types/http-status-code.enum"; -import { createApplicationError } from "@/utils/create-application-error.factory"; jest.mock("@util-board/BoardNotifier.composable"); const mockedUseBoardNotifier = jest.mocked(useBoardNotifier); @@ -152,6 +152,7 @@ describe("Board", () => { contextType: computed(() => undefined), pageTitle: computed(() => "page-title"), roomId: computed(() => "room-id"), + resetPageInformation: jest.fn(), }); mockedUseEditMode.mockReturnValue({ @@ -343,12 +344,6 @@ describe("Board", () => { expect(wrapperVM.board).toStrictEqual(board); }); - it("should call cardStore loadPreferredTools action", () => { - const { cardStore } = setup(); - - expect(cardStore.loadPreferredTools).toHaveBeenCalled(); - }); - it("should be found in the dom", () => { const { wrapper } = setup(); @@ -376,6 +371,24 @@ describe("Board", () => { expect(boardHeaderComponent.props("title")).toBe(board.title); }); }); + + describe("when the user has tool create permissions", () => { + it("should call cardStore loadPreferredTools action", () => { + mockedBoardPermissions.hasCreateToolPermission = true; + const { cardStore } = setup(); + + expect(cardStore.loadPreferredTools).toHaveBeenCalled(); + }); + }); + + describe("when the user does not have tool create permissions", () => { + it("should call cardStore loadPreferredTools action", () => { + mockedBoardPermissions.hasCreateToolPermission = false; + const { cardStore } = setup(); + + expect(cardStore.loadPreferredTools).not.toHaveBeenCalled(); + }); + }); }); describe("when component is unMounted", () => { diff --git a/src/modules/feature/board/board/Board.vue b/src/modules/feature/board/board/Board.vue index 5210bf412c..4b7bac62ad 100644 --- a/src/modules/feature/board/board/Board.vue +++ b/src/modules/feature/board/board/Board.vue @@ -94,7 +94,9 @@ diff --git a/src/modules/feature/room/RoomDetails.unit.ts b/src/modules/feature/room/RoomDetails.unit.ts index b8de87e3ed..397dc62f95 100644 --- a/src/modules/feature/room/RoomDetails.unit.ts +++ b/src/modules/feature/room/RoomDetails.unit.ts @@ -12,8 +12,10 @@ const mockRoom: RoomDetailsType = { id: "59cce2c61113d1132c98dc06", name: "A11Y for Beginners", color: RoomColor.Magenta, + schoolId: "123", startDate: "", endDate: "", + permissions: [], createdAt: "2017-09-28T11:49:39.924Z", updatedAt: "2017-09-28T11:49:39.924Z", }; diff --git a/src/modules/feature/room/RoomMembers/AddMembers.unit.ts b/src/modules/feature/room/RoomMembers/AddMembers.unit.ts index e3daf9a7cb..78a77f9695 100644 --- a/src/modules/feature/room/RoomMembers/AddMembers.unit.ts +++ b/src/modules/feature/room/RoomMembers/AddMembers.unit.ts @@ -125,11 +125,11 @@ describe("AddMembers", () => { }); expect(roleComponent).toBeTruthy(); - await roleComponent.vm.$emit("update:modelValue", RoleName.RoomViewer); + await roleComponent.vm.$emit("update:modelValue", RoleName.Roomviewer); await nextTick(); expect(wrapper.emitted("update:role")).toHaveLength(1); expect(wrapper.emitted("update:role")![0]).toStrictEqual([ - { role: RoleName.RoomViewer, schoolId: roomMembersSchools[0].id }, + { role: RoleName.Roomviewer, schoolId: roomMembersSchools[0].id }, ]); }); }); diff --git a/src/modules/feature/room/RoomMembers/AddMembers.vue b/src/modules/feature/room/RoomMembers/AddMembers.vue index 7a300dd8c0..3a70ed3e98 100644 --- a/src/modules/feature/room/RoomMembers/AddMembers.vue +++ b/src/modules/feature/room/RoomMembers/AddMembers.vue @@ -102,7 +102,7 @@ const { t } = useI18n(); const schoolList = toRef(props, "schools"); const selectedSchool = ref(schoolList.value[0].id); -const roles = [{ id: RoleName.RoomEditor, name: t("common.labels.teacher") }]; +const roles = [{ id: RoleName.Roomeditor, name: t("common.labels.teacher") }]; const selectedRole = ref(roles[0].id); const selectedUsers = ref([]); diff --git a/src/modules/feature/room/RoomMembers/MembersTable.unit.ts b/src/modules/feature/room/RoomMembers/MembersTable.unit.ts index a6e5914235..a0da092c36 100644 --- a/src/modules/feature/room/RoomMembers/MembersTable.unit.ts +++ b/src/modules/feature/room/RoomMembers/MembersTable.unit.ts @@ -3,97 +3,375 @@ import { createTestingVuetify, } from "@@/tests/test-utils/setup"; import MembersTable from "./MembersTable.vue"; -import { Ref } from "vue"; +import { ref } from "vue"; import { mdiMenuDown, mdiMenuUp, mdiMagnify } from "@icons/material"; import { roomMemberResponseFactory } from "@@/tests/test-utils"; -import { RoomMember } from "@data-room"; +import { DOMWrapper, VueWrapper } from "@vue/test-utils"; +import { VDataTable, VTextField } from "vuetify/lib/components/index.mjs"; +import { useConfirmationDialog } from "@ui-confirmation-dialog"; +import setupConfirmationComposableMock from "@@/tests/test-utils/composable-mocks/setupConfirmationComposableMock"; -const mockMembers = roomMemberResponseFactory.buildList(3); +jest.mock("@ui-confirmation-dialog"); +const mockedUseRemoveConfirmationDialog = jest.mocked(useConfirmationDialog); describe("MembersTable", () => { + let askConfirmationMock: jest.Mock; + + beforeEach(() => { + askConfirmationMock = jest.fn(); + setupConfirmationComposableMock({ + askConfirmationMock, + }); + mockedUseRemoveConfirmationDialog.mockReturnValue({ + askConfirmation: askConfirmationMock, + isDialogOpen: ref(false), + }); + }); + + const tableHeaders = [ + "common.labels.firstName", + "common.labels.lastName", + "common.labels.role", + "common.words.mainSchool", + "", + ]; + const setup = () => { + const mockMembers = roomMemberResponseFactory.buildList(3); const wrapper = mount(MembersTable, { + attachTo: document.body, global: { plugins: [createTestingVuetify(), createTestingI18n()], }, props: { members: mockMembers }, }); - const wrapperVM = wrapper.vm as unknown as { - members: RoomMember[]; - search: Ref; - tableTitle: string; - tableHeader: { title: string; key: string }[]; - }; + return { wrapper, mockMembers }; + }; + + // index 0 is the header checkbox + const selectCheckboxes = async (indices: number[], wrapper: VueWrapper) => { + const dataTable = wrapper.getComponent(VDataTable); + const checkboxes = dataTable.findAll("input[type='checkbox']"); - return { wrapper, wrapperVM }; + for (const index of indices) { + const checkbox = checkboxes[index]; + await checkbox.trigger("click"); + } + + return { checkboxes }; }; - describe("when component is mounted", () => { - it("should render member's table", () => { + const getCheckedIndices = (checkboxes: DOMWrapper[]) => + checkboxes.reduce((selectedIndices, checkbox, index) => { + if (checkbox.attributes("checked") === "") { + selectedIndices.push(index); + } + return selectedIndices; + }, [] as Array); + + it("should render members table component", () => { + const { wrapper } = setup(); + + expect(wrapper.exists()).toBe(true); + }); + + it("should render data table", () => { + const { wrapper, mockMembers } = setup(); + + const dataTable = wrapper.getComponent(VDataTable); + + expect(dataTable.props("headers")!.map((header) => header.title)).toEqual( + tableHeaders + ); + expect(dataTable.props("items")).toEqual(mockMembers); + expect(dataTable.props("sortAscIcon")).toEqual(mdiMenuDown); + expect(dataTable.props("sortDescIcon")).toEqual(mdiMenuUp); + }); + + it("should render checkboxes", async () => { + const { wrapper, mockMembers } = setup(); + + const dataTable = wrapper.findComponent(VDataTable); + const checkboxes = dataTable.findAll("input[type='checkbox']"); + + expect(checkboxes.length).toEqual(mockMembers.length + 1); // all checkboxes including header checkbox + }); + + describe("when selecting members", () => { + it("should select all members when header checkbox is clicked", async () => { const { wrapper } = setup(); - expect(wrapper.exists()).toBe(true); - expect(wrapper.findComponent(MembersTable)).toBeTruthy(); + const { checkboxes } = await selectCheckboxes([0], wrapper); + const checkedIndices = getCheckedIndices(checkboxes); + + const expectedIndices = [0, 1, 2, 3]; + + expect(checkedIndices).toEqual(expectedIndices); }); - }); - describe("DataTable component", () => { - it("should render the table component", () => { - const { wrapper, wrapperVM } = setup(); - const dataTable = wrapper.findComponent({ name: "v-data-table" }); + it("should emit select:members", async () => { + const { wrapper, mockMembers } = setup(); + + await selectCheckboxes([1], wrapper); + + const selectEvents = wrapper.emitted("select:members"); + expect(selectEvents).toHaveLength(1); + expect(selectEvents![0]).toEqual([[mockMembers[0].userId]]); + }); + + it("should render the multi action menu", async () => { + const { wrapper } = setup(); + + await selectCheckboxes([1], wrapper); + + const multiActionMenu = wrapper.find("[data-testid=multi-action-menu]"); + + expect(multiActionMenu.exists()).toBe(true); + }); + + it("should render selected members remove button", async () => { + const { wrapper } = setup(); + + await selectCheckboxes([1], wrapper); + + const removeButton = wrapper.findComponent({ + ref: "removeSelectedMembers", + }); + + expect(removeButton.exists()).toBe(true); + }); + + it("should render selected members reset button", async () => { + const { wrapper } = setup(); + + await selectCheckboxes([1, 2], wrapper); + + const resetButton = wrapper.findComponent({ + ref: "resetSelectedMembers", + }); + + expect(resetButton.exists()).toBe(true); + }); + + it("should reset member selection when clicking reset button", async () => { + const { wrapper } = setup(); + + askConfirmationMock.mockResolvedValue(false); + + await selectCheckboxes([0], wrapper); + + const resetButton = wrapper.findComponent({ + ref: "resetSelectedMembers", + }); + await resetButton.trigger("click"); + + const checkboxes = wrapper + .getComponent(VDataTable) + .findAll("input[type='checkbox']"); + + const checkedIndices = getCheckedIndices(checkboxes); + + expect(checkedIndices).toEqual([]); + }); + + it.each([ + { + description: "one member", + checkboxesToSelect: [1], + }, + { + description: "multiple members", + checkboxesToSelect: [1, 2], + }, + ])( + "should render number of selected users in multi action menu, when $description selected", + async ({ checkboxesToSelect }) => { + const { wrapper } = setup(); + + await selectCheckboxes(checkboxesToSelect, wrapper); + + const multiActionMenu = wrapper.get("[data-testid=multi-action-menu]"); + + expect(multiActionMenu.text()).toBe( + `${checkboxesToSelect.length} pages.administration.selected` + ); + } + ); - expect(dataTable).toBeTruthy(); - expect(dataTable.vm.items).toEqual(mockMembers); - expect(dataTable.vm.headers).toEqual(wrapperVM.tableHeader); - expect(dataTable.vm["sortAscIcon"]).toEqual(mdiMenuDown); - expect(dataTable.vm["sortDescIcon"]).toEqual(mdiMenuUp); + it("should emit remove:members when selected members remove button is clicked", async () => { + const { wrapper, mockMembers } = setup(); + + askConfirmationMock.mockResolvedValue(true); + + await selectCheckboxes([1], wrapper); + + const removeButton = wrapper.findComponent({ + ref: "removeSelectedMembers", + }); + await removeButton.trigger("click"); + + const removeEvents = wrapper.emitted("remove:members"); + expect(removeEvents).toHaveLength(1); + expect(removeEvents![0]).toEqual([[mockMembers[0].userId]]); }); - it("should render the table title", () => { - const { wrapper, wrapperVM } = setup(); - const title = wrapper.find(".table-title"); + it("should not emit remove:members event when remove was cancled", async () => { + const { wrapper } = setup(); + + askConfirmationMock.mockResolvedValue(false); + + await selectCheckboxes([1], wrapper); - expect(title.text()).toBe(wrapperVM.tableTitle); + const removeButton = wrapper.findComponent({ + ref: "removeSelectedMembers", + }); + await removeButton.trigger("click"); + + expect(wrapper.emitted()).not.toHaveProperty("remove:members"); }); - describe("when the remove button is clicked", () => { - it("should emit the remove event", async () => { + it.each([ + { + description: "single member", + checkboxesToSelect: [1], + expectedMessage: "pages.rooms.members.remove.confirmation", + }, + { + description: "multiple members", + checkboxesToSelect: [1, 2], + expectedMessage: "pages.rooms.members.multipleRemove.confirmation", + }, + ])( + "should render confirmation dialog with text for $description when remove button is clicked", + async ({ checkboxesToSelect, expectedMessage }) => { const { wrapper } = setup(); + + askConfirmationMock.mockResolvedValue(true); + + await selectCheckboxes(checkboxesToSelect, wrapper); + const removeButton = wrapper.findComponent({ - name: "v-btn", - ref: "removeMember", + ref: "removeSelectedMembers", + }); + await removeButton.trigger("click"); + + expect(wrapper.emitted()).toHaveProperty("remove:members"); + + expect(askConfirmationMock).toHaveBeenCalledWith({ + confirmActionLangKey: "common.actions.remove", + message: expectedMessage, + }); + } + ); + + it("should keep selection if confirmation dialog is canceled", async () => { + const { wrapper } = setup(); + + askConfirmationMock.mockResolvedValue(false); + + await selectCheckboxes([1], wrapper); + + const removeButton = wrapper.getComponent({ + ref: "removeSelectedMembers", + }); + await removeButton.trigger("click"); + + const checkboxes = wrapper + .getComponent(VDataTable) + .findAll("input[type='checkbox']"); + + const checkedIndices = getCheckedIndices(checkboxes); + + expect(checkedIndices).toEqual([1]); + }); + }); + + describe("when no members are selected", () => { + it("should not render multi action menu when no members are selected", async () => { + const { wrapper } = setup(); + const multiActionMenu = wrapper.find("[data-testid=multi-action-menu]"); + + expect(multiActionMenu.exists()).toBe(false); + }); + + describe("when the remove button in the user row is clicked", () => { + const triggerMemberRemoval = async ( + index: number, + wrapper: VueWrapper + ) => { + const dataTable = wrapper.getComponent(VDataTable); + const removeButton = dataTable.findComponent( + `[data-testid=remove-member-${index}]` + ); + + await removeButton.trigger("click"); + }; + + it("should open confirmation dialog with remove message for single member ", async () => { + const { wrapper } = setup(); + + askConfirmationMock.mockResolvedValue(true); + + await triggerMemberRemoval(0, wrapper); + + expect(askConfirmationMock).toHaveBeenCalledWith({ + confirmActionLangKey: "common.actions.remove", + message: "pages.rooms.members.remove.confirmation", }); + }); + + it("should call remove:members event after confirmation", async () => { + const { wrapper, mockMembers } = setup(); + + askConfirmationMock.mockResolvedValue(true); + + await triggerMemberRemoval(0, wrapper); - await removeButton.vm.$emit("click"); - expect(wrapper.emitted()).toHaveProperty("remove:member"); + expect(wrapper.emitted()).toHaveProperty("remove:members"); + + const removeEvents = wrapper.emitted("remove:members"); + expect(removeEvents).toHaveLength(1); + expect(removeEvents![0]).toEqual([[mockMembers[0].userId]]); + }); + + it("should not call remove:members event when dialog is cancelled", async () => { + const { wrapper } = setup(); + + askConfirmationMock.mockResolvedValue(false); + + await triggerMemberRemoval(0, wrapper); + + expect(wrapper.emitted()).not.toHaveProperty("remove:members"); }); }); }); - describe("Search component", () => { + describe("when searching for members", () => { it("should render the search component", () => { - const { wrapper, wrapperVM } = setup(); - const search = wrapper.findComponent({ name: "v-text-field" }); + const { wrapper } = setup(); - expect(search).toBeTruthy(); - expect(search.vm["label"]).toEqual("common.labels.search"); - expect(search.vm["prependInnerIcon"]).toEqual(mdiMagnify); - expect(search.vm["vModel"]).toEqual(wrapperVM.search.value); + const search = wrapper.getComponent(VTextField); + + expect(search.props("label")).toEqual("common.labels.search"); + expect(search.props("prependInnerIcon")).toEqual(mdiMagnify); }); it("should filter the members based on the search value", async () => { - const { wrapper, wrapperVM } = setup(); - const search = wrapper.findComponent({ name: "v-text-field" }); + const { wrapper, mockMembers } = setup(); + + const search = wrapper.getComponent(VTextField); + const searchValue = mockMembers[0].firstName; + + await search.setValue(searchValue); - const title = wrapper.find(".table-title"); - expect(title.text()).toContain(`(${mockMembers.length})`); - await search.vm.$emit("update:modelValue", mockMembers[0].firstName); - expect(wrapperVM.search).toBe(mockMembers[0].firstName); - const dataTable = wrapper.findComponent({ name: "v-data-table" }); + const dataTable = wrapper.getComponent(VDataTable); + const dataTableTextContent = dataTable.text(); - expect(dataTable.vm.search).toEqual(mockMembers[0].firstName); - expect(title.text()).toContain("(1)"); + expect(dataTable.props("search")).toEqual(searchValue); + expect(dataTableTextContent).toContain(mockMembers[0].firstName); + expect(dataTableTextContent).not.toContain(mockMembers[1].firstName); + expect(dataTableTextContent).not.toContain(mockMembers[2].firstName); }); }); }); diff --git a/src/modules/feature/room/RoomMembers/MembersTable.vue b/src/modules/feature/room/RoomMembers/MembersTable.vue index b27873d63d..d8ba934427 100644 --- a/src/modules/feature/room/RoomMembers/MembersTable.vue +++ b/src/modules/feature/room/RoomMembers/MembersTable.vue @@ -1,61 +1,104 @@ diff --git a/src/modules/feature/room/RoomTile.unit.ts b/src/modules/feature/room/RoomTile.unit.ts index 2cdffefb40..5089d73021 100644 --- a/src/modules/feature/room/RoomTile.unit.ts +++ b/src/modules/feature/room/RoomTile.unit.ts @@ -12,6 +12,7 @@ const mockRoom: RoomItem = { id: "123", name: "A11Y for Beginners", color: RoomColor.Magenta, + schoolId: "123", createdAt: "2024-10-11T16:36:06.434Z", updatedAt: "2024-10-11T16:36:06.434Z", }; diff --git a/src/modules/feature/room/index.ts b/src/modules/feature/room/index.ts index 38f6bbfb17..49882e414e 100644 --- a/src/modules/feature/room/index.ts +++ b/src/modules/feature/room/index.ts @@ -3,5 +3,13 @@ import RoomDetails from "./RoomDetails.vue"; import RoomForm from "./RoomForm.vue"; import MembersTable from "./RoomMembers/MembersTable.vue"; import AddMembers from "./RoomMembers/AddMembers.vue"; +import { useRoomAuthorization } from "./roomAuthorization.composable"; -export { RoomGrid, RoomDetails, RoomForm, MembersTable, AddMembers }; +export { + RoomGrid, + RoomDetails, + RoomForm, + MembersTable, + AddMembers, + useRoomAuthorization, +}; diff --git a/src/modules/feature/room/roomAuthorization.composable.ts b/src/modules/feature/room/roomAuthorization.composable.ts new file mode 100644 index 0000000000..1f9f05c2e0 --- /dev/null +++ b/src/modules/feature/room/roomAuthorization.composable.ts @@ -0,0 +1,38 @@ +import { + Permission, + ImportUserResponseRoleNamesEnum as Roles, +} from "@/serverApi/v3"; +import { RoomDetails } from "@/types/room/Room"; +import { AUTH_MODULE_KEY, injectStrict } from "@/utils/inject"; +import { ComputedRef, MaybeRefOrGetter, ref, toValue, watchEffect } from "vue"; + +export const useRoomAuthorization = ( + room: + | ComputedRef + | MaybeRefOrGetter +) => { + const authModule = injectStrict(AUTH_MODULE_KEY); + + const canCreateRoom = ref(false); + const canViewRoom = ref(false); + const canEditRoom = ref(false); + const canDeleteRoom = ref(false); + + watchEffect(() => { + const permissions = toValue(room)?.permissions ?? []; + + canCreateRoom.value = + permissions.includes(Permission.RoomEdit) && + authModule.getUserRoles.includes(Roles.Teacher); + canViewRoom.value = permissions.includes(Permission.RoomView); + canEditRoom.value = permissions.includes(Permission.RoomEdit); + canDeleteRoom.value = permissions.includes(Permission.RoomDelete); + }); + + return { + canCreateRoom, + canViewRoom, + canEditRoom, + canDeleteRoom, + }; +}; diff --git a/src/modules/feature/room/roomAuthorization.composable.unit.ts b/src/modules/feature/room/roomAuthorization.composable.unit.ts new file mode 100644 index 0000000000..f44b68c550 --- /dev/null +++ b/src/modules/feature/room/roomAuthorization.composable.unit.ts @@ -0,0 +1,147 @@ +import { + Permission, + ImportUserResponseRoleNamesEnum as Roles, +} from "@/serverApi/v3"; +import AuthModule from "@/store/auth"; +import { RoomDetails } from "@/types/room/Room"; +import { AUTH_MODULE_KEY } from "@/utils/inject"; +import { mountComposable } from "@@/tests/test-utils"; +import { roomDetailsFactory } from "@@/tests/test-utils/factory/roomDetailsFactory"; +import { createModuleMocks } from "@@/tests/test-utils/mock-store-module"; +import { ref } from "vue"; +import { useRoomAuthorization } from "./roomAuthorization.composable"; + +type setupParams = { userRoles?: Roles[]; roomPermissions?: Permission[] }; + +describe("roomAuthorization", () => { + const genericSetup = ({ + userRoles = [], + roomPermissions = [], + }: setupParams) => { + const room = ref( + roomDetailsFactory.build({ permissions: roomPermissions }) + ); + const authModuleMock = createModuleMocks(AuthModule, { + getUserRoles: userRoles, + }); + return mountComposable(() => useRoomAuthorization(room), { + global: { provide: { [AUTH_MODULE_KEY]: authModuleMock } }, + }); + }; + + describe("canCreateRoom", () => { + describe("when the user has room edit permission and is a teacher", () => { + const setup = () => { + return genericSetup({ + userRoles: [Roles.Teacher], + roomPermissions: [Permission.RoomEdit], + }); + }; + + it("should be allowed to create a room", () => { + const { canCreateRoom } = setup(); + expect(canCreateRoom.value).toBe(true); + }); + }); + + describe("when the user has room edit permission but is not a teacher", () => { + const setup = () => { + return genericSetup({ + userRoles: [Roles.Student], + roomPermissions: [Permission.RoomEdit], + }); + }; + + it("should not be allowed to create a room", () => { + const { canCreateRoom } = setup(); + expect(canCreateRoom.value).toBe(false); + }); + }); + + describe("when the user has room view permission and is a teacher", () => { + const setup = () => { + return genericSetup({ + userRoles: [Roles.Teacher], + roomPermissions: [Permission.RoomView], + }); + }; + + it("should not be allowed to create a room", () => { + const { canCreateRoom } = setup(); + expect(canCreateRoom.value).toBe(false); + }); + }); + }); + + describe("canViewRoom", () => { + describe("when the user has room view permission", () => { + const setup = () => { + return genericSetup({ roomPermissions: [Permission.RoomView] }); + }; + + it("should be allowed to view the room", () => { + const { canViewRoom } = setup(); + expect(canViewRoom.value).toBe(true); + }); + }); + + describe("when the user does not have room view permission", () => { + const setup = () => { + return genericSetup({ roomPermissions: [] }); + }; + + it("should not be allowed to view the room", () => { + const { canViewRoom } = setup(); + expect(canViewRoom.value).toBe(false); + }); + }); + }); + + describe("canEditRoom", () => { + describe("when the user has room edit permission", () => { + const setup = () => { + return genericSetup({ roomPermissions: [Permission.RoomEdit] }); + }; + + it("should be allowed to edit the room", () => { + const { canEditRoom } = setup(); + expect(canEditRoom.value).toBe(true); + }); + }); + + describe("when the user does not have room edit permission", () => { + const setup = () => { + return genericSetup({ roomPermissions: [] }); + }; + + it("should not be allowed to edit the room", () => { + const { canEditRoom } = setup(); + expect(canEditRoom.value).toBe(false); + }); + }); + }); + + describe("canDeleteRoom", () => { + describe("when the user has room delete permission", () => { + const setup = () => { + return genericSetup({ roomPermissions: [Permission.RoomDelete] }); + }; + + it("should be allowed to delete the room", () => { + const { canDeleteRoom } = setup(); + expect(canDeleteRoom.value).toBe(true); + }); + }); + + describe("when the user does not have room delete permission", () => { + const setup = () => { + return genericSetup({ roomPermissions: [] }); + }; + + it("should not be allowed to delete the room", () => { + const { canDeleteRoom } = setup(); + expect(canDeleteRoom.value).toBe(false); + }); + }); + }); +}); diff --git a/src/modules/page/room/RoomCreate.page.unit.ts b/src/modules/page/room/RoomCreate.page.unit.ts new file mode 100644 index 0000000000..2b2b01f884 --- /dev/null +++ b/src/modules/page/room/RoomCreate.page.unit.ts @@ -0,0 +1,95 @@ +import { RoomCreateParams } from "@/types/room/Room"; +import { useRoomCreateState } from "@data-room"; +import { + createTestingI18n, + createTestingVuetify, +} from "@@/tests/test-utils/setup"; +import { RoomCreatePage } from "@page-room"; +import { useRouter } from "vue-router"; +import { RoomColor } from "@/serverApi/v3"; +import { RoomForm } from "@feature-room"; +import { NOTIFIER_MODULE_KEY } from "@/utils/inject"; +import { createModuleMocks } from "@@/tests/test-utils/mock-store-module"; +import NotifierModule from "@/store/notifier"; +import { flushPromises } from "@vue/test-utils"; + +jest.mock("vue-router", () => ({ + useRouter: jest.fn().mockReturnValue({ + push: jest.fn(), + }), +})); + +jest.mock("@data-room/RoomCreate.state.ts", () => ({ + useRoomCreateState: jest.fn().mockReturnValue({ + createRoom: jest.fn().mockResolvedValue({ + id: "123", + name: "test", + color: "blue", + }), + roomData: { + name: "test-room-data", + color: "blue", + }, + }), +})); + +jest.mock("@/utils/pageTitle", () => ({ + buildPageTitle: (pageTitle) => pageTitle ?? "", +})); + +const roomParams: RoomCreateParams = { + name: "test", + color: RoomColor.Blue, +}; + +describe("@pages/RoomCreate.page.vue", () => { + const setup = () => { + const notifierModule = createModuleMocks(NotifierModule); + + const wrapper = mount(RoomCreatePage, { + global: { + plugins: [createTestingVuetify(), createTestingI18n()], + provide: { + [NOTIFIER_MODULE_KEY.valueOf()]: notifierModule, + }, + }, + }); + + const { createRoom } = useRoomCreateState(); + const roomFormComponent = wrapper.findComponent(RoomForm); + + return { + wrapper, + router: useRouter(), + createRoom, + roomFormComponent, + }; + }; + + it("should have roomFormComponent", () => { + const { roomFormComponent } = setup(); + expect(roomFormComponent).toBeDefined(); + }); + + it("should call createRoom with correct parameters on save", async () => { + const { createRoom, roomFormComponent } = setup(); + roomFormComponent.vm.$emit("save", { room: roomParams }); + await flushPromises(); + expect(createRoom).toHaveBeenCalledWith(roomParams); + }); + + it("should navigate to 'room-details' with correct room id on save", async () => { + const { roomFormComponent, router } = setup(); + roomFormComponent.vm.$emit("save", roomParams); + expect(router.push).toHaveBeenCalledWith({ + name: "rooms-id", + params: { id: "123" }, + }); + }); + + it("should navigate to 'rooms' on cancel", async () => { + const { router, roomFormComponent } = setup(); + roomFormComponent.vm.$emit("cancel"); + expect(router.push).toHaveBeenCalledWith({ name: "rooms" }); + }); +}); diff --git a/src/modules/page/room/RoomCreate.page.vue b/src/modules/page/room/RoomCreate.page.vue index 06329ea830..df3e870b16 100644 --- a/src/modules/page/room/RoomCreate.page.vue +++ b/src/modules/page/room/RoomCreate.page.vue @@ -50,7 +50,7 @@ const onSave = async (payload: { room: RoomCreateParams }) => { try { const room = await createRoom(payload.room); - router.push({ name: "room-details", params: { id: room.id } }); + router.push({ name: "rooms-id", params: { id: room.id } }); } catch (error: unknown) { if (isInvalidRequestError(error)) { notifierModule.show({ diff --git a/src/modules/page/room/RoomDetails.page.unit.ts b/src/modules/page/room/RoomDetails.page.unit.ts new file mode 100644 index 0000000000..a3594aa7f9 --- /dev/null +++ b/src/modules/page/room/RoomDetails.page.unit.ts @@ -0,0 +1,362 @@ +import * as serverApi from "@/serverApi/v3/api"; +import DefaultWireframe from "@/components/templates/DefaultWireframe.vue"; +import { RoomColor } from "@/serverApi/v3"; +import { AUTH_MODULE_KEY, ENV_CONFIG_MODULE_KEY } from "@/utils/inject"; +import EnvConfigModule from "@/store/env-config"; +import { envsFactory, mockedPiniaStoreTyping } from "@@/tests/test-utils"; +import { createModuleMocks } from "@@/tests/test-utils/mock-store-module"; +import { + createTestingI18n, + createTestingVuetify, +} from "@@/tests/test-utils/setup"; +import { RoomVariant, useRoomDetailsStore } from "@data-room"; +import { RoomDetailsPage } from "@page-room"; +import { createTestingPinia } from "@pinia/testing"; +import AuthModule from "@/store/auth"; +import { nextTick, ref, Ref } from "vue"; +import { Breadcrumb } from "@/components/templates/default-wireframe.types"; +import setupStores from "@@/tests/test-utils/setupStores"; +import { roomDetailsFactory } from "@@/tests/test-utils/factory/roomDetailsFactory"; +import { flushPromises } from "@vue/test-utils"; +import { Router, useRoute, useRouter } from "vue-router"; +import { createMock } from "@golevelup/ts-jest"; +import { useRoomAuthorization } from "@feature-room"; + +jest.mock("vue-router", () => ({ + useRoute: jest.fn(), + useRouter: jest.fn(), +})); + +jest.mock("@feature-room/roomAuthorization.composable"); +const roomPermissions: ReturnType = { + canCreateRoom: ref(false), + canViewRoom: ref(false), + canEditRoom: ref(false), + canDeleteRoom: ref(false), +}; +(useRoomAuthorization as jest.Mock).mockReturnValue(roomPermissions); + +describe("@pages/RoomsDetails.page.vue", () => { + const router = createMock(); + const useRouteMock = useRoute; + useRouteMock.mockReturnValue({ params: { id: "room-id" }, push: jest.fn() }); + const useRouterMock = useRouter; + + beforeEach(() => { + useRouterMock.mockReturnValue(router); + setupStores({ + envConfigModule: EnvConfigModule, + }); + }); + + const setup = ( + { + isLoading, + roomVariant, + envs, + isTeacher, + permissions, + }: { + isLoading: boolean; + roomVariant?: RoomVariant; + envs?: Record; + isTeacher?: boolean; + permissions?: string[]; + } = { isLoading: false, roomVariant: RoomVariant.ROOM } + ) => { + const envConfigModule = createModuleMocks(EnvConfigModule, { + getEnv: envsFactory.build({ + FEATURE_BOARD_LAYOUT_ENABLED: true, + FEATURE_ROOMS_ENABLED: true, + ...envs, + }), + }); + + const authModule = createModuleMocks(AuthModule, { + getUserPermissions: permissions || [], + getUserRoles: !isTeacher ? ["teacher"] : [], + }); + + const wrapper = mount(RoomDetailsPage, { + global: { + plugins: [ + createTestingVuetify(), + createTestingI18n(), + createTestingPinia(), + ], + provide: { + [ENV_CONFIG_MODULE_KEY.valueOf()]: envConfigModule, + [AUTH_MODULE_KEY.valueOf()]: authModule, + }, + stubs: { + SelectBoardLayoutDialog: true, + CourseRoomDetailsPage: true, + }, + }, + router, + }); + + const roomDetailsStore = mockedPiniaStoreTyping(useRoomDetailsStore); + const room = roomDetailsFactory.build(); + roomDetailsStore.room = room; + roomDetailsStore.roomVariant = roomVariant; + roomDetailsStore.isLoading = isLoading; + roomDetailsStore.roomBoards = []; + + const wrapperVM = wrapper.vm as unknown as { + room: { + id: string; + name: string; + color: RoomColor; + createdAt: string; + updatedAt: string; + }; + pageTitle: string; + breadcrumbs: Breadcrumb[]; + fabItems: { + icon: string; + title: string; + ariaLabel: string; + testId: string; + }[]; + isMembersDialogOpen: boolean; + isRoom: Ref; + onFabClick: ReturnType; + boardLayoutsEnabled: Ref; + }; + + return { + wrapper, + roomDetailsStore, + wrapperVM, + authModule, + }; + }; + + describe("when page is mounted", () => { + it("should be rendered in DOM", () => { + const { wrapper } = setup(); + + expect(wrapper.vm).toBeDefined(); + }); + + it("should render DefaultWireframe", () => { + const { wrapper } = setup(); + + const defaultWireframe = wrapper.findComponent(DefaultWireframe); + expect(defaultWireframe).toBeDefined(); + }); + + describe("breadcrumbs", () => { + it("should have elements inside the list", () => { + const { wrapperVM } = setup(); + + expect(wrapperVM.breadcrumbs).toHaveLength(2); + expect(wrapperVM.breadcrumbs[0].title).toContain("pages.rooms.title"); + }); + + describe("when room is undefined", () => { + it("should not have any element inside the list", () => { + const { wrapperVM, roomDetailsStore } = setup(); + roomDetailsStore.room = undefined; + expect(wrapperVM.breadcrumbs).toHaveLength(0); + }); + }); + }); + + describe("pageTitle", () => { + it("should set the page title", async () => { + const { wrapperVM } = setup(); + expect(wrapperVM.pageTitle).toContain("pages.roomDetails.title"); + }); + }); + + describe("boardLayoutsEnabled", () => { + it("should be true", () => { + const { wrapperVM } = setup(); + expect(wrapperVM.boardLayoutsEnabled).toBe(true); + }); + + it("should be false", () => { + const { wrapperVM } = setup({ + envs: { FEATURE_BOARD_LAYOUT_ENABLED: false }, + isLoading: false, + }); + expect(wrapperVM.boardLayoutsEnabled).toBe(false); + }); + }); + + describe("when FEATURE_ROOMS_ENABLED flag is set to true", () => { + describe("and user has 'room_create' permission", () => { + it("should call fetchRoom on mounted", () => { + const { roomDetailsStore } = setup({ + isLoading: false, + permissions: ["room_create"], + }); + + expect(roomDetailsStore.fetchRoom).toHaveBeenCalledWith("room-id"); + expect(roomDetailsStore.deactivateRoom).not.toHaveBeenCalled(); + }); + }); + + describe("and user does not have 'room_create' permission", () => { + it("should not call fetchRoom on mounted", () => { + const { roomDetailsStore } = setup({ + isLoading: false, + permissions: [], + }); + + expect(roomDetailsStore.deactivateRoom).toHaveBeenCalled(); + expect(roomDetailsStore.fetchRoom).not.toHaveBeenCalled(); + }); + }); + }); + + describe("when FEATURE_ROOMS_ENABLED flag is set false", () => { + it("should not call fetchRoom on mounted", () => { + const { roomDetailsStore } = setup({ + envs: { FEATURE_ROOMS_ENABLED: false }, + isLoading: false, + }); + + expect(roomDetailsStore.deactivateRoom).toHaveBeenCalled(); + expect(roomDetailsStore.fetchRoom).not.toHaveBeenCalled(); + }); + }); + }); + + describe("when loading", () => { + it("should render a loading indication", () => { + const { wrapper } = setup({ isLoading: true }); + + const div = wrapper.find("[data-testid=loading]"); + expect(div.exists()).toBe(true); + }); + }); + + describe("when roomVariant is invalid", () => { + it("should not render RoomDetails", () => { + const { wrapper } = setup({ + roomVariant: RoomVariant.COURSE_ROOM, + isLoading: false, + }); + + const roomDetailsComponent = wrapper.findComponent({ + name: "RoomDetails", + }); + expect(roomDetailsComponent.exists()).toBe(false); + }); + }); + + describe("when not loading", () => { + it("should not render a loading indication", async () => { + const { wrapper } = setup({ + isLoading: false, + permissions: ["room_create"], + }); + await flushPromises(); + + const div = wrapper.find('[data-testid="loading"]'); + expect(div.exists()).toBe(false); + }); + + describe("when roomVariant is valid", () => { + it("should render DefaultLayout ", async () => { + const { wrapper } = setup({ + isLoading: false, + roomVariant: RoomVariant.ROOM, + permissions: ["room_create"], + }); + await flushPromises(); + + const defaultWireframe = wrapper.findComponent(DefaultWireframe); + expect(defaultWireframe.exists()).toBe(true); + }); + + describe("when user clicks on add content button", () => { + it("should open the select layout dialog", async () => { + const { wrapper } = setup({ + isLoading: false, + roomVariant: RoomVariant.ROOM, + permissions: ["room_create"], + }); + + await flushPromises(); + const defaultWireframe = wrapper.findComponent(DefaultWireframe); + defaultWireframe.vm.$emit("onFabItemClick", "board-type-dialog-open"); + + const selectLayoutDialog = wrapper.findComponent({ + name: "SelectBoardLayoutDialog", + }); + expect(selectLayoutDialog.exists()).toBe(true); + }); + }); + + describe("when user creates a new board", () => { + it.each([ + { event: "multi-column", layout: "columns" }, + { event: "single-column", layout: "list" }, + ])( + "should have a '$layout'-layout when '$event' was chosen", + async ({ event, layout }) => { + roomPermissions.canEditRoom.value = true; + + const { wrapper } = setup({ + isLoading: false, + roomVariant: RoomVariant.ROOM, + permissions: ["room_create"], + }); + + await flushPromises(); + + const mockApi = { + boardControllerCreateBoard: jest + .fn() + .mockResolvedValue({ data: { id: "board-id" } }), + }; + const spy = jest + .spyOn(serverApi, "BoardApiFactory") + .mockReturnValue( + mockApi as unknown as serverApi.BoardApiInterface + ); + + const selectLayoutDialog = wrapper.findComponent({ + name: "SelectBoardLayoutDialog", + }); + + await selectLayoutDialog.vm.$emit(`select:${event}`); + + expect(mockApi.boardControllerCreateBoard).toHaveBeenCalledTimes(1); + expect(mockApi.boardControllerCreateBoard).toHaveBeenCalledWith( + expect.objectContaining({ layout }) + ); + + spy.mockRestore(); + } + ); + }); + + describe("when user clicks on edit room button", () => { + it("should navigate to the edit room page", async () => { + const { wrapper } = setup({ + isLoading: false, + roomVariant: RoomVariant.ROOM, + permissions: ["room_edit"], + }); + + await flushPromises(); + const defaultWireframe = wrapper.findComponent(DefaultWireframe); + const kebabMenu = defaultWireframe.find('[data-testid="room-menu"]'); + await kebabMenu.trigger("click"); + + const menus = wrapper.findAllComponents({ name: "VListItem" }); + + menus[0].vm.$emit("click"); + await nextTick(); + + expect(useRouteMock).toHaveBeenCalled(); + }); + }); + }); + }); +}); diff --git a/src/modules/page/room/RoomDetails.page.vue b/src/modules/page/room/RoomDetails.page.vue index 6cf03b0587..a4cdd23b0e 100644 --- a/src/modules/page/room/RoomDetails.page.vue +++ b/src/modules/page/room/RoomDetails.page.vue @@ -1,5 +1,14 @@ + diff --git a/src/modules/ui/layout/sidebar/SidebarItems.composable.ts b/src/modules/ui/layout/sidebar/SidebarItems.composable.ts index 6d96fafab2..1311bfb7b3 100644 --- a/src/modules/ui/layout/sidebar/SidebarItems.composable.ts +++ b/src/modules/ui/layout/sidebar/SidebarItems.composable.ts @@ -40,6 +40,7 @@ export const useSidebarItems = () => { to: "/rooms", icon: mdiAccountSupervisorCircleOutline, feature: "FEATURE_ROOMS_ENABLED", + permissions: ["ROOM_CREATE"], testId: "Räume", }, { diff --git a/src/modules/ui/layout/sidebar/SidebarSelection.composable.ts b/src/modules/ui/layout/sidebar/SidebarSelection.composable.ts index 4e235e8c9e..500d81a620 100644 --- a/src/modules/ui/layout/sidebar/SidebarSelection.composable.ts +++ b/src/modules/ui/layout/sidebar/SidebarSelection.composable.ts @@ -53,8 +53,10 @@ export const useSidebarSelection = ( // Board if (route.name === "boards-id") { return ( - item.to === "/rooms/courses-overview" && - contextType.value === BoardExternalReferenceType.Course + (item.to === "/rooms/courses-overview" && + contextType.value === BoardExternalReferenceType.Course) || + (item.to === "/rooms" && + contextType.value === BoardExternalReferenceType.Room) ); } diff --git a/src/modules/ui/layout/sidebar/SidebarSelection.composable.unit.ts b/src/modules/ui/layout/sidebar/SidebarSelection.composable.unit.ts index bce6e8b3f0..0da961beb1 100644 --- a/src/modules/ui/layout/sidebar/SidebarSelection.composable.unit.ts +++ b/src/modules/ui/layout/sidebar/SidebarSelection.composable.unit.ts @@ -33,6 +33,7 @@ describe("@ui/layout/sidebar/SidebarSelection.composable", () => { contextType: computed(() => undefined), pageTitle: computed(() => "page-title"), roomId: computed(() => "room-id"), + resetPageInformation: jest.fn(), }); }); @@ -264,6 +265,7 @@ describe("@ui/layout/sidebar/SidebarSelection.composable", () => { contextType: computed(() => BoardContextType.Course), pageTitle: computed(() => "page-title"), roomId: computed(() => "room-id"), + resetPageInformation: jest.fn(), }); return setupBoardDetailsRoute(); @@ -285,6 +287,7 @@ describe("@ui/layout/sidebar/SidebarSelection.composable", () => { contextType: computed(() => BoardContextType.User), pageTitle: computed(() => "page-title"), roomId: computed(() => "room-id"), + resetPageInformation: jest.fn(), }); return setupBoardDetailsRoute(); diff --git a/src/pages/course-rooms/CourseRoomDetails.page.unit.ts b/src/pages/course-rooms/CourseRoomDetails.page.unit.ts index 96fab4fd3f..2576198222 100644 --- a/src/pages/course-rooms/CourseRoomDetails.page.unit.ts +++ b/src/pages/course-rooms/CourseRoomDetails.page.unit.ts @@ -40,6 +40,7 @@ import { AxiosInstance } from "axios"; import { VBtn } from "vuetify/lib/components/index.mjs"; import CourseRoomDetailsPage from "./CourseRoomDetails.page.vue"; import RoomExternalToolsOverview from "./tools/RoomExternalToolsOverview.vue"; +import { createTestingPinia } from "@pinia/testing"; jest.mock("./tools/RoomExternalToolsOverview.vue"); @@ -181,7 +182,11 @@ const getWrapper = ( return mount(CourseRoomDetailsPage, { global: { - plugins: [createTestingVuetify(), createTestingI18n()], + plugins: [ + createTestingVuetify(), + createTestingI18n(), + createTestingPinia(), + ], mocks: { $router, $route, diff --git a/src/pages/course-rooms/CourseRoomDetails.page.vue b/src/pages/course-rooms/CourseRoomDetails.page.vue index eaa5b56cdd..5b506a6992 100644 --- a/src/pages/course-rooms/CourseRoomDetails.page.vue +++ b/src/pages/course-rooms/CourseRoomDetails.page.vue @@ -157,6 +157,8 @@ import { COURSE_ROOM_DETAILS_MODULE_KEY, SHARE_MODULE_KEY, } from "@/utils/inject"; +import { RoomVariant, useRoomDetailsStore } from "@data-room"; +import { storeToRefs } from "pinia"; export default defineComponent({ setup() { @@ -168,11 +170,14 @@ export default defineComponent({ const { copy, backgroundCopyProcesses, isCopyProcessInBackground } = useCopy(isLoadingDialogOpen); + const { roomVariant } = storeToRefs(useRoomDetailsStore()); + return { mdiPlus, copy, backgroundCopyProcesses, isCopyProcessInBackground, + roomVariant, }; }, components: { @@ -486,6 +491,11 @@ export default defineComponent({ this.courseId = courseId; await this.courseRoomDetailsModule.fetchContent(courseId); + + if (this.roomData.roomId) { + this.roomVariant = RoomVariant.COURSE_ROOM; + } + await this.courseRoomDetailsModule.fetchScopePermission({ courseId, userId: this.authModule.getUser?.id, diff --git a/src/pages/course-rooms/CourseRoomOverview.page.unit.js b/src/pages/course-rooms/CourseRoomOverview.page.unit.js index 6572074799..b5a6fe1065 100644 --- a/src/pages/course-rooms/CourseRoomOverview.page.unit.js +++ b/src/pages/course-rooms/CourseRoomOverview.page.unit.js @@ -200,12 +200,7 @@ describe("@/pages/CourseRoomOverview.page", () => { const expected = [ { id: "1234", - isArchived: undefined, - searchText: "Mathe", - title: "Mathe", - shortTitle: "Ma", - displayColor: "#54616e", - to: "/rooms/1234", + name: "Mathe", }, ]; diff --git a/src/pages/course-rooms/CourseRoomOverview.page.vue b/src/pages/course-rooms/CourseRoomOverview.page.vue index ee6f16e6c3..41c734e2d2 100644 --- a/src/pages/course-rooms/CourseRoomOverview.page.vue +++ b/src/pages/course-rooms/CourseRoomOverview.page.vue @@ -117,7 +117,8 @@ @@ -211,7 +212,12 @@ export default defineComponent({ }); }, courses() { - return courseRoomListModule.getAllElements; + return courseRoomListModule.getAllElements.map((item) => { + return { + id: item.id, + name: item.title, + }; + }); }, hasRoomsBeingCopied() { return this.rooms.some((item) => item.copyingSince !== undefined); diff --git a/src/pages/course-rooms/tools/RoomExternalToolsErrorDialog.unit.ts b/src/pages/course-rooms/tools/RoomExternalToolsErrorDialog.unit.ts index 7e81a7ed2f..6e895d2657 100644 --- a/src/pages/course-rooms/tools/RoomExternalToolsErrorDialog.unit.ts +++ b/src/pages/course-rooms/tools/RoomExternalToolsErrorDialog.unit.ts @@ -58,6 +58,7 @@ describe("RoomExternalToolsErrorDialog", () => { name: "Test Tool", openInNewTab: false, contextExternalToolId: "contextExternalToolId", + isLtiDeepLinkingTool: false, }; }; diff --git a/src/pages/course-rooms/tools/RoomExternalToolsOverview.unit.ts b/src/pages/course-rooms/tools/RoomExternalToolsOverview.unit.ts index 0843ab31e7..b79d48d754 100644 --- a/src/pages/course-rooms/tools/RoomExternalToolsOverview.unit.ts +++ b/src/pages/course-rooms/tools/RoomExternalToolsOverview.unit.ts @@ -240,4 +240,25 @@ describe("RoomExternalToolOverview", () => { ).toHaveBeenCalledWith(displayData.contextExternalToolId); }); }); + + describe("when emit refresh", () => { + const setup = () => { + const { wrapper } = getWrapper(); + + return { + wrapper, + }; + }; + + it("should call tool reference endpoint again", () => { + const { wrapper } = setup(); + + const section = wrapper.findComponent(RoomExternalToolsSection); + section.vm.$emit("refresh"); + + expect( + useExternalToolDisplayListStateMock.fetchDisplayData + ).toHaveBeenCalledTimes(2); + }); + }); }); diff --git a/src/pages/course-rooms/tools/RoomExternalToolsOverview.vue b/src/pages/course-rooms/tools/RoomExternalToolsOverview.vue index c95636ce51..96c7a8e716 100644 --- a/src/pages/course-rooms/tools/RoomExternalToolsOverview.vue +++ b/src/pages/course-rooms/tools/RoomExternalToolsOverview.vue @@ -38,6 +38,7 @@ :room-id="roomId" data-testid="room-external-tool-section" @delete="onDeleteTool" + @refresh="() => fetchDisplayData(props.roomId, ToolContextType.Course)" />
diff --git a/src/pages/course-rooms/tools/RoomExternalToolsSection.vue b/src/pages/course-rooms/tools/RoomExternalToolsSection.vue index 04c26d979f..353ce3d945 100644 --- a/src/pages/course-rooms/tools/RoomExternalToolsSection.vue +++ b/src/pages/course-rooms/tools/RoomExternalToolsSection.vue @@ -10,6 +10,7 @@ @delete="onOpenDeleteDialog" @edit="onEditTool" @error="onError" + @refresh="$emit('refresh')" :data-testid="`external-tool-card-${index}`" /> @@ -77,6 +78,7 @@ const props = defineProps({ const emit = defineEmits<{ (e: "delete", value: ExternalToolDisplayData): void; + (e: "refresh"): void; }>(); const authModule: AuthModule = injectStrict(AUTH_MODULE_KEY); diff --git a/src/plugins/application-error-handler.ts b/src/plugins/application-error-handler.ts index e532476309..7b79454560 100644 --- a/src/plugins/application-error-handler.ts +++ b/src/plugins/application-error-handler.ts @@ -6,7 +6,6 @@ export const handleApplicationError = (err: unknown) => { * Note: The Global-ErrorHandler wraps the error * so we can't use instanceof ApplicationError here. */ - console.error(err); const applicationError = err as ApplicationError; if (applicationError.name === "ApplicationError") { applicationErrorModule.setError({ diff --git a/src/router/routes.ts b/src/router/routes.ts index c4d0f49643..ce89422cf8 100644 --- a/src/router/routes.ts +++ b/src/router/routes.ts @@ -245,30 +245,42 @@ export const routes: Readonly[] = [ { path: `/rooms`, component: async () => (await import("@page-room")).RoomsPage, - beforeEnter: checkRoomsFeature, + beforeEnter: Multiguard([ + checkRoomsFeature, + createPermissionGuard(["room_create"]), + ]), name: "rooms", }, { path: `/rooms/new`, component: async () => (await import("@page-room")).RoomCreatePage, - beforeEnter: checkRoomsFeature, + beforeEnter: Multiguard([ + checkRoomsFeature, + createPermissionGuard(["room_create"]), + ]), name: "rooms-new", }, { - path: `/rooms/:id`, + path: `/rooms/:id(${REGEX_ID})`, component: async () => (await import("@page-room")).RoomDetailsPage, - name: "room-details", + name: "rooms-id", }, { - path: `/rooms/:id/edit`, + path: `/rooms/:id(${REGEX_ID})/edit`, component: async () => (await import("@page-room")).RoomEditPage, - beforeEnter: checkRoomsFeature, + beforeEnter: Multiguard([ + checkRoomsFeature, + createPermissionGuard(["room_create"]), + ]), name: "room-edit", }, { - path: `/rooms/:id/members`, + path: `/rooms/:id(${REGEX_ID})/members`, component: async () => (await import("@page-room")).RoomMembersPage, - beforeEnter: checkRoomsFeature, + beforeEnter: Multiguard([ + checkRoomsFeature, + createPermissionGuard(["room_create"]), + ]), name: "room-members", }, // TODO BC-7877 This redirect should be removed. Currently this route is used by the legacy client (and dof_app_deploy). @@ -293,11 +305,6 @@ export const routes: Readonly[] = [ component: () => import("@/pages/course-rooms/CourseRoomOverview.page.vue"), name: "course-room-overview", }, - { - path: `/rooms/:id(${REGEX_ID})`, - component: async () => (await import("@page-room")).RoomDetailsPage, - name: "rooms-id", - }, { path: `/rooms/:id(${REGEX_ID})/board`, redirect: { name: "boards-id" }, diff --git a/src/serverApi/v3/api.ts b/src/serverApi/v3/api.ts index f5aeb4199f..2c18a19e03 100644 --- a/src/serverApi/v3/api.ts +++ b/src/serverApi/v3/api.ts @@ -357,8 +357,10 @@ export enum AuthorizationContextParamsRequiredPermissionsEnum { RoleCreate = 'ROLE_CREATE', RoleEdit = 'ROLE_EDIT', RoleView = 'ROLE_VIEW', + RoomCreate = 'ROOM_CREATE', RoomEdit = 'ROOM_EDIT', RoomView = 'ROOM_VIEW', + RoomDelete = 'ROOM_DELETE', SchoolChatManage = 'SCHOOL_CHAT_MANAGE', SchoolCreate = 'SCHOOL_CREATE', SchoolEdit = 'SCHOOL_EDIT', @@ -2014,12 +2016,6 @@ export interface ContextExternalToolResponse { * @memberof ContextExternalToolResponse */ parameters: Array; - /** - * - * @type {string} - * @memberof ContextExternalToolResponse - */ - logoUrl?: string; } /** @@ -2070,11 +2066,11 @@ export interface CopyApiResponse { */ type: CopyApiResponseTypeEnum; /** - * Id of destination course + * Id of destination parent reference * @type {string} * @memberof CopyApiResponse */ - destinationCourseId?: string; + destinationId?: string; /** * Copy progress status of copied element * @type {string} @@ -4102,6 +4098,18 @@ export enum LanguageType { Uk = 'uk' } +/** + * + * @export + * @enum {string} + */ +export enum LaunchType { + Basic = 'basic', + Oauth2 = 'oauth2', + Lti11BasicLaunch = 'lti11BasicLaunch', + Lti11ContentItemSelection = 'lti11ContentItemSelection' +} + /** * * @export @@ -4625,6 +4633,171 @@ export interface LoginResponse { */ accessToken: string; } +/** + * + * @export + * @interface Lti11DeepLinkContentItemListParams + */ +export interface Lti11DeepLinkContentItemListParams { + /** + * + * @type {string} + * @memberof Lti11DeepLinkContentItemListParams + */ + context: string; + /** + * + * @type {Array} + * @memberof Lti11DeepLinkContentItemListParams + */ + graph: Array; +} +/** + * + * @export + * @interface Lti11DeepLinkParams + */ +export interface Lti11DeepLinkParams { + /** + * + * @type {string} + * @memberof Lti11DeepLinkParams + */ + lti_message_type: string; + /** + * + * @type {string} + * @memberof Lti11DeepLinkParams + */ + lti_version: string; + /** + * + * @type {Lti11DeepLinkContentItemListParams} + * @memberof Lti11DeepLinkParams + */ + content_items?: Lti11DeepLinkContentItemListParams; + /** + * + * @type {string} + * @memberof Lti11DeepLinkParams + */ + data: string; + /** + * + * @type {string} + * @memberof Lti11DeepLinkParams + */ + oauth_version: string; + /** + * + * @type {string} + * @memberof Lti11DeepLinkParams + */ + oauth_nonce: string; + /** + * + * @type {number} + * @memberof Lti11DeepLinkParams + */ + oauth_timestamp: number; + /** + * + * @type {string} + * @memberof Lti11DeepLinkParams + */ + oauth_signature_method: string; + /** + * + * @type {string} + * @memberof Lti11DeepLinkParams + */ + oauth_consumer_key: string; + /** + * + * @type {string} + * @memberof Lti11DeepLinkParams + */ + oauth_signature: string; + /** + * + * @type {string} + * @memberof Lti11DeepLinkParams + */ + oauth_callback?: string; +} +/** + * + * @export + * @interface Lti11DeepLinkParamsRaw + */ +export interface Lti11DeepLinkParamsRaw { + /** + * + * @type {string} + * @memberof Lti11DeepLinkParamsRaw + */ + lti_message_type: string; + /** + * + * @type {string} + * @memberof Lti11DeepLinkParamsRaw + */ + lti_version: string; + /** + * + * @type {string} + * @memberof Lti11DeepLinkParamsRaw + */ + content_items?: string; + /** + * + * @type {string} + * @memberof Lti11DeepLinkParamsRaw + */ + data: string; + /** + * + * @type {string} + * @memberof Lti11DeepLinkParamsRaw + */ + oauth_version: string; + /** + * + * @type {string} + * @memberof Lti11DeepLinkParamsRaw + */ + oauth_nonce: string; + /** + * + * @type {number} + * @memberof Lti11DeepLinkParamsRaw + */ + oauth_timestamp: number; + /** + * + * @type {string} + * @memberof Lti11DeepLinkParamsRaw + */ + oauth_signature_method: string; + /** + * + * @type {string} + * @memberof Lti11DeepLinkParamsRaw + */ + oauth_consumer_key: string; + /** + * + * @type {string} + * @memberof Lti11DeepLinkParamsRaw + */ + oauth_signature: string; + /** + * + * @type {string} + * @memberof Lti11DeepLinkParamsRaw + */ + oauth_callback?: string; +} /** * * @export @@ -4723,6 +4896,55 @@ export interface Lti11ToolConfigUpdateParams { */ launch_presentation_locale: string; } +/** + * + * @export + * @interface LtiDeepLinkResponse + */ +export interface LtiDeepLinkResponse { + /** + * + * @type {string} + * @memberof LtiDeepLinkResponse + */ + mediaType: string; + /** + * + * @type {string} + * @memberof LtiDeepLinkResponse + */ + title?: string; + /** + * + * @type {string} + * @memberof LtiDeepLinkResponse + */ + text?: string; + /** + * + * @type {string} + * @memberof LtiDeepLinkResponse + */ + availableFrom?: string; + /** + * + * @type {string} + * @memberof LtiDeepLinkResponse + */ + availableUntil?: string; + /** + * + * @type {string} + * @memberof LtiDeepLinkResponse + */ + submissionFrom?: string; + /** + * + * @type {string} + * @memberof LtiDeepLinkResponse + */ + submissionUntil?: string; +} /** * * @export @@ -6554,6 +6776,180 @@ export interface PeriodResponse { */ until: string; } +/** + * + * @export + * @enum {string} + */ +export enum Permission { + AccountCreate = 'ACCOUNT_CREATE', + AccountDelete = 'ACCOUNT_DELETE', + AccountEdit = 'ACCOUNT_EDIT', + AccountView = 'ACCOUNT_VIEW', + AddSchoolMembers = 'ADD_SCHOOL_MEMBERS', + AdminEdit = 'ADMIN_EDIT', + AdminView = 'ADMIN_VIEW', + BaseView = 'BASE_VIEW', + CalendarCreate = 'CALENDAR_CREATE', + CalendarEdit = 'CALENDAR_EDIT', + CalendarView = 'CALENDAR_VIEW', + ChangeTeamRoles = 'CHANGE_TEAM_ROLES', + ClassCreate = 'CLASS_CREATE', + ClassEdit = 'CLASS_EDIT', + ClassFullAdmin = 'CLASS_FULL_ADMIN', + ClassList = 'CLASS_LIST', + ClassRemove = 'CLASS_REMOVE', + ClassView = 'CLASS_VIEW', + CommentsCreate = 'COMMENTS_CREATE', + CommentsEdit = 'COMMENTS_EDIT', + CommentsView = 'COMMENTS_VIEW', + ContentNonOerView = 'CONTENT_NON_OER_VIEW', + ContentView = 'CONTENT_VIEW', + ContextToolAdmin = 'CONTEXT_TOOL_ADMIN', + ContextToolUser = 'CONTEXT_TOOL_USER', + CoursegroupCreate = 'COURSEGROUP_CREATE', + CoursegroupEdit = 'COURSEGROUP_EDIT', + CourseAdministration = 'COURSE_ADMINISTRATION', + CourseCreate = 'COURSE_CREATE', + CourseDelete = 'COURSE_DELETE', + CourseEdit = 'COURSE_EDIT', + CourseRemove = 'COURSE_REMOVE', + CourseView = 'COURSE_VIEW', + CreateSupportJwt = 'CREATE_SUPPORT_JWT', + CreateTopicsAndTasks = 'CREATE_TOPICS_AND_TASKS', + DashboardView = 'DASHBOARD_VIEW', + DatasourcesCreate = 'DATASOURCES_CREATE', + DatasourcesDelete = 'DATASOURCES_DELETE', + DatasourcesEdit = 'DATASOURCES_EDIT', + DatasourcesRun = 'DATASOURCES_RUN', + DatasourcesRunView = 'DATASOURCES_RUN_VIEW', + DatasourcesView = 'DATASOURCES_VIEW', + DefaultFilePermissions = 'DEFAULT_FILE_PERMISSIONS', + DeleteTeam = 'DELETE_TEAM', + EditAllFiles = 'EDIT_ALL_FILES', + EnterthecloudStart = 'ENTERTHECLOUD_START', + FederalstateCreate = 'FEDERALSTATE_CREATE', + FederalstateEdit = 'FEDERALSTATE_EDIT', + FederalstateView = 'FEDERALSTATE_VIEW', + FilestorageCreate = 'FILESTORAGE_CREATE', + FilestorageEdit = 'FILESTORAGE_EDIT', + FilestorageRemove = 'FILESTORAGE_REMOVE', + FilestorageView = 'FILESTORAGE_VIEW', + FileCreate = 'FILE_CREATE', + FileDelete = 'FILE_DELETE', + FileMove = 'FILE_MOVE', + FolderCreate = 'FOLDER_CREATE', + FolderDelete = 'FOLDER_DELETE', + GroupList = 'GROUP_LIST', + GroupFullAdmin = 'GROUP_FULL_ADMIN', + GroupView = 'GROUP_VIEW', + HelpdeskCreate = 'HELPDESK_CREATE', + HelpdeskEdit = 'HELPDESK_EDIT', + HelpdeskView = 'HELPDESK_VIEW', + HomeworkCreate = 'HOMEWORK_CREATE', + HomeworkEdit = 'HOMEWORK_EDIT', + HomeworkView = 'HOMEWORK_VIEW', + ImportUserMigrate = 'IMPORT_USER_MIGRATE', + ImportUserUpdate = 'IMPORT_USER_UPDATE', + ImportUserView = 'IMPORT_USER_VIEW', + InstanceView = 'INSTANCE_VIEW', + InviteAdministrators = 'INVITE_ADMINISTRATORS', + InviteExperts = 'INVITE_EXPERTS', + JoinMeeting = 'JOIN_MEETING', + LeaveTeam = 'LEAVE_TEAM', + LernstoreView = 'LERNSTORE_VIEW', + LessonsCreate = 'LESSONS_CREATE', + LessonsView = 'LESSONS_VIEW', + LinkCreate = 'LINK_CREATE', + NewsCreate = 'NEWS_CREATE', + NewsEdit = 'NEWS_EDIT', + NewsView = 'NEWS_VIEW', + NextcloudUser = 'NEXTCLOUD_USER', + NotificationCreate = 'NOTIFICATION_CREATE', + NotificationEdit = 'NOTIFICATION_EDIT', + NotificationView = 'NOTIFICATION_VIEW', + OauthClientEdit = 'OAUTH_CLIENT_EDIT', + OauthClientView = 'OAUTH_CLIENT_VIEW', + PasswordEdit = 'PASSWORD_EDIT', + PwrecoveryCreate = 'PWRECOVERY_CREATE', + PwrecoveryEdit = 'PWRECOVERY_EDIT', + PwrecoveryView = 'PWRECOVERY_VIEW', + ReleasesCreate = 'RELEASES_CREATE', + ReleasesEdit = 'RELEASES_EDIT', + ReleasesView = 'RELEASES_VIEW', + RemoveMembers = 'REMOVE_MEMBERS', + RenameTeam = 'RENAME_TEAM', + RequestConsents = 'REQUEST_CONSENTS', + RoleCreate = 'ROLE_CREATE', + RoleEdit = 'ROLE_EDIT', + RoleView = 'ROLE_VIEW', + RoomCreate = 'ROOM_CREATE', + RoomEdit = 'ROOM_EDIT', + RoomView = 'ROOM_VIEW', + RoomDelete = 'ROOM_DELETE', + SchoolChatManage = 'SCHOOL_CHAT_MANAGE', + SchoolCreate = 'SCHOOL_CREATE', + SchoolEdit = 'SCHOOL_EDIT', + SchoolEditAll = 'SCHOOL_EDIT_ALL', + SchoolLogoManage = 'SCHOOL_LOGO_MANAGE', + SchoolNewsEdit = 'SCHOOL_NEWS_EDIT', + SchoolPermissionChange = 'SCHOOL_PERMISSION_CHANGE', + SchoolPermissionView = 'SCHOOL_PERMISSION_VIEW', + SchoolStudentTeamManage = 'SCHOOL_STUDENT_TEAM_MANAGE', + SchoolSystemEdit = 'SCHOOL_SYSTEM_EDIT', + SchoolSystemView = 'SCHOOL_SYSTEM_VIEW', + SchoolToolAdmin = 'SCHOOL_TOOL_ADMIN', + ScopePermissionsView = 'SCOPE_PERMISSIONS_VIEW', + StartMeeting = 'START_MEETING', + StudentCreate = 'STUDENT_CREATE', + StudentDelete = 'STUDENT_DELETE', + StudentEdit = 'STUDENT_EDIT', + StudentList = 'STUDENT_LIST', + StudentSkipRegistration = 'STUDENT_SKIP_REGISTRATION', + SubmissionsCreate = 'SUBMISSIONS_CREATE', + SubmissionsEdit = 'SUBMISSIONS_EDIT', + SubmissionsSchoolView = 'SUBMISSIONS_SCHOOL_VIEW', + SubmissionsView = 'SUBMISSIONS_VIEW', + SyncStart = 'SYNC_START', + SystemCreate = 'SYSTEM_CREATE', + SystemEdit = 'SYSTEM_EDIT', + SystemView = 'SYSTEM_VIEW', + TaskDashboardTeacherViewV3 = 'TASK_DASHBOARD_TEACHER_VIEW_V3', + TaskDashboardViewV3 = 'TASK_DASHBOARD_VIEW_V3', + TeacherCreate = 'TEACHER_CREATE', + TeacherDelete = 'TEACHER_DELETE', + TeacherEdit = 'TEACHER_EDIT', + TeacherList = 'TEACHER_LIST', + TeacherSkipRegistration = 'TEACHER_SKIP_REGISTRATION', + TeamCreate = 'TEAM_CREATE', + ToolCreateEtherpad = 'TOOL_CREATE_ETHERPAD', + TeamEdit = 'TEAM_EDIT', + TeamInviteExternal = 'TEAM_INVITE_EXTERNAL', + TeamView = 'TEAM_VIEW', + ToolAdmin = 'TOOL_ADMIN', + ToolCreate = 'TOOL_CREATE', + ToolEdit = 'TOOL_EDIT', + ToolNewView = 'TOOL_NEW_VIEW', + ToolView = 'TOOL_VIEW', + TopicCreate = 'TOPIC_CREATE', + TopicEdit = 'TOPIC_EDIT', + TopicView = 'TOPIC_VIEW', + UploadFiles = 'UPLOAD_FILES', + UseLibreoffice = 'USE_LIBREOFFICE', + UseRocketchat = 'USE_ROCKETCHAT', + UsergroupCreate = 'USERGROUP_CREATE', + UsergroupEdit = 'USERGROUP_EDIT', + UsergroupView = 'USERGROUP_VIEW', + UserChangeOwnName = 'USER_CHANGE_OWN_NAME', + UserCreate = 'USER_CREATE', + UserLoginMigrationAdmin = 'USER_LOGIN_MIGRATION_ADMIN', + UserLoginMigrationRollback = 'USER_LOGIN_MIGRATION_ROLLBACK', + UserLoginMigrationForce = 'USER_LOGIN_MIGRATION_FORCE', + UserMigrate = 'USER_MIGRATE', + UserUpdate = 'USER_UPDATE', + YearsEdit = 'YEARS_EDIT' +} + /** * * @export @@ -6909,9 +7305,11 @@ export enum RoleName { DemoStudent = 'demoStudent', DemoTeacher = 'demoTeacher', Expert = 'expert', + GuestTeacher = 'guestTeacher', + GuestStudent = 'guestStudent', Helpdesk = 'helpdesk', - RoomViewer = 'room_viewer', - RoomEditor = 'room_editor', + Roomviewer = 'roomviewer', + Roomeditor = 'roomeditor', Student = 'student', Superhero = 'superhero', Teacher = 'teacher', @@ -7041,6 +7439,12 @@ export interface RoomDetailsResponse { * @memberof RoomDetailsResponse */ color: RoomColor; + /** + * + * @type {string} + * @memberof RoomDetailsResponse + */ + schoolId: string; /** * * @type {string} @@ -7065,6 +7469,12 @@ export interface RoomDetailsResponse { * @memberof RoomDetailsResponse */ updatedAt: string; + /** + * + * @type {Array} + * @memberof RoomDetailsResponse + */ + permissions: Array; } /** * @@ -7090,6 +7500,12 @@ export interface RoomItemResponse { * @memberof RoomItemResponse */ color: RoomColor; + /** + * + * @type {string} + * @memberof RoomItemResponse + */ + schoolId: string; /** * * @type {string} @@ -8008,11 +8424,11 @@ export interface ShareTokenImportBodyParams { */ newName: string; /** - * Id of the course to which the lesson/task will be added + * Id of the parent to which the imported object will be added. * @type {string} * @memberof ShareTokenImportBodyParams */ - destinationCourseId?: string | null; + destinationId?: string | null; } /** * @@ -8767,11 +9183,11 @@ export interface ToolLaunchRequestResponse { */ openNewTab?: boolean; /** - * Specifies whether the request is an LTI Deep linking content item selection request - * @type {boolean} + * + * @type {LaunchType} * @memberof ToolLaunchRequestResponse */ - isDeepLink: boolean; + launchType: LaunchType; } /** @@ -8844,6 +9260,18 @@ export interface ToolReferenceResponse { * @memberof ToolReferenceResponse */ status: ContextExternalToolConfigurationStatusResponse; + /** + * Whether the tool is a lti deep linking tool + * @type {boolean} + * @memberof ToolReferenceResponse + */ + isLtiDeepLinkingTool: boolean; + /** + * + * @type {LtiDeepLinkResponse} + * @memberof ToolReferenceResponse + */ + ltiDeepLink?: LtiDeepLinkResponse; } /** * @@ -9053,8 +9481,8 @@ export interface UserIdAndRole { * @enum {string} */ export enum UserIdAndRoleRoleNameEnum { - Editor = 'room_editor', - Viewer = 'room_viewer' + Roomeditor = 'roomeditor', + Roomviewer = 'roomviewer' } /** @@ -20508,7 +20936,7 @@ export const RoomApiAxiosParamCreator = function (configuration?: Configuration) }, /** * - * @summary Create a new room + * @summary Update an existing room * @param {string} roomId * @param {UpdateRoomBodyParams} updateRoomBodyParams * @param {*} [options] Override http request option. @@ -20528,7 +20956,7 @@ export const RoomApiAxiosParamCreator = function (configuration?: Configuration) baseOptions = configuration.baseOptions; } - const localVarRequestOptions = { method: 'PATCH', ...baseOptions, ...options}; + const localVarRequestOptions = { method: 'PUT', ...baseOptions, ...options}; const localVarHeaderParameter = {} as any; const localVarQueryParameter = {} as any; @@ -20653,7 +21081,7 @@ export const RoomApiFp = function(configuration?: Configuration) { }, /** * - * @summary Create a new room + * @summary Update an existing room * @param {string} roomId * @param {UpdateRoomBodyParams} updateRoomBodyParams * @param {*} [options] Override http request option. @@ -20758,7 +21186,7 @@ export const RoomApiFactory = function (configuration?: Configuration, basePath? }, /** * - * @summary Create a new room + * @summary Update an existing room * @param {string} roomId * @param {UpdateRoomBodyParams} updateRoomBodyParams * @param {*} [options] Override http request option. @@ -20861,7 +21289,7 @@ export interface RoomApiInterface { /** * - * @summary Create a new room + * @summary Update an existing room * @param {string} roomId * @param {UpdateRoomBodyParams} updateRoomBodyParams * @param {*} [options] Override http request option. @@ -20980,7 +21408,7 @@ export class RoomApi extends BaseAPI implements RoomApiInterface { /** * - * @summary Create a new room + * @summary Update an existing room * @param {string} roomId * @param {UpdateRoomBodyParams} updateRoomBodyParams * @param {*} [options] Override http request option. @@ -24387,6 +24815,45 @@ export const ToolApiAxiosParamCreator = function (configuration?: Configuration) options: localVarRequestOptions, }; }, + /** + * + * @param {string} contextExternalToolId + * @param {Lti11DeepLinkParamsRaw} lti11DeepLinkParamsRaw + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + toolDeepLinkControllerDeepLink: async (contextExternalToolId: string, lti11DeepLinkParamsRaw: Lti11DeepLinkParamsRaw, options: any = {}): Promise => { + // verify required parameter 'contextExternalToolId' is not null or undefined + assertParamExists('toolDeepLinkControllerDeepLink', 'contextExternalToolId', contextExternalToolId) + // verify required parameter 'lti11DeepLinkParamsRaw' is not null or undefined + assertParamExists('toolDeepLinkControllerDeepLink', 'lti11DeepLinkParamsRaw', lti11DeepLinkParamsRaw) + const localVarPath = `/tools/context-external-tools/{contextExternalToolId}/lti11-deep-link-callback` + .replace(`{${"contextExternalToolId"}}`, encodeURIComponent(String(contextExternalToolId))); + // use dummy base URL string because the URL constructor only accepts absolute URLs. + const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL); + let baseOptions; + if (configuration) { + baseOptions = configuration.baseOptions; + } + + const localVarRequestOptions = { method: 'POST', ...baseOptions, ...options}; + const localVarHeaderParameter = {} as any; + const localVarQueryParameter = {} as any; + + + + localVarHeaderParameter['Content-Type'] = 'application/json'; + + setSearchParams(localVarUrlObj, localVarQueryParameter, options.query); + let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {}; + localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers}; + localVarRequestOptions.data = serializeDataIfNeeded(lti11DeepLinkParamsRaw, localVarRequestOptions, configuration) + + return { + url: toPathString(localVarUrlObj), + options: localVarRequestOptions, + }; + }, /** * * @summary Get tool launch request for a context external tool id @@ -25026,6 +25493,17 @@ export const ToolApiFp = function(configuration?: Configuration) { const localVarAxiosArgs = await localVarAxiosParamCreator.toolControllerUpdateExternalTool(externalToolId, externalToolUpdateParams, options); return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration); }, + /** + * + * @param {string} contextExternalToolId + * @param {Lti11DeepLinkParamsRaw} lti11DeepLinkParamsRaw + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + async toolDeepLinkControllerDeepLink(contextExternalToolId: string, lti11DeepLinkParamsRaw: Lti11DeepLinkParamsRaw, options?: any): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise> { + const localVarAxiosArgs = await localVarAxiosParamCreator.toolDeepLinkControllerDeepLink(contextExternalToolId, lti11DeepLinkParamsRaw, options); + return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration); + }, /** * * @summary Get tool launch request for a context external tool id @@ -25357,6 +25835,16 @@ export const ToolApiFactory = function (configuration?: Configuration, basePath? toolControllerUpdateExternalTool(externalToolId: string, externalToolUpdateParams: ExternalToolUpdateParams, options?: any): AxiosPromise { return localVarFp.toolControllerUpdateExternalTool(externalToolId, externalToolUpdateParams, options).then((request) => request(axios, basePath)); }, + /** + * + * @param {string} contextExternalToolId + * @param {Lti11DeepLinkParamsRaw} lti11DeepLinkParamsRaw + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + toolDeepLinkControllerDeepLink(contextExternalToolId: string, lti11DeepLinkParamsRaw: Lti11DeepLinkParamsRaw, options?: any): AxiosPromise { + return localVarFp.toolDeepLinkControllerDeepLink(contextExternalToolId, lti11DeepLinkParamsRaw, options).then((request) => request(axios, basePath)); + }, /** * * @summary Get tool launch request for a context external tool id @@ -25677,6 +26165,16 @@ export interface ToolApiInterface { */ toolControllerUpdateExternalTool(externalToolId: string, externalToolUpdateParams: ExternalToolUpdateParams, options?: any): AxiosPromise; + /** + * + * @param {string} contextExternalToolId + * @param {Lti11DeepLinkParamsRaw} lti11DeepLinkParamsRaw + * @param {*} [options] Override http request option. + * @throws {RequiredError} + * @memberof ToolApiInterface + */ + toolDeepLinkControllerDeepLink(contextExternalToolId: string, lti11DeepLinkParamsRaw: Lti11DeepLinkParamsRaw, options?: any): AxiosPromise; + /** * * @summary Get tool launch request for a context external tool id @@ -26037,6 +26535,18 @@ export class ToolApi extends BaseAPI implements ToolApiInterface { return ToolApiFp(this.configuration).toolControllerUpdateExternalTool(externalToolId, externalToolUpdateParams, options).then((request) => request(this.axios, this.basePath)); } + /** + * + * @param {string} contextExternalToolId + * @param {Lti11DeepLinkParamsRaw} lti11DeepLinkParamsRaw + * @param {*} [options] Override http request option. + * @throws {RequiredError} + * @memberof ToolApi + */ + public toolDeepLinkControllerDeepLink(contextExternalToolId: string, lti11DeepLinkParamsRaw: Lti11DeepLinkParamsRaw, options?: any) { + return ToolApiFp(this.configuration).toolDeepLinkControllerDeepLink(contextExternalToolId, lti11DeepLinkParamsRaw, options).then((request) => request(this.axios, this.basePath)); + } + /** * * @summary Get tool launch request for a context external tool id diff --git a/src/store/copy.ts b/src/store/copy.ts index f1bf4f2943..3bbb9a45fc 100644 --- a/src/store/copy.ts +++ b/src/store/copy.ts @@ -11,8 +11,8 @@ import { CourseRoomsApiInterface, ShareTokenApiFactory, ShareTokenApiInterface, - ShareTokenBodyParamsParentTypeEnum, ShareTokenInfoResponse, + ShareTokenInfoResponseParentTypeEnum, TaskApiFactory, TaskApiInterface, } from "../serverApi/v3/api"; @@ -32,10 +32,10 @@ export enum CopyParamsTypeEnum { } interface CopyByShareTokenPayload { - type: ShareTokenBodyParamsParentTypeEnum; + type: ShareTokenInfoResponseParentTypeEnum; token: string; newName: string; - destinationCourseId?: string; + destinationId?: string; } @Module({ @@ -127,12 +127,9 @@ export default class CopyModule extends VuexModule { } @Action({ rawError: true }) - async validateShareToken( - token: string - ): Promise { + async validateShareToken(token: string): Promise { const shareTokenResponse = await this.shareApi.shareTokenControllerLookupShareToken(token); - if (!shareTokenResponse) return undefined; return shareTokenResponse.data; } @@ -141,24 +138,24 @@ export default class CopyModule extends VuexModule { token, type, newName, - destinationCourseId, + destinationId, }: CopyByShareTokenPayload): Promise { let copyResult: CopyApiResponse | undefined = undefined; - if (type === ShareTokenBodyParamsParentTypeEnum.Courses) { + if (type === ShareTokenInfoResponseParentTypeEnum.Courses) { copyResult = await this.shareApi .shareTokenControllerImportShareToken(token, { newName }) .then((response) => response.data); } if ( - type === ShareTokenBodyParamsParentTypeEnum.ColumnBoard || - type === ShareTokenBodyParamsParentTypeEnum.Lessons || - type === ShareTokenBodyParamsParentTypeEnum.Tasks + type === ShareTokenInfoResponseParentTypeEnum.ColumnBoard || + type === ShareTokenInfoResponseParentTypeEnum.Lessons || + type === ShareTokenInfoResponseParentTypeEnum.Tasks ) { copyResult = await this.shareApi .shareTokenControllerImportShareToken(token, { newName, - destinationCourseId, + destinationId, }) .then((response) => response.data); } @@ -219,15 +216,16 @@ export default class CopyModule extends VuexModule { if (type === CopyApiResponseTypeEnum.DrawingElement) return true; if (type === CopyApiResponseTypeEnum.CollaborativeTextEditorElement) return true; + if (type === CopyApiResponseTypeEnum.ExternalToolElement) return true; return false; }; const getUrl = (element: CopyApiResponse): string | undefined => { switch (element.type) { case CopyApiResponseTypeEnum.Task: - return `/homework/${element.id}/edit?returnUrl=rooms/${element.destinationCourseId}`; + return `/homework/${element.id}/edit?returnUrl=rooms/${element.destinationId}`; case CopyApiResponseTypeEnum.Lesson: - return `/courses/${element.destinationCourseId}/topics/${element.id}/edit?returnUrl=rooms/${element.destinationCourseId}`; + return `/courses/${element.destinationId}/topics/${element.id}/edit?returnUrl=rooms/${element.destinationId}`; case CopyApiResponseTypeEnum.Course: return `/courses/${element.id}/edit`; case CopyApiResponseTypeEnum.Columnboard: diff --git a/src/store/copy.unit.ts b/src/store/copy.unit.ts index b6db90cce0..259a975118 100644 --- a/src/store/copy.unit.ts +++ b/src/store/copy.unit.ts @@ -30,7 +30,7 @@ const serverDataPartial: CopyApiResponse = { const serverDataSuccess: CopyApiResponse = { title: "Thema X", type: CopyApiResponseTypeEnum.Lesson, - destinationCourseId: "aCourseId", + destinationId: "aCourseId", status: CopyApiResponseStatusEnum.Success, id: "123", elements: [ @@ -237,7 +237,7 @@ describe("copy module", () => { const newName = "My Course"; const payload = { token, - type: serverApi.ShareTokenBodyParamsParentTypeEnum.Courses, + type: serverApi.ShareTokenInfoResponseParentTypeEnum.Courses, newName, }; @@ -261,7 +261,7 @@ describe("copy module", () => { const copyModule = new CopyModule({}); const payload = { token: "abc123a", - type: serverApi.ShareTokenBodyParamsParentTypeEnum.Courses, + type: serverApi.ShareTokenInfoResponseParentTypeEnum.Courses, newName: "My Course", }; @@ -289,7 +289,7 @@ describe("copy module", () => { const newName = "My Lesson"; const payload = { token, - type: serverApi.ShareTokenBodyParamsParentTypeEnum.Lessons, + type: serverApi.ShareTokenInfoResponseParentTypeEnum.Lessons, newName, }; @@ -313,7 +313,7 @@ describe("copy module", () => { const copyModule = new CopyModule({}); const payload = { token: "abc123a", - type: serverApi.ShareTokenBodyParamsParentTypeEnum.Lessons, + type: serverApi.ShareTokenInfoResponseParentTypeEnum.Lessons, newName: "My Lesson", }; @@ -347,7 +347,7 @@ describe("copy module", () => { const serverData = { title: "Aufgabe", type: CopyApiResponseTypeEnum.Task, - destinationCourseId: "testCourseId", + destinationId: "testCourseId", status: CopyApiResponseStatusEnum.Failure, id: "123", elements: [ @@ -384,7 +384,7 @@ describe("copy module", () => { const serverData = { title: "ColumnBoard", type: CopyApiResponseTypeEnum.Columnboard, - destinationCourseId: "testCourseId", + destinationId: "testCourseId", status: CopyApiResponseStatusEnum.Failure, id: "123", elements: [ @@ -423,7 +423,7 @@ describe("copy module", () => { const serverData = { title: "Thema", type: CopyApiResponseTypeEnum.Lesson, - destinationCourseId: "testCourseIdX", + destinationId: "testCourseIdX", status: CopyApiResponseStatusEnum.Failure, id: "456", elements: [ diff --git a/src/store/course-room-details.ts b/src/store/course-room-details.ts index 137111dfc4..6f24664ecc 100644 --- a/src/store/course-room-details.ts +++ b/src/store/course-room-details.ts @@ -37,7 +37,7 @@ export default class CourseRoomDetailsModule extends VuexModule { }; scopePermissions: string[] = []; loading = false; - error: null | object = null; + error: unknown = null; businessError: BusinessError = { statusCode: "", message: "", @@ -63,7 +63,7 @@ export default class CourseRoomDetailsModule extends VuexModule { this.setLoading(false); return data; - } catch (error: any) { + } catch (error: unknown) { this.setError(error); this.setLoading(false); @@ -80,7 +80,7 @@ export default class CourseRoomDetailsModule extends VuexModule { await this.roomsApi.courseRoomsControllerGetRoomBoard(id); this.setRoomData(data); this.setLoading(false); - } catch (error: any) { + } catch (error: unknown) { this.setError(error); this.setLoading(false); } @@ -104,7 +104,7 @@ export default class CourseRoomDetailsModule extends VuexModule { await this.fetchContent(this.roomData.roomId); this.setLoading(false); - } catch (error: any) { + } catch (error: unknown) { this.setError(error); this.setLoading(false); } @@ -258,8 +258,13 @@ export default class CourseRoomDetailsModule extends VuexModule { userId: string; }): Promise { const requestUrl = `/v3/courses/${payload.courseId}/user-permissions`; - const ret_val = (await $axios.get(requestUrl)).data; - this.setPermissionData(ret_val[payload.userId]); + try { + const ret_val = (await $axios.get(requestUrl)).data; + this.setPermissionData(ret_val[payload.userId]); + } catch (error: unknown) { + this.setError(error); + this.setLoading(false); + } } @Mutation @@ -278,7 +283,7 @@ export default class CourseRoomDetailsModule extends VuexModule { } @Mutation - setError(error: object | null): void { + setError(error: unknown): void { this.error = error; const handledApplicationErrors: Array = [ HttpStatusCode.BadRequest, @@ -323,7 +328,7 @@ export default class CourseRoomDetailsModule extends VuexModule { return this.loading; } - get getError(): object | null { + get getError(): unknown { return this.error; } diff --git a/src/store/env-config.ts b/src/store/env-config.ts index 6cc22aef3e..9afb2b85f4 100644 --- a/src/store/env-config.ts +++ b/src/store/env-config.ts @@ -225,7 +225,6 @@ export default class EnvConfigModule extends VuexModule { HttpStatusCode.GatewayTimeout ); applicationErrorModule.setError(applicationError); - console.error(`Configuration could not be loaded from the server`, error); this.setStatus("error"); } diff --git a/src/store/external-tool/mapper/external-tool.mapper.ts b/src/store/external-tool/mapper/external-tool.mapper.ts index 88b80e81a0..771cc0620a 100644 --- a/src/store/external-tool/mapper/external-tool.mapper.ts +++ b/src/store/external-tool/mapper/external-tool.mapper.ts @@ -58,6 +58,8 @@ export class ExternalToolMapper { logoUrl: response.logoUrl, thumbnailUrl: response.thumbnailUrl, openInNewTab: response.openInNewTab, + isLtiDeepLinkingTool: response.isLtiDeepLinkingTool, + ltiDeepLink: response.ltiDeepLink, }; return mapped; @@ -71,7 +73,7 @@ export class ExternalToolMapper { url: response.url, payload: response.payload, openNewTab: response.openNewTab, - isDeepLink: response.isDeepLink, + launchType: response.launchType, }; return mapped; diff --git a/src/store/external-tool/tool-launch-request.ts b/src/store/external-tool/tool-launch-request.ts index b5deddb91c..f0f3d5d1d7 100644 --- a/src/store/external-tool/tool-launch-request.ts +++ b/src/store/external-tool/tool-launch-request.ts @@ -1,3 +1,4 @@ +import { LaunchType } from "@/serverApi/v3"; import { ToolLaunchRequestMethodEnum } from "@/store/external-tool/tool-launch-request-method.enum"; export type ToolLaunchRequest = { @@ -5,5 +6,5 @@ export type ToolLaunchRequest = { url: string; payload?: string; openNewTab?: boolean; - isDeepLink: boolean; + launchType: LaunchType; }; diff --git a/src/store/share.ts b/src/store/share.ts index 2a1f772bc2..23f70adb20 100644 --- a/src/store/share.ts +++ b/src/store/share.ts @@ -1,6 +1,7 @@ import { $axios } from "@/utils/api"; import { Action, Module, Mutation, VuexModule } from "vuex-module-decorators"; import { + BoardExternalReferenceType, ShareTokenApiFactory, ShareTokenApiInterface, ShareTokenBodyParams, @@ -20,6 +21,7 @@ export interface SharePayload extends ShareOptions { export interface StartFlow { id: string; type: ShareTokenBodyParamsParentTypeEnum; + destinationType?: BoardExternalReferenceType; } @Module({ @@ -32,6 +34,8 @@ export default class ShareModule extends VuexModule { private parentId = ""; private shareUrl: string | undefined = undefined; private parentType = ShareTokenBodyParamsParentTypeEnum.Courses; + private destinationType: BoardExternalReferenceType = + BoardExternalReferenceType.Course; private get shareApi(): ShareTokenApiInterface { return ShareTokenApiFactory(undefined, "v3", $axios); @@ -53,7 +57,11 @@ export default class ShareModule extends VuexModule { shareTokenPayload ); if (!shareTokenResult) return undefined; - const shareUrl = `${window.location.origin}/rooms/courses-overview?import=${shareTokenResult.data.token}`; + const sharePath = + this.destinationType === BoardExternalReferenceType.Course + ? "rooms/courses-overview" + : "rooms"; + const shareUrl = `${window.location.origin}/${sharePath}?import=${shareTokenResult.data.token}`; this.setShareUrl(shareUrl); return shareTokenResult.data; } catch { @@ -62,9 +70,12 @@ export default class ShareModule extends VuexModule { } @Action - startShareFlow({ id, type }: StartFlow): void { + startShareFlow({ id, type, destinationType }: StartFlow): void { this.setParentId(id); this.setParentType(type); + if (destinationType) { + this.setDestinationType(destinationType); + } this.setShareModalOpen(true); } @@ -85,6 +96,11 @@ export default class ShareModule extends VuexModule { this.parentType = type; } + @Mutation + setDestinationType(destinationType: BoardExternalReferenceType): void { + this.destinationType = destinationType; + } + @Mutation setShareModalOpen(open: boolean): void { this.isShareModalOpen = open; diff --git a/src/store/share.unit.ts b/src/store/share.unit.ts index af5d88c2ab..4e6e4fb001 100644 --- a/src/store/share.unit.ts +++ b/src/store/share.unit.ts @@ -3,6 +3,7 @@ import * as serverApi from "../serverApi/v3/api"; import { ShareTokenApiInterface, ShareTokenBodyParamsParentTypeEnum, + BoardExternalReferenceType, } from "../serverApi/v3/api"; import setupStores from "@@/tests/test-utils/setupStores"; import courseRoomDetailsModule from "@/store/course-room-details"; @@ -114,23 +115,30 @@ describe("share module", () => { }); describe("startShareFlow", () => { - it("should call setParentId, setParentType and setShareModalOpen mutations", async () => { + it("should call setParentId, setParentType, setDestinationType and setShareModalOpen mutations", async () => { const shareModule = new ShareModule({}); const setParentIdMock = jest.spyOn(shareModule, "setParentId"); const setParentTypeMock = jest.spyOn(shareModule, "setParentType"); + const setDestinationTypeMock = jest.spyOn( + shareModule, + "setDestinationType" + ); const setShareModalOpenMock = jest.spyOn( shareModule, "setShareModalOpen" ); const testId = "test-id"; const type = ShareTokenBodyParamsParentTypeEnum.Courses; + const destinationType = BoardExternalReferenceType.Room; shareModule.startShareFlow({ id: testId, type, + destinationType, }); expect(setParentIdMock).toHaveBeenCalledWith(testId); expect(setParentTypeMock).toHaveBeenCalledWith(type); + expect(setDestinationTypeMock).toHaveBeenCalledWith(destinationType); expect(setShareModalOpenMock).toHaveBeenCalledWith(true); }); }); diff --git a/src/store/types/rooms.ts b/src/store/types/rooms.ts index 99498e22fc..c3d48a8b09 100644 --- a/src/store/types/rooms.ts +++ b/src/store/types/rooms.ts @@ -41,3 +41,8 @@ export type SharingCourseObject = { }; export type AllItems = Array; + +export type ImportDestinationItem = { + id: string; + name: string; +}; diff --git a/src/types/board/Permissions.ts b/src/types/board/Permissions.ts index 13388b53f8..4bfe8b58a3 100644 --- a/src/types/board/Permissions.ts +++ b/src/types/board/Permissions.ts @@ -2,6 +2,7 @@ export type BoardPermissionChecks = { hasMovePermission: boolean; hasCreateCardPermission: boolean; hasCreateColumnPermission: boolean; + hasCreateToolPermission: boolean; hasEditPermission: boolean; hasDeletePermission: boolean; isTeacher: boolean; @@ -12,6 +13,7 @@ export const defaultPermissions: BoardPermissionChecks = { hasMovePermission: true, hasCreateCardPermission: true, hasCreateColumnPermission: true, + hasCreateToolPermission: true, hasDeletePermission: true, hasEditPermission: true, isTeacher: true, diff --git a/tests/test-utils/factory/externalToolDisplayDataFactory.ts b/tests/test-utils/factory/externalToolDisplayDataFactory.ts index e1abd328d8..7f6a3c2c59 100644 --- a/tests/test-utils/factory/externalToolDisplayDataFactory.ts +++ b/tests/test-utils/factory/externalToolDisplayDataFactory.ts @@ -10,4 +10,5 @@ export const externalToolDisplayDataFactory = openInNewTab: false, status: contextExternalToolConfigurationStatusFactory.build(), logoUrl: "https://example.com/logo.png", + isLtiDeepLinkingTool: false, })); diff --git a/tests/test-utils/factory/index.ts b/tests/test-utils/factory/index.ts index 1817284e48..8230f77e4e 100644 --- a/tests/test-utils/factory/index.ts +++ b/tests/test-utils/factory/index.ts @@ -64,6 +64,7 @@ export * from "./videoConferenceJoinResponseFactory"; export * from "./envsFactory"; export * from "./media-board"; export * from "./contextExternalToolResponseFactory"; +export * from "./ltiDeepLinkResponseFactory"; export * from "./courseInfoDataResponseFactory"; export * from "./deletedElementResponseFactory"; export * from "./room"; diff --git a/tests/test-utils/factory/ltiDeepLinkResponseFactory.ts b/tests/test-utils/factory/ltiDeepLinkResponseFactory.ts new file mode 100644 index 0000000000..4ea0cf1883 --- /dev/null +++ b/tests/test-utils/factory/ltiDeepLinkResponseFactory.ts @@ -0,0 +1,8 @@ +import { Factory } from "fishery"; +import { LtiDeepLinkResponse } from "@/serverApi/v3"; + +export const ltiDeepLinkResponseFactory = Factory.define( + () => ({ + mediaType: "mediaType", + }) +); diff --git a/tests/test-utils/factory/room/roomMembersFactory.ts b/tests/test-utils/factory/room/roomMembersFactory.ts index 4d1bd7170c..358b36a77b 100644 --- a/tests/test-utils/factory/room/roomMembersFactory.ts +++ b/tests/test-utils/factory/room/roomMembersFactory.ts @@ -11,7 +11,7 @@ export const roomMemberResponseFactory = Factory.define( userId: `member${sequence}`, firstName: `firstName${sequence}`, lastName: `lastName${sequence}`, - roleName: RoleName.RoomEditor, + roleName: RoleName.Roomeditor, displayRoleName: RoleName.Teacher, schoolName: "Paul-Gerhardt-Gymnasium", }) @@ -23,7 +23,7 @@ export const roomMemberListFactory = Factory.define( firstName: `firstName${sequence}`, lastName: `lastName${sequence}`, fullName: `lastName${sequence}, firstName${sequence}`, - roleName: RoleName.RoomEditor, + roleName: RoleName.Roomeditor, displayRoleName: RoleName.Teacher, schoolName: "Paul-Gerhardt-Gymnasium", }) diff --git a/tests/test-utils/factory/roomDetailsFactory.ts b/tests/test-utils/factory/roomDetailsFactory.ts new file mode 100644 index 0000000000..fe229d4a0e --- /dev/null +++ b/tests/test-utils/factory/roomDetailsFactory.ts @@ -0,0 +1,17 @@ +import { Factory } from "fishery"; +import { RoomDetails } from "@/types/room/Room"; +import { RoomColor } from "@/serverApi/v3"; + +export const roomDetailsFactory = Factory.define( + ({ sequence }) => ({ + id: `room${sequence}`, + name: `room #${sequence}`, + color: RoomColor.BlueGrey, + schoolId: "6749dd4e657d98af622e370c", + startDate: new Date().toISOString(), + endDate: new Date().toISOString(), + permissions: [], + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + }) +); diff --git a/tests/test-utils/factory/toolLaunchRequestFactory.ts b/tests/test-utils/factory/toolLaunchRequestFactory.ts index f6cced0a4c..c9d1217ed8 100644 --- a/tests/test-utils/factory/toolLaunchRequestFactory.ts +++ b/tests/test-utils/factory/toolLaunchRequestFactory.ts @@ -1,3 +1,4 @@ +import { LaunchType } from "@/serverApi/v3"; import { ToolLaunchRequest, ToolLaunchRequestMethodEnum, @@ -9,6 +10,6 @@ export const toolLaunchRequestFactory = Factory.define( method: ToolLaunchRequestMethodEnum.Get, payload: '{ "key": "value" }', url: "https://example.com/tool-launch", - isDeepLink: false, + launchType: LaunchType.Basic, }) ); diff --git a/tests/test-utils/factory/toolLaunchRequestResponseFactory.ts b/tests/test-utils/factory/toolLaunchRequestResponseFactory.ts index 72ee1b30fb..0e4f195642 100644 --- a/tests/test-utils/factory/toolLaunchRequestResponseFactory.ts +++ b/tests/test-utils/factory/toolLaunchRequestResponseFactory.ts @@ -1,5 +1,6 @@ import { Factory } from "fishery"; import { + LaunchType, ToolLaunchRequestResponse, ToolLaunchRequestResponseMethodEnum, } from "@/serverApi/v3"; @@ -9,5 +10,5 @@ export const toolLaunchRequestResponseFactory = method: ToolLaunchRequestResponseMethodEnum.Get, payload: '{ "key": "value" }', url: "https://example.com/tool-launch", - isDeepLink: false, + launchType: LaunchType.Basic, })); diff --git a/tests/test-utils/factory/toolReferenceResponseFactory.ts b/tests/test-utils/factory/toolReferenceResponseFactory.ts index bd5aef3092..742cbbcfa5 100644 --- a/tests/test-utils/factory/toolReferenceResponseFactory.ts +++ b/tests/test-utils/factory/toolReferenceResponseFactory.ts @@ -8,4 +8,5 @@ export const toolReferenceResponseFactory = status: contextExternalToolConfigurationStatusFactory.build(), openInNewTab: true, displayName: `Tool ${sequence}`, + isLtiDeepLinkingTool: false, }));