From f39148d2a5525ccf018322168480b4a5d816b6f9 Mon Sep 17 00:00:00 2001
From: Thomas Hackl <hackl@data-quest.de>
Date: Thu, 18 Jan 2024 08:33:21 +0000
Subject: [PATCH] =?UTF-8?q?Resolve=20"VVZ:=20Text=20l=C3=A4uft=20aus=20Kac?=
 =?UTF-8?q?hel"?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

Closes #3574, #3638, and #3639

Merge request studip/studip!2521
---
 app/controllers/search/courses.php            | 15 +++++++
 app/views/search/courses/index.php            |  8 +++-
 .../Routes/Tree/CourseInfoOfTreeNode.php      |  3 +-
 lib/models/StudipStudyArea.class.php          |  4 +-
 .../stylesheets/scss/loading-skeleton.scss    |  5 +++
 resources/assets/stylesheets/scss/tree.scss   | 10 ++++-
 resources/assets/stylesheets/studip.scss      |  1 +
 .../vue/components/StudipLoadingSkeleton.vue  |  9 ++++
 .../vue/components/tree/StudipTreeList.vue    | 41 ++++++++++++++---
 .../vue/components/tree/StudipTreeNode.vue    |  2 +-
 .../vue/components/tree/StudipTreeTable.vue   | 43 +++++++++++++++---
 .../components/tree/TreeNodeCourseInfo.vue    | 45 +++++++------------
 .../vue/components/tree/TreeNodeTile.vue      |  7 ++-
 resources/vue/mixins/TreeMixin.js             | 19 +++++++-
 14 files changed, 156 insertions(+), 56 deletions(-)
 create mode 100644 resources/assets/stylesheets/scss/loading-skeleton.scss
 create mode 100644 resources/vue/components/StudipLoadingSkeleton.vue

diff --git a/app/controllers/search/courses.php b/app/controllers/search/courses.php
index 50a348066fa..9e0b45d6e31 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 c6f49050601..500c31e7f69 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 283593150cf..e684c40301b 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 444ca212912..2d13b186eff 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 00000000000..480255fe502
--- /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 dbad68b1bc5..1d700f92eb5 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 e45cdb3f63f..b0e768ea172 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 00000000000..90b97339703
--- /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 b607e02197a..4ba08bcbb81 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 39f696ccc14..2b5676e24a3 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 585acd42604..03a9d7b60e9 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 db31d148c3f..3f3777c9338 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 cbb0679eaef..dab2bdf9ff6 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 9c4c859d7b0..4263eaf938d 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;
+                });
         }
     }
 }
-- 
GitLab