diff --git a/app/controllers/course/basicdata.php b/app/controllers/course/basicdata.php index cda28d74ec39e3a1d5d8f8dea7288c6f29982ea6..1df3a203343733aae3201ea89bb8b991eb6b0bf5 100644 --- a/app/controllers/course/basicdata.php +++ b/app/controllers/course/basicdata.php @@ -703,7 +703,7 @@ class Course_BasicdataController extends AuthenticatedController } else { $sem = Seminar::getInstance($course_id); $deputy = Deputy::find([$deputy_id, $course_id]); - if ($deputy && $deputy->delete) { + if ($deputy && $deputy->delete()) { // Remove user from subcourses as well. if($sem->children) { $children_ids = $sem->children->pluck('seminar_id'); @@ -903,4 +903,4 @@ class Course_BasicdataController extends AuthenticatedController } return $sem_types; } -} \ No newline at end of file +} diff --git a/app/controllers/settings/calendar.php b/app/controllers/settings/calendar.php index 1634cfb4123e8191816e9434dede9303eee3928c..640a8c93e6df615d3347bb0efa1e4342eb13add1 100644 --- a/app/controllers/settings/calendar.php +++ b/app/controllers/settings/calendar.php @@ -36,7 +36,7 @@ class Settings_CalendarController extends Settings_SettingsController PageLayout::setHelpKeyword('Basis.MyStudIPTerminkalender'); PageLayout::setTitle(_('Einstellungen des Terminkalenders anpassen')); Navigation::activateItem('/profile/settings/calendar_new'); - SkipLinks::addIndex(_('Einstellungen des Terminkalenders anpassen'), 'main_content', 100); + SkipLinks::addIndex(_('Einstellungen des Terminkalenders anpassen'), 'layout_content', 100); } /** diff --git a/app/views/calendar/single/day.php b/app/views/calendar/single/day.php index 6d78a5225bcdffe55829ede4355060d8844f4159..e8f625da25432af00b62ca6d23fd573b53320fd0 100644 --- a/app/views/calendar/single/day.php +++ b/app/views/calendar/single/day.php @@ -1,6 +1,6 @@ <? // add skip link -SkipLinks::addIndex(_('Tagesansicht'), 'main_content', 100); +SkipLinks::addIndex(_('Tagesansicht'), 'layout_content', 100); ?> <div style="width: 100%; display: flex; flex-wrap: wrap;"> <div style="flex-grow:2; flex-basis: 60%;"> @@ -14,4 +14,4 @@ SkipLinks::addIndex(_('Tagesansicht'), 'main_content', 100); <? $imt = mktime(12, 0, 0, date('n', $imt) + 1, date('j', $imt), date('Y', $imt)) ?> <?= $this->render_partial('calendar/single/_include_month', ['imt' => $imt, 'href' => '', 'mod' => 'NONAVARROWS']) ?> </div> -</div> \ No newline at end of file +</div> diff --git a/app/views/calendar/single/week.php b/app/views/calendar/single/week.php index a47c51ed90a20c9e1449c14871d1e0c6c810b761..c3c98e1fc79b9a9cb65ad801ce48a9ed654f0c42 100644 --- a/app/views/calendar/single/week.php +++ b/app/views/calendar/single/week.php @@ -1,6 +1,6 @@ <? // add skip link -SkipLinks::addIndex(_('Wochenansicht'), 'main_content', 100); +SkipLinks::addIndex(_('Wochenansicht'), 'layout_content', 100); $at = date('G', $atime); if ($at >= $settings['start'] && $at <= $settings['end'] || !$atime) { diff --git a/app/views/contents/courseware/create_project.php b/app/views/contents/courseware/create_project.php index 37fce118a96a43192c2c4c0a03574e25e4dff7b2..78f3173a6a20ac38cdf3c7881bf6b9d8fd704631 100755 --- a/app/views/contents/courseware/create_project.php +++ b/app/views/contents/courseware/create_project.php @@ -64,7 +64,7 @@ <?= _('Niveau') ?> </span> <select name="difficulty"> - <? for ($i = 1; $i<=12; $i++): ?> + <? for ($i = 1; $i<=12; $i++): ?> <option value="<?= $i?>"><?= $i?></option> <? endfor; ?> </select> @@ -111,7 +111,7 @@ </select> </label> - <label class="file-upload"> + <label class="file-upload enter-accessible" tabindex="0"> <?= _('Vorschaubild hochladen') ?> <input id="previewfile" name="previewfile" type="file" accept="image/*"> </label> diff --git a/resources/assets/javascripts/bootstrap/studip_helper_attributes.js b/resources/assets/javascripts/bootstrap/studip_helper_attributes.js index a433bb267ce8021796235034bffec26282cf8f96..60a9e1c40e02a73e8a7206b2fae0b14b819c2ada 100644 --- a/resources/assets/javascripts/bootstrap/studip_helper_attributes.js +++ b/resources/assets/javascripts/bootstrap/studip_helper_attributes.js @@ -227,3 +227,12 @@ $(document).on('change', 'input[data-must-equal]', function() { this.setCustomValidity(''); } }); + +//Generalisation: The enter-accessible class allows an element to be accessible via keyboard +//by triggering the click event when the enter key is pressed. +$(document).on('keydown', '.enter-accessible', function(event) { + if (event.code == 'Enter') { + //The enter key has been pressed. + $(this).trigger('click'); + } +}); diff --git a/resources/assets/javascripts/lib/search.js b/resources/assets/javascripts/lib/search.js index 38894f76d66f8f49a3f598fb08506b81dd78cd6e..c059144862d182802a3ca8206946ac73e1dc9df0 100644 --- a/resources/assets/javascripts/lib/search.js +++ b/resources/assets/javascripts/lib/search.js @@ -143,7 +143,8 @@ const Search = { $(`a#search_category_${name}`) .removeClass('no-result') - .text(`${value.name} (${counter}${value.plus ? '+' : ''})`); + .text(`${value.name} (${counter}${value.plus ? '+' : ''})`) + .attr('tabindex', '0'); // We have more search results than shown, provide link to // full search if available. @@ -326,7 +327,7 @@ const Search = { * Grey out all categories in the sidebar with no results. */ greyOutSearchCategories: function () { - $('a[id^="search_category_"]').addClass('no-result'); + $('a[id^="search_category_"]').addClass('no-result').attr('tabindex', '-1'); }, /** @@ -371,6 +372,7 @@ const Search = { $('#show_all_categories').closest('li').addClass('active'); } else { $(`#search_category_${category}`).closest('li').addClass('active'); + $(`#search_category_${category}`).attr('tabindex', '0'); } STUDIP.Search.showFilter(category); diff --git a/resources/assets/stylesheets/scss/courseware.scss b/resources/assets/stylesheets/scss/courseware.scss index 52ce7b807dec24e47824a923a2e9b4f2e5c50cb3..a8a523c57f7e2118f239d52218059d509a7ca7ba 100755 --- a/resources/assets/stylesheets/scss/courseware.scss +++ b/resources/assets/stylesheets/scss/courseware.scss @@ -4196,18 +4196,17 @@ vSelect end margin: 0; list-style: none; } - button { - width: 100%; - border: solid thin $content-color-40; - background-color: $white; - padding: 1em; - margin-bottom: 4px; + .cw-manager-copy-selector-course { color: $base-color; cursor: pointer; - outline: none; + line-height: 2em; + &:hover { - color:$white; - background-color: $base-color; + color: $active-color; + } + + img { + vertical-align: text-bottom; } } } diff --git a/resources/vue/components/BlubberThread.vue b/resources/vue/components/BlubberThread.vue index f07e2e43b248713d8fd455997e0576b3a32ddd73..ce84d9647e92a0c06cc5d9f18e7da4c924fb9e20 100644 --- a/resources/vue/components/BlubberThread.vue +++ b/resources/vue/components/BlubberThread.vue @@ -47,12 +47,12 @@ <div v-html="comment.html" class="html"></div> <textarea class="edit" v-html="comment.content" - @keyup.enter.exact="saveComment" + @keydown.enter.exact="saveComment" @keyup.escape.exact="editComment"></textarea> </div> <div class="time"> <studip-date-time :timestamp="comment.mkdate" :relative="true"></studip-date-time> - <a href="" v-if="comment.writable" @click.prevent="editComment" class="edit_comment" :title="$gettext('Bearbeiten.')"> + <a href="" v-if="comment.writable" @click.prevent.stop="editComment" class="edit_comment" :title="$gettext('Bearbeiten.')"> <studip-icon shape="edit" size="14" role="inactive"></studip-icon> </a> <a href="" @click.prevent="answerComment" class="answer_comment" :title="$gettext('Hierauf antworten.')"> diff --git a/resources/vue/components/courseware/CoursewareActionWidget.vue b/resources/vue/components/courseware/CoursewareActionWidget.vue index f2a443631c9ce38fede623b3cd801b7dccea09c2..ca1859c9ff2aa8bd666e0edca650f6664edd0948 100644 --- a/resources/vue/components/courseware/CoursewareActionWidget.vue +++ b/resources/vue/components/courseware/CoursewareActionWidget.vue @@ -36,6 +36,7 @@ export default { ...mapGetters({ oerEnabled: 'oerEnabled', oerTitle: 'oerTitle', + userId: 'userId', }), isRoot() { if (!this.structuralElement) { @@ -53,6 +54,18 @@ export default { currentId() { return this.structuralElement?.id; }, + blocked() { + return this.structuralElement?.relationships['edit-blocker'].data !== null; + }, + blockerId() { + return this.blocked ? this.structuralElement?.relationships['edit-blocker'].data?.id : null; + }, + blockedByThisUser() { + return this.blocked && this.userId === this.blockerId; + }, + blockedByAnotherUser() { + return this.blocked && this.userId !== this.blockerId; + }, }, methods: { ...mapActions({ @@ -67,7 +80,22 @@ export default { lockObject: 'lockObject', }), async editElement() { - await this.lockObject({ id: this.currentId, type: 'courseware-structural-elements' }); + if (this.blockedByAnotherUser) { + this.companionInfo({ info: this.$gettext('Diese Seite wird bereits bearbeitet.') }); + + return false; + } + try { + await this.lockObject({ id: this.currentId, type: 'courseware-structural-elements' }); + } catch(error) { + if (error.status === 409) { + this.companionInfo({ info: this.$gettext('Diese Seite wird bereits bearbeitet.') }); + } else { + console.log(error); + } + + return false; + } this.showElementEditDialog(true); }, async deleteElement() { diff --git a/resources/vue/components/courseware/CoursewareDefaultBlock.vue b/resources/vue/components/courseware/CoursewareDefaultBlock.vue index 341168784ddaebdd06f95a68c443f8eb09121c4c..486d6219f348486a79762ea5dc631cdf52b8dfc8 100755 --- a/resources/vue/components/courseware/CoursewareDefaultBlock.vue +++ b/resources/vue/components/courseware/CoursewareDefaultBlock.vue @@ -150,10 +150,10 @@ export default { return this.blocked ? this.block?.relationships['edit-blocker'].data?.id : null; }, blockedByThisUser() { - return this.userId === this.blockerId; + return this.blocked && this.userId === this.blockerId; }, blockedByAnotherUser() { - return this.userId !== this.blockerId; + return this.blocked && this.userId !== this.blockerId; }, blockTitle() { const type = this.block.attributes['block-type']; @@ -181,6 +181,9 @@ export default { loadContainer: 'loadContainer', }), async displayFeature(element) { + if (this.showEdit && element === 'Edit') { + return false; + } this.showFeatures = false; this.showFeedback = false; this.showComments = false; @@ -190,8 +193,20 @@ export default { this.showContent = true; if (element) { if (element === 'Edit') { + await this.loadContainer(this.block.relationships.container.data.id); if (!this.blocked) { - await this.lockObject({ id: this.block.id, type: 'courseware-blocks' }); + try { + await this.lockObject({ id: this.block.id, type: 'courseware-blocks' }); + } catch(error) { + if (error.status === 403) { + this.companionInfo({ info: this.$gettext('Dieser Block wird bereits bearbeitet.') }); + } else { + console.log(error); + } + + return false; + } + if (!this.preview) { this.showContent = false; } diff --git a/resources/vue/components/courseware/CoursewareDefaultContainer.vue b/resources/vue/components/courseware/CoursewareDefaultContainer.vue index a1d41c3e35598d08ad54dcc50004d20cc1c65ecd..53acc421c2cdddcc38581dab266d4bd59f910639 100755 --- a/resources/vue/components/courseware/CoursewareDefaultContainer.vue +++ b/resources/vue/components/courseware/CoursewareDefaultContainer.vue @@ -50,7 +50,7 @@ <script> import CoursewareContainerActions from './CoursewareContainerActions.vue'; import StudipDialog from '../StudipDialog.vue'; -import { mapActions } from 'vuex'; +import { mapGetters, mapActions } from 'vuex'; export default { name: 'courseware-default-container', @@ -76,12 +76,27 @@ export default { }; }, computed: { + ...mapGetters({ + userId: 'userId', + }), showEditMode() { return this.$store.getters.viewMode === 'edit'; }, colSpan() { return this.container.attributes.payload.colspan ? this.container.attributes.payload.colspan : 'full'; }, + blocked() { + return this.container?.relationships['edit-blocker'].data !== null; + }, + blockerId() { + return this.blocked ? this.container?.relationships['edit-blocker'].data?.id : null; + }, + blockedByThisUser() { + return this.blocked && this.userId === this.blockerId; + }, + blockedByAnotherUser() { + return this.blocked && this.userId !== this.blockerId; + }, }, methods: { ...mapActions({ @@ -90,7 +105,23 @@ export default { unlockObject: 'unlockObject', }), async displayEditDialog() { - await this.lockObject({ id: this.container.id, type: 'courseware-containers' }); + if (this.blockedByAnotherUser) { + this.companionInfo({ info: this.$gettext('Dieser Abschnitt wird bereits bearbeitet.') }); + + return false; + } + try { + await this.lockObject({ id: this.container.id, type: 'courseware-containers' }); + } catch(error) { + if (error.status === 409) { + this.companionInfo({ info: this.$gettext('Dieser Abschnitt wird bereits bearbeitet.') }); + } else { + console.log(error); + } + + return false; + } + this.showEditDialog = true; }, async closeEdit() { diff --git a/resources/vue/components/courseware/CoursewareManagerCopySelector.vue b/resources/vue/components/courseware/CoursewareManagerCopySelector.vue index a914cde8ed26cbcd9ecde8f2e0a8a73bcb29e64e..602a0b4d2a66f911a3a534e090520626a3408977 100755 --- a/resources/vue/components/courseware/CoursewareManagerCopySelector.vue +++ b/resources/vue/components/courseware/CoursewareManagerCopySelector.vue @@ -1,25 +1,43 @@ <template> <div class="cw-manager-copy-selector"> <div v-if="sourceEmpty" class="cw-manager-copy-selector-source"> - <button class="hugebutton" @click="selectSource('own'); loadOwnCourseware()"><translate>Aus meine Inhalte kopieren</translate></button> - <button class="hugebutton" @click="selectSource('remote')"><translate>Aus Veranstaltung kopieren</translate></button> + <button class="button" @click="selectSource('own'); loadOwnCourseware()"><translate>Aus meine Inhalte kopieren</translate></button> + <button class="button" @click="selectSource('remote')"><translate>Aus Veranstaltung kopieren</translate></button> </div> <div v-else> <button class="button" @click="reset"><translate>Quelle auswählen</translate></button> + <button v-show="!sourceOwn && hasRemoteCid" class="button" @click="selectNewCourse"><translate>Veranstaltung auswählen</translate></button> <div v-if="sourceRemote"> <h2 v-if="!hasRemoteCid"><translate>Veranstaltungen</translate></h2> <ul v-if="!hasRemoteCid"> - <li v-for="course in courses" :key="course.id" > - <button class="hugebutton" @click="loadRemoteCourseware(course.id)">{{course.attributes.title}}</button> + <li v-for="semester in semesterMap" :key="semester.id"> + <h3>{{semester.attributes.title}}</h3> + <ul> + <li + v-for="course in coursesBySemester(semester)" + :key="course.id" + class="cw-manager-copy-selector-course" + @click="loadRemoteCourseware(course.id)" + > + <studip-icon :shape="getCourseIcon(course)" /> + {{course.attributes.title}} + </li> + </ul> </li> + </ul> <courseware-manager-element - v-if="hasRemoteCid" + v-if="remoteId !== '' && hasRemoteCid" type="remote" :currentElement="remoteElement" @selectElement="setRemoteId" @loadSelf="loadSelf" /> + <courseware-companion-box + v-if="remoteId === '' && hasRemoteCid" + :msgCompanion="$gettext('In dieser Veranstaltung wurden noch keine Inhalte angelegt')" + mood="sad" + /> </div> <div v-if="sourceOwn"> <courseware-manager-element @@ -61,12 +79,14 @@ export default { ownCoursewareInstance: {}, ownId: '', ownElement: {}, + semesterMap: [], }}, computed: { ...mapGetters({ userId: 'userId', structuralElementById: 'courseware-structural-elements/byId', + semesterById: 'semesters/byId', }), sourceEmpty() { return this.source === ''; @@ -80,12 +100,16 @@ export default { hasRemoteCid() { return this.remoteCid !== ''; }, + loadedCourses() { + return this.courses.sort((a, b) => a.attributes.title > b.attributes.title); + } }, methods: { ...mapActions({ loadUsersCourses: 'loadUsersCourses', loadStructuralElement: 'loadStructuralElement', loadRemoteCoursewareStructure: 'loadRemoteCoursewareStructure', + loadSemester: 'semesters/loadById', }), selectSource(source) { this.source = source; @@ -96,7 +120,7 @@ export default { if (this.remoteCoursewareInstance !== null) { this.setRemoteId(this.remoteCoursewareInstance.relationships.root.data.id); } else { - console.debug('can not load'); + this.remoteId = ''; } }, @@ -105,14 +129,17 @@ export default { if (this.ownCoursewareInstance !== null) { this.setOwnId(this.ownCoursewareInstance.relationships.root.data.id); } else { - console.debug('can not load'); + this.ownId = ''; } - }, reset() { this.selectSource(''); this.remoteCid = ''; }, + selectNewCourse() { + this.remoteCid = ''; + this.remoteId = ''; + }, async setRemoteId(target) { this.remoteId = target; await this.loadStructuralElement(this.remoteId); @@ -131,11 +158,42 @@ export default { }, loadSelf(data) { this.$emit('loadSelf', data); + }, + loadSemesterMap() { + let view = this; + let semesters = []; + this.courses.every(course => { + let semId = course.relationships['start-semester'].data.id; + if(!semesters.includes(semId)) { + semesters.push(semId); + } + return true; + }); + semesters.every(semester => { + view.loadSemester({id: semester}).then( () => { + view.semesterMap.push(view.semesterById({id: semester})); + view.semesterMap.sort((a, b) => a.attributes.start < b.attributes.start); + }); + return true; + }); + }, + coursesBySemester(semester) { + return this.loadedCourses.filter(course => { + return course.relationships['start-semester'].data.id === semester.id} + ); + }, + getCourseIcon(course) { + if (course.attributes['course-type'] === 99) { + return 'studygroup'; + } + + return 'seminar'; } }, async mounted() { this.courses = await this.loadUsersCourses(this.userId); + this.loadSemesterMap(); } } -</script> \ No newline at end of file +</script> diff --git a/resources/vue/components/courseware/CoursewareStructuralElement.vue b/resources/vue/components/courseware/CoursewareStructuralElement.vue index 564bff692c211ad4156a3fded3335945b72672e9..d52358a001c7ed8f36b7374227a61e354cf97008 100755 --- a/resources/vue/components/courseware/CoursewareStructuralElement.vue +++ b/resources/vue/components/courseware/CoursewareStructuralElement.vue @@ -555,6 +555,7 @@ export default { licenses: 'licenses', exportState: 'exportState', exportProgress: 'exportProgress', + userId: 'userId', }), currentId() { @@ -907,6 +908,18 @@ export default { return ''; }, + blocked() { + return this.structuralElement?.relationships['edit-blocker'].data !== null; + }, + blockerId() { + return this.blocked ? this.structuralElement?.relationships['edit-blocker'].data?.id : null; + }, + blockedByThisUser() { + return this.blocked && this.userId === this.blockerId; + }, + blockedByAnotherUser() { + return this.blocked && this.userId !== this.blockerId; + }, }, methods: { @@ -936,7 +949,22 @@ export default { async menuAction(action) { switch (action) { case 'editCurrentElement': - await this.lockObject({ id: this.currentId, type: 'courseware-structural-elements' }); + if (this.blockedByAnotherUser) { + this.companionInfo({ info: this.$gettext('Diese Seite wird bereits bearbeitet.') }); + + return false; + } + try { + await this.lockObject({ id: this.currentId, type: 'courseware-structural-elements' }); + } catch(error) { + if (error.status === 409) { + this.companionInfo({ info: this.$gettext('Diese Seite wird bereits bearbeitet.') }); + } else { + console.log(error); + } + + return false; + } this.showElementEditDialog(true); break; case 'addElement': diff --git a/resources/vue/components/courseware/CoursewareTabs.vue b/resources/vue/components/courseware/CoursewareTabs.vue index 4cae1b663d6cce2c75f16f1d74a86c8177b58cf0..4df1a5d724c30a911ef04eb5f51403784962c71c 100755 --- a/resources/vue/components/courseware/CoursewareTabs.vue +++ b/resources/vue/components/courseware/CoursewareTabs.vue @@ -10,7 +10,7 @@ tab.icon !== '' && tab.name === '' ? 'cw-tabs-nav-icon-solo-' + tab.icon : '', ]" :href="tab.href" - :tabindex="index" + tabindex="0" @click="selectTab(tab)" @keydown.enter="selectTab(tab)" @keydown.space="selectTab(tab)" diff --git a/templates/contentbar/contentbar.php b/templates/contentbar/contentbar.php index 887f49525439dc5855b7900d54f62eb41428b956..4433aabdc5becaf64b99083232120ccf8a72c937 100644 --- a/templates/contentbar/contentbar.php +++ b/templates/contentbar/contentbar.php @@ -16,7 +16,7 @@ <div class="contentbar-icons"> <? if ($toc->hasChildren()) : ?> <input type="checkbox" id="cb-toc"> - <label for="cb-toc" class="check-box" title="<?= _('Inhaltsverzeichnis') ?>" > + <label for="cb-toc" class="check-box enter-accessible" title="<?= _('Inhaltsverzeichnis') ?>" tabindex="0"> <?= Icon::create('table-of-contents')->asImg(24) ?> </label> <?= $ttpl->render() ?> diff --git a/templates/toc/generic-toc-list.php b/templates/toc/generic-toc-list.php index ff81cb22f3f0b85fd6c8314d23a6d88ca4dba205..475ceaae4572c144e2bc115cf86d5445b43ed3c4 100644 --- a/templates/toc/generic-toc-list.php +++ b/templates/toc/generic-toc-list.php @@ -2,7 +2,7 @@ <article class="toc_overview toc_transform" id="toc"> <header id="toc_header"> <h1 id="toc_h1"><?= sprintf(_('Inhalt (%u Elemente)'), htmlReady($root->countAllChildren())) ?></h1> - <label for="cb-toc" class="check-box" title="<?= _('Schließen')?>"> + <label for="cb-toc" class="check-box enter-accessible" title="<?= _('Schließen')?>" tabindex="0"> <?= Icon::create('decline')->asImg(24) ?> </label> </header>