diff --git a/app/controllers/search/courses.php b/app/controllers/search/courses.php index 50a348066fa9dad1f5333c435729b2b6fe4d3cc8..9e0b45d6e31adfa3df193efccc5487c99501a63d 100644 --- a/app/controllers/search/courses.php +++ b/app/controllers/search/courses.php @@ -28,6 +28,10 @@ class Search_CoursesController extends AuthenticatedController PageLayout::setHelpKeyword('Basis.VeranstaltungenAbonnieren'); $this->type = Request::option('type', 'semtree'); + $this->show_as = Request::option('show_as', 'list'); + if (!in_array($this->show_as, ['list', 'table'])) { + $this->show_as = 'list'; + } $this->semester = Request::option('semester', Semester::findCurrent()->id); $this->semClass = Request::int('semclass', 0); $this->search = Request::get('search', ''); @@ -107,5 +111,16 @@ class Search_CoursesController extends AuthenticatedController $sidebar->addWidget(new VueWidget('search-widget')); $sidebar->addWidget(new VueWidget('export-widget')); + + $views = new ViewsWidget(); + $views->addLink( + _('Als Liste'), + $this->url_for('search/courses', array_merge($params, ['show_as' => 'list'])) + )->setActive($this->show_as === 'list'); + $views->addLink( + _('Als Tabelle'), + $this->url_for('search/courses', array_merge($params, ['show_as' => 'table'])) + )->setActive($this->show_as === 'table'); + $sidebar->addWidget($views); } } diff --git a/app/views/search/courses/index.php b/app/views/search/courses/index.php index c6f490506018972d0765c56676e34fb9b73d6835..500c31e7f69d808dfe18fdaeceab04a16ba03744 100644 --- a/app/views/search/courses/index.php +++ b/app/views/search/courses/index.php @@ -1,11 +1,15 @@ <?php /** * @var String $startId - * @var String $nodeClass + * @var String $show_as + * @var String $treeTitle + * @var String $breadcrumIcon + * @var String $semester + * @var String $semClass */ ?> <div data-studip-tree> - <studip-tree start-id="<?= htmlReady($startId) ?>" view-type="list" :visible-children-only="true" + <studip-tree start-id="<?= htmlReady($startId) ?>" view-type="<?= htmlReady($show_as) ?>" :visible-children-only="true" title="<?= htmlReady($treeTitle) ?>" breadcrumb-icon="<?= htmlReady($breadcrumbIcon) ?>" :with-search="true" :with-export="true" :with-courses="true" semester="<?= htmlReady($semester) ?>" :sem-class="<?= htmlReady($semClass) ?>" :with-export="true"></studip-tree> diff --git a/lib/classes/JsonApi/Routes/Tree/CourseInfoOfTreeNode.php b/lib/classes/JsonApi/Routes/Tree/CourseInfoOfTreeNode.php index 283593150cf5c0c4ec8d3fb5cc0ae2555b9ff2d4..e684c40301b2e26d4a3c9e109129229de257753f 100644 --- a/lib/classes/JsonApi/Routes/Tree/CourseInfoOfTreeNode.php +++ b/lib/classes/JsonApi/Routes/Tree/CourseInfoOfTreeNode.php @@ -32,8 +32,7 @@ class CourseinfoOfTreeNode extends NonJsonApiController $filters = $this->getContextFilters($request); $info = [ - 'courses' => (int) $node->countCourses($filters['semester'], $filters['semclass']), - 'allCourses' => (int) $node->countCourses($filters['semester'], $filters['semclass'], true) + 'courses' => (int) $node->countCourses($filters['semester'], $filters['semclass'], true) ]; $response->getBody()->write(json_encode($info)); diff --git a/lib/models/StudipStudyArea.class.php b/lib/models/StudipStudyArea.class.php index 444ca21291237d152b8aa2e2824c2f96675732c5..2d13b186eff39112472337fe562899f9a2649c9d 100644 --- a/lib/models/StudipStudyArea.class.php +++ b/lib/models/StudipStudyArea.class.php @@ -494,7 +494,7 @@ class StudipStudyArea extends SimpleORMap implements StudipTreeNode OR sc.`semester_id` IS NULL )"; $parameters = [ - 'ids' => $with_children ? $this->getDescendantIds() : [$this->id], + 'ids' => $with_children ? array_merge([$this->id], $this->getDescendantIds()) : [$this->id], 'semester' => $semester_id ]; } else { @@ -502,7 +502,7 @@ class StudipStudyArea extends SimpleORMap implements StudipTreeNode FROM `seminar_sem_tree` t JOIN `seminare` s ON (s.`Seminar_id` = t.`seminar_id`) WHERE `sem_tree_id` IN (:ids)"; - $parameters = ['ids' => $with_children ? $this->getDescendantIds() : [$this->id]]; + $parameters = ['ids' => $with_children ? array_merge([$this->id], $this->getDescendantIds()) : [$this->id]]; } if (!$GLOBALS['perm']->have_perm(Config::get()->SEM_VISIBILITY_PERM)) { diff --git a/resources/assets/stylesheets/scss/loading-skeleton.scss b/resources/assets/stylesheets/scss/loading-skeleton.scss new file mode 100644 index 0000000000000000000000000000000000000000..480255fe502110970ee3daf54dec1768addf3240 --- /dev/null +++ b/resources/assets/stylesheets/scss/loading-skeleton.scss @@ -0,0 +1,5 @@ +.studip-loading-skeleton { + background-color: var(--light-gray-color-20); + height: 1em; + width: 100%; +} diff --git a/resources/assets/stylesheets/scss/tree.scss b/resources/assets/stylesheets/scss/tree.scss index dbad68b1bc507c14dacc893b93662e8631eb6e84..1d700f92eb5d1b29b85151f055abd167e56bae47 100644 --- a/resources/assets/stylesheets/scss/tree.scss +++ b/resources/assets/stylesheets/scss/tree.scss @@ -271,8 +271,8 @@ $tree-outline: 1px solid var(--light-gray-color-40); background: var(--dark-gray-color-5); border: solid thin var(--light-gray-color-40); display: flex; - height: 130px; - padding: 10px; + min-height: 130px; + padding: 5px 10px; /* Handle for drag&drop */ .drag-handle { @@ -284,11 +284,17 @@ $tree-outline: 1px solid var(--light-gray-color-40); flex-direction: column; padding: 10px; text-align: left; + width: 100%; .studip-tree-child-title { font-size: 1.1em; font-weight: bold; } + + .studip-tree-child-description { + color: var(--black); + font-size: 0.9em; + } } &:hover { diff --git a/resources/assets/stylesheets/studip.scss b/resources/assets/stylesheets/studip.scss index e45cdb3f63f5fe2480726b70fda004897528efea..b0e768ea172bf86327d5c2a7361450a94a4b1171 100644 --- a/resources/assets/stylesheets/studip.scss +++ b/resources/assets/stylesheets/studip.scss @@ -57,6 +57,7 @@ @import "scss/globalsearch"; @import "scss/links"; @import "scss/lists"; +@import "scss/loading-skeleton.scss"; @import "scss/messages"; @import "scss/my_courses"; @import "scss/mvv"; diff --git a/resources/vue/components/StudipLoadingSkeleton.vue b/resources/vue/components/StudipLoadingSkeleton.vue new file mode 100644 index 0000000000000000000000000000000000000000..90b973397032cfb35556dea67c5ed76377d73c76 --- /dev/null +++ b/resources/vue/components/StudipLoadingSkeleton.vue @@ -0,0 +1,9 @@ +<template> + <div class="studip-loading-skeleton"></div> +</template> + +<script> +export default { + name: 'StudipLoadingSkeleton' +} +</script> diff --git a/resources/vue/components/tree/StudipTreeList.vue b/resources/vue/components/tree/StudipTreeList.vue index b607e02197a1bff868fe4a3ce73a0866055b7e56..4ba08bcbb81200e3751530c7c3c32e8fda65d3e9 100644 --- a/resources/vue/components/tree/StudipTreeList.vue +++ b/resources/vue/components/tree/StudipTreeList.vue @@ -62,8 +62,8 @@ </span> <span v-if="withCourses && subLevelsCourses > 0 && !showingAllCourses"> <button type="button" @click="showAllCourses(true)" - :title="$gettext('Veranstaltungen auf allen Unterebenen anzeigen')"> - {{ $gettext('Veranstaltungen auf allen Unterebenen anzeigen') }} + :title="$gettext('Veranstaltungen auf Unterebenen anzeigen')"> + {{ $gettext('Veranstaltungen auf Unterebenen anzeigen') }} </button> </span> </section> @@ -74,6 +74,15 @@ <col> </colgroup> <thead> + <tr v-if="totalCourseCount > limit"> + <td colspan="2"> + <studip-pagination :items-per-page="limit" + :total-items="totalCourseCount" + :current-offset="offset" + @updateOffset="updateOffset" + /> + </td> + </tr> <tr> <th>{{ $gettext('Name') }}</th> <th>{{ $gettext('Information') }}</th> @@ -98,6 +107,17 @@ </td> </tr> </tbody> + <tfoot v-if="totalCourseCount > limit"> + <tr> + <td colspan="2"> + <studip-pagination :items-per-page="limit" + :total-items="totalCourseCount" + :current-offset="offset" + @updateOffset="updateOffset" + /> + </td> + </tr> + </tfoot> </table> <MountingPortal v-if="showExport" mountTo="#export-widget" name="sidebar-export"> <tree-export-widget v-if="courses.length > 0" @@ -118,13 +138,14 @@ import TreeBreadcrumb from './TreeBreadcrumb.vue'; import TreeNodeTile from './TreeNodeTile.vue'; import StudipProgressIndicator from '../StudipProgressIndicator.vue'; import TreeCourseDetails from './TreeCourseDetails.vue'; -import AssignLinkWidget from "./AssignLinkWidget.vue"; +import AssignLinkWidget from './AssignLinkWidget.vue'; +import StudipPagination from '../StudipPagination.vue'; export default { name: 'StudipTreeList', components: { draggable, StudipProgressIndicator, TreeExportWidget, TreeBreadcrumb, TreeNodeTile, TreeCourseDetails, - AssignLinkWidget + AssignLinkWidget, StudipPagination }, mixins: [ TreeMixin ], props: { @@ -225,8 +246,10 @@ export default { }); if (this.withCourses) { - this.getNodeCourses(node, this.semester, this.semClass, '', false) + this.getNodeCourses(node, this.offset, this.semester, this.semClass, '', false) .then(courses => { + this.totalCourseCount = courses.data.meta.page.total; + this.offset = Math.ceil(courses.data.meta.page.offset / this.limit); this.courses = courses.data.data; }); } @@ -288,8 +311,10 @@ export default { } }, showAllCourses(state) { - this.getNodeCourses(this.currentNode, this.semester, this.semClass, '', state) + this.getNodeCourses(this.currentNode, this.offset, this.semester, this.semClass, '', state) .then(courses => { + this.totalCourseCount = courses.data.meta.page.total; + this.offset = Math.ceil(courses.data.meta.page.offset / this.limit); this.courses = courses.data.data; this.showingAllCourses = state; }); @@ -309,8 +334,10 @@ export default { }); if (this.withCourses) { - this.getNodeCourses(this.currentNode, this.semester, this.semClass) + this.getNodeCourses(this.currentNode, 0, this.semester, this.semClass) .then(courses => { + this.totalCourseCount = courses.data.meta.page.total; + this.offset = 0; this.courses = courses.data.data; }); } diff --git a/resources/vue/components/tree/StudipTreeNode.vue b/resources/vue/components/tree/StudipTreeNode.vue index 39f696ccc14e12480701eb2501a71b34cffacf4a..2b5676e24a38455201ff5b0c6a898036f231b1ba 100644 --- a/resources/vue/components/tree/StudipTreeNode.vue +++ b/resources/vue/components/tree/StudipTreeNode.vue @@ -175,7 +175,7 @@ export default { } if (ids.length > 0) { - this.getNodeCourses(this.node, 'all', 0, '', false, ids) + this.getNodeCourses(this.node, 0, 'all', 0, '', false, ids) .then(response => { // None of the given courses are assigned here. if (response.data.data.length === 0) { diff --git a/resources/vue/components/tree/StudipTreeTable.vue b/resources/vue/components/tree/StudipTreeTable.vue index 585acd426046c1cda90ac862d5354bf105c36a96..03a9d7b60e9ac64c2263d4da5b609e1717ce0495 100644 --- a/resources/vue/components/tree/StudipTreeTable.vue +++ b/resources/vue/components/tree/StudipTreeTable.vue @@ -35,8 +35,8 @@ </template> <span v-if="withCourses && subLevelsCourses > 0 && !showingAllCourses"> <button type="button" @click="showAllCourses(true)" - :title="$gettext('Veranstaltungen auf allen Unterebenen anzeigen')"> - {{ $gettext('Veranstaltungen auf allen Unterebenen anzeigen') }} + :title="$gettext('Veranstaltungen auf Unterebenen anzeigen')"> + {{ $gettext('Veranstaltungen auf Unterebenen anzeigen') }} </button> </span> </section> @@ -57,6 +57,15 @@ <col style="width: 40%"> </colgroup> <thead> + <tr v-if="totalCourseCount > limit"> + <td colspan="4"> + <studip-pagination :items-per-page="limit" + :total-items="totalCourseCount" + :current-offset="offset" + @updateOffset="updateOffset" + /> + </td> + </tr> <tr> <th></th> <th>{{ $gettext('Typ') }}</th> @@ -89,8 +98,11 @@ </a> </td> <td> - <tree-node-course-info :node="child" :semester="semester" - :sem-class="semClass"></tree-node-course-info> + <tree-node-course-info v-if="node.attributes.ancestors.length > 2" + :node="child" + :semester="semester" + :sem-class="semClass" + ></tree-node-course-info> </td> </tr> <tr v-for="(course) in courses" :key="course.id" class="studip-tree-child studip-tree-course"> @@ -114,6 +126,17 @@ </td> </tr> </draggable> + <tfoot v-if="totalCourseCount > limit"> + <tr> + <td colspan="4"> + <studip-pagination :items-per-page="limit" + :total-items="totalCourseCount" + :current-offset="offset" + @updateOffset="updateOffset" + /> + </td> + </tr> + </tfoot> </table> <MountingPortal v-if="showExport" mountTo="#export-widget" name="sidebar-export"> <tree-export-widget v-if="courses.length > 0" :title="$gettext('Download des Ergebnisses')" :url="exportUrl()" @@ -242,8 +265,10 @@ export default { if (this.withCourses) { - this.getNodeCourses(node, this.semester, this.semClass, '', false) + this.getNodeCourses(node, this.offset, this.semester, this.semClass, '', false) .then(response => { + this.totalCourseCount = response.data.meta.page.total; + this.offset = Math.ceil(response.data.meta.page.offset / this.limit); this.courses = response.data.data; }); } @@ -305,8 +330,10 @@ export default { } }, showAllCourses(state) { - this.getNodeCourses(this.currentNode, this.semester, this.semClass, '', state) + this.getNodeCourses(this.currentNode, this.offset, this.semester, this.semClass, '', state) .then(courses => { + this.totalCourseCount = courses.data.meta.page.total; + this.offset = Math.ceil(courses.data.meta.page.offset / this.limit); this.courses = courses.data.data; this.showingAllCourses = state; }); @@ -326,8 +353,10 @@ export default { }); if (this.withCourses) { - this.getNodeCourses(this.currentNode, this.semester, this.semClass) + this.getNodeCourses(this.currentNode, 0, this.semester, this.semClass) .then(courses => { + this.totalCourseCount = courses.data.meta.page.total; + this.offset = 0; this.courses = courses.data.data; }); } diff --git a/resources/vue/components/tree/TreeNodeCourseInfo.vue b/resources/vue/components/tree/TreeNodeCourseInfo.vue index db31d148c3f144ec4b221f91fd77667f5cf6f250..3f3777c933853066ba83eb23a58cc37e935f7544 100644 --- a/resources/vue/components/tree/TreeNodeCourseInfo.vue +++ b/resources/vue/components/tree/TreeNodeCourseInfo.vue @@ -1,33 +1,20 @@ <template> <div class="studip-tree-child-description"> - <template v-if="showingAllCourses"> - <div v-translate="{ count: courseCount }" :translate-n="courseCount" - translate-plural="<strong>%{count}</strong> Veranstaltungen auf dieser Ebene."> - <strong>Eine</strong> Veranstaltung auf dieser Ebene. - </div> - </template> + <studip-loading-skeleton v-if="isLoading" /> <div v-else v-translate="{ count: courseCount }" :translate-n="courseCount" - translate-plural="<strong>%{count}</strong> Veranstaltungen auf dieser Ebene."> - <strong>Eine</strong> Veranstaltung auf dieser Ebene. - </div> - <template v-if="!showingAllCourses"> - <div v-translate="{ count: allCourseCount }" :translate-n="allCourseCount" - translate-plural="<strong>%{count}</strong> Veranstaltungen auf allen Unterebenen."> - <strong>Eine</strong> Veranstaltung auf allen Unterebenen. - </div> - </template> - <div v-else v-translate="{ count: allCourseCount }" :translate-n="allCourseCount" - translate-plural="<strong>%{count}</strong> Veranstaltungen auf allen Unterebenen."> - <strong>Eine</strong> Veranstaltung auf allen Unterebenen. + translate-plural="<strong>%{count}</strong> Veranstaltungen"> + <strong>Eine</strong> Veranstaltung </div> </div> </template> <script> import { TreeMixin } from '../../mixins/TreeMixin'; +import StudipLoadingSkeleton from '../StudipLoadingSkeleton.vue'; export default { name: 'TreeNodeCourseInfo', + components: { StudipLoadingSkeleton }, mixins: [ TreeMixin ], props: { node: { @@ -45,8 +32,8 @@ export default { }, data() { return { + isLoading: false, courseCount: 0, - allCourseCount: 0, showingAllCourses: false } }, @@ -54,22 +41,22 @@ export default { showAllCourses(state) { this.showingAllCourses = state; this.$emit('showAllCourses', state); + }, + loadNodeInfo(node) { + this.isLoading = true; + this.getNodeCourseInfo(node, this.semester, this.semClass) + .then(info => { + this.courseCount = info?.data.courses; + this.isLoading = false; + }); } }, mounted() { - this.getNodeCourseInfo(this.node, this.semester, this.semClass) - .then(info => { - this.courseCount = info?.data.courses; - this.allCourseCount = info?.data.allCourses; - }); + this.loadNodeInfo(this.node); }, watch: { node(newNode) { - this.getNodeCourseInfo(newNode, this.semester, this.semClass) - .then(info => { - this.courseCount = info?.data.courses; - this.allCourseCount = info?.data.allCourses; - }); + this.loadNodeInfo(newNode); } } } diff --git a/resources/vue/components/tree/TreeNodeTile.vue b/resources/vue/components/tree/TreeNodeTile.vue index cbb0679eaeffeedb844b823abf4a2a5c0293e955..dab2bdf9ff698e0c4333cdd1873338ac45a85769 100644 --- a/resources/vue/components/tree/TreeNodeTile.vue +++ b/resources/vue/components/tree/TreeNodeTile.vue @@ -5,8 +5,11 @@ {{ node.attributes.name }} </p> - <tree-node-course-info :node="node" :semester="semester" - :sem-class="semClass"></tree-node-course-info> + <tree-node-course-info v-if="node.attributes.ancestors.length > 2" + :node="node" + :semester="semester" + :sem-class="semClass" + ></tree-node-course-info> </a> </template> diff --git a/resources/vue/mixins/TreeMixin.js b/resources/vue/mixins/TreeMixin.js index 9c4c859d7b092aba832c53186e52bb669b4c6a7b..4263eaf938d269906d771e679752511642a073d2 100644 --- a/resources/vue/mixins/TreeMixin.js +++ b/resources/vue/mixins/TreeMixin.js @@ -3,7 +3,10 @@ import axios from 'axios'; export const TreeMixin = { data() { return { - showProgressIndicatorTimeout: 500 + showProgressIndicatorTimeout: 500, + totalCourseCount: 0, + offset: 0, + limit: 50 }; }, methods: { @@ -22,9 +25,12 @@ export const TreeMixin = { { params: parameters } ); }, - async getNodeCourses(node, semesterId = 'all', semClass = 0, searchterm = '', recursive = false, ids = []) { + async getNodeCourses(node, offset, semesterId = 'all', semClass = 0, searchterm = '', recursive = false, ids = []) { let parameters = {}; + parameters['page[offset]'] = offset * this.limit; + parameters['page[limit]'] = this.limit; + if (semesterId !== 'all' && semesterId !== '0') { parameters['filter[semester]'] = semesterId; } @@ -103,6 +109,15 @@ export const TreeMixin = { { headers: { 'Content-Type': 'multipart/form-data' }} ); STUDIP.Vue.emit('sort-tree-children', { parent: parentId, children: children }); + }, + updateOffset(newOffset) { + this.getNodeCourses(this.currentNode, newOffset, this.semester, this.semClass, '', this.showingAllCourses) + .then(courses => { + this.courseCount = courses.data.meta.page.total; + this.currentOffset = courses.data.meta.page.offset; + this.offset = newOffset; + this.courses = courses.data.data; + }); } } }