aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorThomas Hackl <hackl@data-quest.de>2023-06-28 13:27:46 +0000
committerThomas Hackl <hackl@data-quest.de>2023-06-28 13:27:46 +0000
commit559ab723fabd4d10f26e7df631808e4cb8d91c9b (patch)
tree91ef8cf94eba86973baf3efabca1cdbb8bf6826b
parentb7f0f8bcaad8fefd96fd3e6316377eda53929ad3 (diff)
Resolve "Neuentwicklung Verzeichnisstrukturen"
Closes #1664, #2693, and #2692 Merge request studip/studip!1081
-rw-r--r--app/controllers/admin/courses.php8
-rw-r--r--app/controllers/admin/tree.php292
-rw-r--r--app/controllers/search/courses.php143
-rw-r--r--app/controllers/studyarea.php51
-rw-r--r--app/controllers/tree.php55
-rw-r--r--app/views/admin/courses/batch_assign_semtree.php8
-rw-r--r--app/views/admin/courses/courses.php3
-rw-r--r--app/views/admin/tree/assign_courses.php10
-rw-r--r--app/views/admin/tree/batch_assign_semtree.php43
-rw-r--r--app/views/admin/tree/create.php49
-rw-r--r--app/views/admin/tree/edit.php54
-rw-r--r--app/views/admin/tree/rangetree.php9
-rw-r--r--app/views/admin/tree/semtree.php10
-rw-r--r--app/views/search/courses/index.php14
-rw-r--r--app/views/studyarea/edit.php1
-rw-r--r--db/migrations/5.4.6_tree_changes.php66
-rw-r--r--lib/classes/JsonApi/RouteMap.php12
-rw-r--r--lib/classes/JsonApi/Routes/RangeTree/ChildrenOfRangeTreeNode.php39
-rw-r--r--lib/classes/JsonApi/Routes/RangeTree/CoursesOfRangeTreeNode.php51
-rw-r--r--lib/classes/JsonApi/Routes/RangeTree/InstituteOfRangeTreeNode.php30
-rw-r--r--lib/classes/JsonApi/Routes/RangeTree/ParentOfRangeTreeNode.php32
-rw-r--r--lib/classes/JsonApi/Routes/RangeTree/RangeTreeIndex.php53
-rw-r--r--lib/classes/JsonApi/Routes/RangeTree/RangeTreeShow.php32
-rw-r--r--lib/classes/JsonApi/Routes/StudyAreas/StudyAreasShow.php3
-rw-r--r--lib/classes/JsonApi/Routes/Tree/ChildrenOfTreeNode.php50
-rw-r--r--lib/classes/JsonApi/Routes/Tree/CourseInfoOfTreeNode.php83
-rw-r--r--lib/classes/JsonApi/Routes/Tree/CoursesOfTreeNode.php112
-rw-r--r--lib/classes/JsonApi/Routes/Tree/DetailsOfTreeNodeCourse.php79
-rw-r--r--lib/classes/JsonApi/Routes/Tree/PathinfoOfTreeNodeCourse.php34
-rw-r--r--lib/classes/JsonApi/Routes/Tree/TreeShow.php40
-rw-r--r--lib/classes/JsonApi/SchemaMap.php2
-rw-r--r--lib/classes/JsonApi/Schemas/StudyArea.php4
-rw-r--r--lib/classes/JsonApi/Schemas/TreeNode.php151
-rw-r--r--lib/classes/SemBrowse.class.php10
-rw-r--r--lib/classes/StudipTreeNode.php114
-rw-r--r--lib/classes/forms/QuicksearchInput.php2
-rw-r--r--lib/classes/searchtypes/TreeSearch.class.php96
-rw-r--r--lib/models/RangeTreeNode.php263
-rw-r--r--lib/models/StudipStudyArea.class.php218
-rw-r--r--lib/navigation/AdminNavigation.php4
-rw-r--r--public/admin_range_tree.php59
-rw-r--r--public/admin_sem_tree.php310
-rw-r--r--resources/assets/javascripts/bootstrap/treeview.js12
-rw-r--r--resources/assets/javascripts/entry-base.js1
-rw-r--r--resources/assets/stylesheets/scss/sidebar.scss2
-rw-r--r--resources/assets/stylesheets/scss/tree.scss384
-rw-r--r--resources/assets/stylesheets/studip.scss1
-rw-r--r--resources/vue/components/SearchWidget.vue56
-rw-r--r--resources/vue/components/StudipProgressIndicator.vue2
-rw-r--r--resources/vue/components/tree/AssignLinkWidget.vue46
-rw-r--r--resources/vue/components/tree/StudipTree.vue214
-rw-r--r--resources/vue/components/tree/StudipTreeList.vue359
-rw-r--r--resources/vue/components/tree/StudipTreeNode.vue277
-rw-r--r--resources/vue/components/tree/StudipTreeTable.vue377
-rw-r--r--resources/vue/components/tree/TreeBreadcrumb.vue194
-rw-r--r--resources/vue/components/tree/TreeCourseDetails.vue51
-rw-r--r--resources/vue/components/tree/TreeExportWidget.vue50
-rw-r--r--resources/vue/components/tree/TreeNodeCourseInfo.vue76
-rw-r--r--resources/vue/components/tree/TreeNodeCoursePath.vue54
-rw-r--r--resources/vue/components/tree/TreeNodeTile.vue46
-rw-r--r--resources/vue/components/tree/TreeSearchResult.vue85
-rw-r--r--resources/vue/mixins/TreeMixin.js108
62 files changed, 4581 insertions, 513 deletions
diff --git a/app/controllers/admin/courses.php b/app/controllers/admin/courses.php
index a683639..71b75ce 100644
--- a/app/controllers/admin/courses.php
+++ b/app/controllers/admin/courses.php
@@ -1042,6 +1042,14 @@ class Admin_CoursesController extends AuthenticatedController
'attributes' => ['data-dialog' => 'size=auto'],
'partial' => 'notice-action.php',
],
+ 21 => [
+ 'name' => _('Mehrfachzuordnung von Studienbereichen'),
+ 'title' => _('Mehrfachzuordnung von Studienbereichen'),
+ 'url' => 'dispatch.php/admin/tree/batch_assign_semtree',
+ 'dialogform' => true,
+ 'multimode' => true,
+ 'partial' => 'batch_assign_semtree.php'
+ ],
];
if (!$GLOBALS['perm']->have_perm('admin')) {
diff --git a/app/controllers/admin/tree.php b/app/controllers/admin/tree.php
new file mode 100644
index 0000000..18ddb06
--- /dev/null
+++ b/app/controllers/admin/tree.php
@@ -0,0 +1,292 @@
+<?php
+
+class Admin_TreeController extends AuthenticatedController
+{
+ public function rangetree_action()
+ {
+ $GLOBALS['perm']->check('root');
+ Navigation::activateItem('/admin/locations/range_tree');
+ PageLayout::setTitle(_('Einrichtungshierarchie bearbeiten'));
+ $this->startId = Request::get('node_id', 'RangeTreeNode_root');
+ $this->semester = Request::option('semester', Semester::findCurrent()->id);
+ $this->classname = RangeTreeNode::class;
+ $this->setupSidebar();
+ }
+
+ public function semtree_action()
+ {
+ $GLOBALS['perm']->check('root');
+ Navigation::activateItem('/admin/locations/sem_tree');
+ PageLayout::setTitle(_('Veranstaltungshierarchie bearbeiten'));
+ $this->startId = Request::get('node_id', 'StudipStudyArea_root');
+ $this->semester = Request::option('semester', Semester::findCurrent()->id);
+ $this->classname = StudipStudyArea::class;
+ $this->setupSidebar();
+ }
+
+ /**
+ * Edit the given node.
+ *
+ * @param string $class_id concatenated classname and node id
+ * @return void
+ */
+ public function edit_action(string $class_id)
+ {
+ $GLOBALS['perm']->check('root');
+ PageLayout::setTitle(_('Eintrag bearbeiten'));
+
+ $data = $this->checkClassAndId($class_id);
+ $this->node = $data['classname']::getNode($data['id']);
+ $parent = $data['classname']::getNode($this->node->parent_id);
+
+ $this->treesearch = QuickSearch::get(
+ 'parent_id',
+ new TreeSearch($data['classname'] === StudipStudyArea::class ? 'sem_tree_id' : 'range_tree_id')
+ )->withButton();
+ $this->treesearch->defaultValue($parent->id, $parent->getName());
+
+ if ($data['classname'] === RangeTreeNode::class) {
+ $this->instsearch = QuickSearch::get(
+ 'studip_object_id',
+ new StandardSearch('Institut_id')
+ )->withButton();
+ if ($this->node->studip_object_id) {
+ $this->instsearch->defaultValue($this->node->studip_object_id, $this->node->institute->name);
+ }
+ }
+
+ $this->from = Request::get('from');
+ }
+
+ /**
+ * Create a new child node of the given parent.
+ *
+ * @param string $class_id concatenated classname and parent id
+ * @return void
+ */
+ public function create_action(string $class_id)
+ {
+ $GLOBALS['perm']->check('root');
+ PageLayout::setTitle(_('Neuen Eintrag anlegen'));
+
+ $data = $this->checkClassAndId($class_id);
+
+ $this->node = new $data['classname']();
+ $this->node->parent_id = $data['id'];
+ $parent = $data['classname']::getNode($data['id']);
+
+ $this->treesearch = QuickSearch::get(
+ 'parent_id',
+ new TreeSearch(get_class($this->node) === StudipStudyArea::class ? 'sem_tree_id' : 'range_tree_id')
+ )->withButton();
+ $this->treesearch->defaultValue($parent->id, $parent->getName());
+
+ $this->instsearch = QuickSearch::get(
+ 'studip_object_id',
+ new StandardSearch('Institut_id')
+ )->withButton();
+
+ $this->from = Request::get('from');
+ }
+
+ /**
+ * Delete the given child node.
+ *
+ * @param string $class_id concatenated classname and node id
+ * @return void
+ */
+ public function delete_action(string $class_id)
+ {
+ $GLOBALS['perm']->check('root');
+ $data = $this->checkClassAndId($class_id);
+
+ if (!Request::isPost()) {
+ throw new MethodNotAllowedException();
+ }
+ $node = $data['classname']::getNode($data['id']);
+
+ if ($node) {
+ $node->delete();
+ } else {
+ $this->set_status(404);
+ }
+
+ $this->render_nothing();
+ }
+
+ /**
+ * Store the given node.
+ *
+ * @param string $classname
+ * @param string $node_id
+ * @return void
+ */
+ public function store_action(string $classname, string $node_id = '')
+ {
+ $GLOBALS['perm']->check('root');
+ CSRFProtection::verifyUnsafeRequest();
+
+ $node = new $classname($node_id);
+ $node->parent_id = Request::option('parent_id');
+
+ $parent = $classname::getNode(Request::option('parent_id'));
+ $maxprio = max(array_map(
+ function ($c) {
+ return $c->priority;
+ },
+ $parent->getChildNodes()
+ ));
+ $node->priority = $maxprio + 1;
+
+ if (Request::option('studip_object_id')) {
+ $node->studip_object_id = Request::option('studip_object_id');
+ $node->name = '';
+ } else {
+ $node->name = Request::get('name');
+ }
+
+ if ($classname === StudipStudyArea::class) {
+ $node->info = Request::get('description');
+ $node->type = Request::int('type');
+ }
+
+ if ($node->store() !== false) {
+ Pagelayout::postSuccess(_('Die Daten wurden gespeichert.'));
+ } else {
+ Pagelayout::postError(_('Die Daten konnten nicht gespeichert werden.'));
+ }
+
+ $this->relocate(Request::get('from'));
+ }
+
+ public function sort_action($parent_id)
+ {
+ $GLOBALS['perm']->check('root');
+ $data = $this->checkClassAndId($parent_id);
+
+ $parent = $data['classname']::getNode($data['id']);
+ $children = $parent->getChildNodes();
+
+ $data = json_decode(Request::get('sorting'), true);
+
+ foreach ($children as $child) {
+ $child->priority = $data[$child->id];
+ $child->store();
+ }
+
+ $this->render_nothing();
+ }
+
+ /**
+ * (De-)assign several courses at once to a sem_tree node
+ * @return void
+ * @throws Exception
+ */
+ public function batch_assign_semtree_action()
+ {
+ $GLOBALS['perm']->check('admin');
+ //set the page title with the area of Stud.IP:
+ PageLayout::setTitle(_('Veranstaltungszuordnungen bearbeiten'));
+ Navigation::activateItem('/browse/my_courses/list');
+
+ $GLOBALS['perm']->check('admin');
+
+ // check the assign_semtree array and extract the relevant course IDs:
+ $courseIds = Request::optionArray('assign_semtree');
+
+ $order = Config::get()->IMPORTANT_SEMNUMBER
+ ? "ORDER BY `start_time` DESC, `VeranstaltungsNummer`, `Name`"
+ : "ORDER BY `start_time` DESC, `Name`";
+ $this->courses = Course::findMany($courseIds, $order);
+
+ $this->return = Request::get('return');
+
+ // check if at least one course was selected (this can only happen from admin courses overview):
+ if (!$courseIds) {
+ PageLayout::postWarning('Es wurde keine Veranstaltung gewählt.');
+ $this->relocate('admin/courses');
+ }
+ }
+
+ public function assign_courses_action($class_id)
+ {
+ $GLOBALS['perm']->check('root');
+ $data = $this->checkClassAndId($class_id);
+ $GLOBALS['perm']->check('admin');
+
+ $this->search = QuickSearch::get('courses[]', new StandardSearch('Seminar_id'))->withButton();
+ $this->node = $data['id'];
+ }
+
+ /**
+ * Store (de-)assignments from courses to sem_tree nodes.
+ * @return void
+ */
+ public function do_batch_assign_action()
+ {
+ $GLOBALS['perm']->check('admin');
+ $astmt = DBManager::get()->prepare("INSERT IGNORE INTO `seminar_sem_tree` VALUES (:course, :node)");
+ $dstmt = DBManager::get()->prepare(
+ "DELETE FROM `seminar_sem_tree` WHERE `seminar_id` IN (:courses) AND `sem_tree_id` = :node");
+
+ $success = true;
+ // Add course assignments to the specified nodes.
+ foreach (Request::optionArray('courses') as $course) {
+ foreach (Request::optionArray('add_assignments') as $a) {
+ $success = $astmt->execute(['course' => $course, 'node' => $a]);
+ }
+ }
+
+ // Remove course assignments from the specified nodes.
+ foreach (Request::optionArray('delete_assignments') as $d) {
+ $success = $dstmt->execute(['courses' => Request::optionArray('courses'), 'node' => $d]);
+ }
+
+ if ($success) {
+ PageLayout::postSuccess(_('Die Zuordnungen wurden gespeichert.'));
+ } else {
+ PageLayout::postError(_('Die Zuordnungen konnten nicht vollständig gespeichert werden.'));
+ }
+
+ $this->relocate(Request::get('return', 'admin/courses'));
+ }
+
+ private function setupSidebar()
+ {
+ $sidebar = Sidebar::Get();
+
+ $semWidget = new SemesterSelectorWidget($this->url_for(''), 'semester');
+ $semWidget->includeAll(true);
+ $semWidget->setId('semester-selector');
+ $semWidget->setSelection($this->semester);
+ $sidebar->addWidget($semWidget);
+
+ if ($this->classname === StudipStudyArea::class) {
+ $sidebar->addWidget(new VueWidget('assign-widget'));
+ }
+ }
+
+ /**
+ * CHeck a combination of class name and ID for validity: is this a StudipTreeNode subclass?
+ * If yes, return the corresponding object.
+ *
+ * @param string $class_id class name and ID, separated by '_'
+ * @return mixed
+ */
+ private function checkClassAndId($class_id)
+ {
+ list($classname, $id) = explode('_', $class_id);
+
+ if (is_a($classname, StudipTreeNode::class, true)) {
+ return [
+ 'classname' => $classname,
+ 'id' => $id
+ ];
+ }
+
+ throw new InvalidArgumentException(
+ sprintf('The given class "%s" does not implement the StudipTreeNode interface!', $classname)
+ );
+
+ }
+}
diff --git a/app/controllers/search/courses.php b/app/controllers/search/courses.php
index 536c361..71b7f7d 100644
--- a/app/controllers/search/courses.php
+++ b/app/controllers/search/courses.php
@@ -27,108 +27,30 @@ class Search_CoursesController extends AuthenticatedController
PageLayout::setHelpKeyword('Basis.VeranstaltungenAbonnieren');
- // activate navigation item
- $nav_options = Config::get()->COURSE_SEARCH_NAVIGATION_OPTIONS;
- URLHelper::bindLinkParam('option', $this->nav_option);
- if (!empty($nav_options[$this->nav_option])
- && Navigation::hasItem('/search/courses/' . $this->nav_option)) {
- Navigation::activateItem('/search/courses/' . $this->nav_option);
- } else {
- URLHelper::removeLinkParam('option');
- $level = Request::get('level', $_SESSION['sem_browse_data']['level'] ?? '');
- $default_option = SemBrowse::getSearchOptionNavigation('sidebar');
- if (!$level) {
- PageLayout::setTitle(_($default_option->getTitle()));
- $this->relocate($default_option->getURL());
- } elseif ($level == 'f' && $nav_options['courses']['visible']) {
- $course_option = SemBrowse::getSearchOptionNavigation('sidebar','courses');
- PageLayout::setTitle(_($course_option->getTitle()));
- Navigation::activateItem('/search/courses/semtree');
- } elseif (($level == 'vv') && $nav_options['semtree']['visible']) {
- $semtree_option = SemBrowse::getSearchOptionNavigation('sidebar','semtree');
- PageLayout::setTitle(_($semtree_option->getTitle()));
- Navigation::activateItem('/search/courses/semtree');
- } elseif ($level == 'ev' && $nav_options['rangetree']['visible']) {
- $rangetree_option = SemBrowse::getSearchOptionNavigation('sidebar','rangetree');
- PageLayout::setTitle(_($rangetree_option->getTitle()));
- Navigation::activateItem('/search/courses/rangetree');
- } else {
- throw new AccessDeniedException();
- }
- }
+ $this->type = Request::option('type', 'semtree');
+ $this->semester = Request::option('semester', Semester::findCurrent()->id);
+ $this->semClass = Request::int('semclass', 0);
}
public function index_action()
{
- SemBrowse::transferSessionData();
- $this->sem_browse_obj = new SemBrowse();
-
- if (!$GLOBALS['perm']->have_perm('root')) {
- $this->sem_browse_obj->target_url = 'dispatch.php/course/details/';
- $this->sem_browse_obj->target_id = 'sem_id';
- } else {
- $this->sem_browse_obj->target_url = 'seminar_main.php';
- $this->sem_browse_obj->target_id = 'auswahl';
- }
-
- $sidebar = Sidebar::get();
-
- // add search options to sidebar
- $level = Request::get('level', $_SESSION['sem_browse_data']['level'] ?? '');
-
- $widget = new OptionsWidget();
- $widget->setTitle(_('Suche'));
- //add a quicksearch input inside the widget
- $search_content = $this->sem_browse_obj->getQuickSearchForm();
- $search_element = new WidgetElement($search_content);
- $widget->addElement($search_element);
- $widget->addCheckbox(_('Erweiterte Suche anzeigen'),
- $_SESSION['sem_browse_data']['cmd'] == "xts",
- URLHelper::getURL('?level='.$level.'&cmd=xts&sset=0&option='),
- URLHelper::getURL('?level='.$level.'&cmd=qs&sset=0&option='));
- $sidebar->addWidget($widget);
-
- SemBrowse::setSemesterSelector($this->url_for('search/courses/index'));
- SemBrowse::setClassesSelector($this->url_for('search/courses/index'));
-
-
- if ($this->sem_browse_obj->show_result
- && count($_SESSION['sem_browse_data']['search_result'])) {
- $actions = new ActionsWidget();
- $actions->addLink(_('Download des Ergebnisses'),
- URLHelper::getURL('dispatch.php/search/courses/export_results'),
- Icon::create('file-office', 'clickable'));
- $sidebar->addWidget($actions);
-
- $grouping = new OptionsWidget();
- $grouping->setTitle(_('Suchergebnis gruppieren:'));
- foreach ($this->sem_browse_obj->group_by_fields as $i => $field) {
- $grouping->addRadioButton(
- $field['name'],
- URLHelper::getURL('?', ['group_by' => $i,
- 'keep_result_set' => 1]),
- $_SESSION['sem_browse_data']['group_by'] == $i
- );
- }
- $sidebar->addWidget($grouping);
- }
-
- // show information about course class if class was changed
- $class = $GLOBALS['SEM_CLASS'][$_SESSION['sem_browse_data']['show_class']] ?? null;
- if (is_object($class) && $class->countSeminars() > 0) {
- if (trim($GLOBALS['SEM_CLASS'][$_SESSION['sem_browse_data']['show_class']]['description'])) {
- PageLayout::postInfo(sprintf(_('Gewählte Veranstaltungsklasse <i>%1s</i>: %2s'),
- $GLOBALS['SEM_CLASS'][$_SESSION['sem_browse_data']['show_class']]['name'],
- $GLOBALS['SEM_CLASS'][$_SESSION['sem_browse_data']['show_class']]['description']));
- } else {
- PageLayout::postInfo(sprintf(_('Gewählte Veranstaltungsklasse <i>%1s</i>.'),
- $GLOBALS['SEM_CLASS'][$_SESSION['sem_browse_data']['show_class']]['name']));
- }
- } elseif ($_SESSION['sem_browse_data']['show_class'] != 'all') {
- PageLayout::postInfo(_('Im gewählten Semester ist in dieser Veranstaltungsklasse keine Veranstaltung verfügbar. Bitte wählen Sie eine andere Veranstaltungsklasse oder ein anderes Semester!'));
+ $nodeClass = '';
+ if (Request::option('type', 'semtree') === 'semtree') {
+ Navigation::activateItem('/search/courses/semtree');
+ $nodeClass = StudipStudyArea::class;
+ $this->treeTitle = _('Studienbereiche');
+ $this->breadcrumbIcon = 'literature';
+ $this->editUrl = $this->url_for('studyarea/edit');
+ } else if (Request::option('type', 'semtree') === 'rangetree') {
+ Navigation::activateItem('/search/courses/rangetree');
+ $nodeClass = RangeTreeNode::class;
+ $this->treeTitle = _('Einrichtungen');
+ $this->breadcrumbIcon = 'institute';
+ $this->editUrl = $this->url_for('rangetree/edit');
}
+ $this->startId = Request::option('node_id', $nodeClass . '_root');
- $this->controller = $this;
+ $this->setupSidebar();
}
public function export_results_action()
@@ -143,4 +65,33 @@ class Search_CoursesController extends AuthenticatedController
}
}
+ private function setupSidebar()
+ {
+ $sidebar = Sidebar::Get();
+
+ $semWidget = new SemesterSelectorWidget($this->url_for(''), 'semester');
+ $semWidget->includeAll(false);
+ $semWidget->setId('semester-selector');
+ $semWidget->setSelection($this->semester);
+ $sidebar->addWidget($semWidget);
+
+ $classWidget = $sidebar->addWidget(new SelectWidget(
+ _('Veranstaltungskategorie'),
+ URLHelper::getURL('', ['type' => $this->type, 'semester' => $this->semester]),
+ 'semclass'
+ ));
+ $classWidget->addElement(new SelectElement(0, _('Alle')));
+ foreach (SemClass::getClasses() as $class) {
+ if (!$class['studygroup_mode']) {
+ $classWidget->addElement(new SelectElement(
+ $class['id'],
+ $class['name'],
+ $this->semClass == $class['id']
+ ));
+ }
+ }
+
+ $sidebar->addWidget(new VueWidget('search-widget'));
+ $sidebar->addWidget(new VueWidget('export-widget'));
+ }
}
diff --git a/app/controllers/studyarea.php b/app/controllers/studyarea.php
new file mode 100644
index 0000000..e43c9b0
--- /dev/null
+++ b/app/controllers/studyarea.php
@@ -0,0 +1,51 @@
+<?php
+
+/**
+ * treenode.php - Controller for editing tree nodes
+ *
+ * This program is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU General Public License as
+ * published by the Free Software Foundation; either version 2 of
+ * the License, or (at your option) any later version.
+ *
+ * @author Thomas Hackl <hackl@data-quest.de>
+ * @license http://www.gnu.org/licenses/gpl-2.0.html GPL version 2 or later
+ * @category Stud.IP
+ * @since 5.4
+ */
+
+class StudyareaController extends AuthenticatedController
+{
+ public function edit_action($id = '')
+ {
+ if ($id !== '') {
+ $object = StudipStudyArea::find($id);
+ } else {
+ $object = new StudipStudyArea();
+ }
+
+ PageLayout::setTitle($object->isNew() ? _('Studienbereich anlegen') : _('Studienbereich bearbeiten'));
+
+ $this->form = Studip\Forms\Form::fromSORM(
+ $object,
+ [
+ 'legend' => $object->isNew()
+ ? _('Neuer Studienbereich')
+ : sprintf(_('Studienbereich %s'), $object->name),
+ 'text' => ['text' => ''],
+ 'fields' => [
+ 'name' => [
+ 'label' => _('Name'),
+ 'type' => 'text',
+ 'required' => true
+ ],
+ 'info' => [
+ 'label' => _('Beschreibung'),
+ 'type' => 'textarea'
+ ]
+ ]
+ ]
+ )->setURL($this->url_for('studyarea/store', $object->id));
+ }
+
+}
diff --git a/app/controllers/tree.php b/app/controllers/tree.php
new file mode 100644
index 0000000..7665b60
--- /dev/null
+++ b/app/controllers/tree.php
@@ -0,0 +1,55 @@
+<?php
+
+class TreeController extends AuthenticatedController
+{
+ public function export_csv_action()
+ {
+ if (!Request::isPost()) {
+ throw new MethodNotAllowedException();
+ }
+
+ $ids = explode(',', Request::get('courses', ''));
+ $courses = Course::findMany($ids);
+
+ $captions = [
+ _('Veranstaltungsnummer'),
+ _('Name'),
+ _('Semester'),
+ _('Zeiten'),
+ _('Lehrende')
+ ];
+
+ $data = [];
+ foreach ($courses as $course) {
+ $sem = Seminar::getInstance($course->id);
+ $lecturers = SimpleCollection::createFromArray(
+ CourseMember::findByCourseAndStatus($course->id, 'dozent')
+ )->orderBy('position, nachname, vorname');
+
+ $lecturersSorted = array_map(
+ function ($l) {
+ return implode(', ', $l);
+ },
+ $lecturers->toArray('nachname vorname title_front title_rear')
+ );
+
+ $data[] = [
+ $course->veranstaltungsnummer,
+ $course->getFullname('type-number-name'),
+ $course->getTextualSemester(),
+ $sem->getDatesExport(),
+ implode(', ', $lecturersSorted)
+ ];
+ }
+
+ $tmpname = md5(uniqid('ErgebnisVeranstaltungssuche'));
+ if (array_to_csv($data, $GLOBALS['TMP_PATH'] . '/' . $tmpname, $captions)) {
+ $this->render_text(FileManager::getDownloadURLForTemporaryFile(
+ $tmpname,
+ 'veranstaltungssuche.csv'
+ ));
+ } else {
+ $this->set_status(400, 'The csv could not be created.');
+ }
+ }
+}
diff --git a/app/views/admin/courses/batch_assign_semtree.php b/app/views/admin/courses/batch_assign_semtree.php
new file mode 100644
index 0000000..b28e96b
--- /dev/null
+++ b/app/views/admin/courses/batch_assign_semtree.php
@@ -0,0 +1,8 @@
+<?php
+/**
+ * @var Course $course
+ */
+?>
+<label>
+ <input name="assign_semtree[]" type="checkbox" value="<?= htmlReady($course->id) ?>">
+</label>
diff --git a/app/views/admin/courses/courses.php b/app/views/admin/courses/courses.php
index 0d19b00..830ac4d 100644
--- a/app/views/admin/courses/courses.php
+++ b/app/views/admin/courses/courses.php
@@ -14,7 +14,8 @@ $colspan = 2
?>
<? if (!empty($actions[$selected_action]['multimode'])) : ?>
- <form action="<?= URLHelper::getLink($actions[$selected_action]['url']) ?>" method="post">
+ <form action="<?= URLHelper::getLink($actions[$selected_action]['url']) ?>" method="post"
+ <?= !empty($actions[$selected_action]['dialogform']) ? ' data-dialog="auto"' : '' ?>>
<? endif ?>
<?= CSRFProtection::tokenTag() ?>
<table class="default course-admin">
diff --git a/app/views/admin/tree/assign_courses.php b/app/views/admin/tree/assign_courses.php
new file mode 100644
index 0000000..df57aef
--- /dev/null
+++ b/app/views/admin/tree/assign_courses.php
@@ -0,0 +1,10 @@
+<form action="<?= $controller->link_for('admin/tree/do_batch_assign') ?>" method="post">
+ <section>
+ <?= $search->render() ?>
+ </section>
+ <input type="hidden" name="node" value="<?= htmlReady($node) ?>">
+ <footer data-dialog-button>
+ <?= Studip\Button::createAccept(_('Zuordnen'), 'assign') ?>
+ <?= Studip\Button::createCancel(_('Abbrechen'), 'cancel', ['data-dialog' => 'close']) ?>
+ </footer>
+</form>
diff --git a/app/views/admin/tree/batch_assign_semtree.php b/app/views/admin/tree/batch_assign_semtree.php
new file mode 100644
index 0000000..c286602
--- /dev/null
+++ b/app/views/admin/tree/batch_assign_semtree.php
@@ -0,0 +1,43 @@
+<form class="default" action="<?= $controller->link_for('admin/tree/do_batch_assign') ?>" method="post">
+ <fieldset>
+ <legend><?= _('Studienbereichszuordnungen der ausgewählten Veranstaltungen bearbeiten') ?></legend>
+ <div data-studip-tree>
+ <studip-tree start-id="StudipStudyArea_root" :with-info="false" :open-levels="1"
+ :assignable="true"></studip-tree>
+ </div>
+ </fieldset>
+ <fieldset>
+ <legend><?= _('Diese Veranstaltungen werden zugewiesen') ?></legend>
+ <table class="default selected-courses">
+ <colgroup>
+ <col>
+ </colgroup>
+ <thead>
+ <tr>
+ <th><?= _('Name') ?></th>
+ </tr>
+ </thead>
+ <tbody>
+ <? foreach ($courses as $course) : ?>
+ <tr>
+ <td>
+ <a href="<?= URLHelper::getLink('dispatch.php/course/overview', ['cid' => $course->id])?>"
+ title="<?= sprintf(_('Zur Veranstaltung %s'), htmlReady($course->getFullname())) ?>"
+ target="_blank">
+ <?= htmlReady($course->getFullname('number-name-semester')) ?>
+ </a>
+ <input type="hidden" name="courses[]" value="<?= htmlReady($course->id) ?>">
+ </td>
+ </tr>
+ <? endforeach ?>
+ </tbody>
+ </table>
+ </fieldset>
+ <? if ($return) : ?>
+ <input type="hidden" name="return" value="<?= htmlReady($return) ?>">
+ <? endif ?>
+ <footer data-dialog-button>
+ <?= Studip\Button::createAccept(_('Speichern'), 'store') ?>
+ <?= Studip\Button::createCancel(_('Abbrechen'), 'cancel', ['data-dialog' => 'close']) ?>
+ </footer>
+</form>
diff --git a/app/views/admin/tree/create.php b/app/views/admin/tree/create.php
new file mode 100644
index 0000000..6b7255f
--- /dev/null
+++ b/app/views/admin/tree/create.php
@@ -0,0 +1,49 @@
+<form class="default" action="<?= $controller->link_for('admin/tree/store', get_class($node), $node->id ?: null) ?>" method="post">
+ <section>
+ <label>
+ <?= _('Name') ?>
+ <input type="text" name="name"
+ placeholder="<?= _('Name des Eintrags (wird bei Zuweisung zu einer Stud.IP-Einrichtung überschrieben)') ?>">
+ </label>
+ </section>
+ <? if (get_class($node) === StudipStudyArea::class): ?>
+ <section>
+ <label>
+ <?= _('Infotext') ?>
+ <textarea name="description" rows="3"></textarea>
+ </label>
+ </section>
+ <section>
+ <label>
+ <?= _('Typ') ?>
+ <select name="type">
+ <? foreach ($GLOBALS['SEM_TREE_TYPES'] as $index => $type) : ?>
+ <option value="<?= htmlReady($index) ?>">
+ <?= $type['name'] ?: _('Standard') ?>
+ <?= !$type['editable'] ? _('(nicht mehr nachträglich änderbar)') : '' ?>
+ <?= $type['hidden'] ? _('(dieser Knoten ist versteckt)') : '' ?>
+ </option>
+ <? endforeach ?>
+ </select>
+ </label>
+ </section>
+ <? endif ?>
+ <section>
+ <label>
+ <?= _('Elternelement') ?>
+ <?= $treesearch->render() ?>
+ </label>
+ </section>
+ <section>
+ <label>
+ <?= _('Zu einer Stud.IP-Einrichtung zuordnen') ?>
+ <?= $instsearch->render() ?>
+ </label>
+ </section>
+ <input type="hidden" name="from" value="<?= htmlReady($from) ?>">
+ <?= CSRFProtection::tokenTag() ?>
+ <footer data-dialog-button>
+ <?= Studip\Button::createAccept(_('Speichern'), 'store') ?>
+ <?= Studip\Button::createCancel(_('Abbrechen'), 'cancel', ['data-dialog' => 'close']) ?>
+ </footer>
+</form>
diff --git a/app/views/admin/tree/edit.php b/app/views/admin/tree/edit.php
new file mode 100644
index 0000000..0076b95
--- /dev/null
+++ b/app/views/admin/tree/edit.php
@@ -0,0 +1,54 @@
+<form class="default" action="<?= $controller->link_for('admin/tree/store', get_class($node), $node->id) ?>" method="post">
+ <section>
+ <label>
+ <?= (get_class($node) === RangeTreeNode::class && $node->studip_object_id)
+ ? _('Name (kann hier nicht bearbeitet werden, da es sich um ein Stud.IP-Objekt handelt)')
+ : _('Name') ?>
+ <input type="text" name="name"
+ value="<?= htmlReady($node->getName()) ?>"
+ <?= get_class($node) === RangeTreeNode::class && $node->studip_object_id ? ' disabled' : '' ?>>
+ </label>
+ </section>
+ <? if (get_class($node) === StudipStudyArea::class): ?>
+ <section>
+ <label>
+ <?= _('Infotext') ?>
+ <textarea name="description" rows="3"><?= htmlReady($node->info) ?></textarea>
+ </label>
+ </section>
+ <section>
+ <label>
+ <?= _('Typ') ?>
+ <select name="type"<?= empty($GLOBALS['SEM_TREE_TYPES'][$node->type]['editable']) ? ' disabled' : '' ?>>
+ <? foreach ($GLOBALS['SEM_TREE_TYPES'] as $index => $type) : ?>
+ <option value="<?= htmlReady($index) ?>"<?= $node->type == $index ? ' selected' : '' ?>>
+ <?= $type['name'] ?: _('Standard') ?>
+ <?= !$type['editable'] ? _('(nicht mehr nachträglich änderbar)') : '' ?>
+ <?= $type['hidden'] ? _('(dieser Knoten ist versteckt)') : '' ?>
+ </option>
+ <? endforeach ?>
+ </select>
+ </label>
+ </section>
+ <? endif ?>
+ <section>
+ <label>
+ <?= _('Elternelement') ?>
+ <?= $treesearch->render() ?>
+ </label>
+ </section>
+ <? if (get_class($node) === RangeTreeNode::class): ?>
+ <section>
+ <label>
+ <?= _('Zu einer Stud.IP-Einrichtung zuordnen') ?>
+ <?= $instsearch->render() ?>
+ </label>
+ </section>
+ <? endif ?>
+ <input type="hidden" name="from" value="<?= $from ?>">
+ <?= CSRFProtection::tokenTag() ?>
+ <footer data-dialog-button>
+ <?= Studip\Button::createAccept(_('Speichern'), 'store') ?>
+ <?= Studip\Button::createCancel(_('Abbrechen'), 'cancel', ['data-dialog' => 'close']) ?>
+ </footer>
+</form>
diff --git a/app/views/admin/tree/rangetree.php b/app/views/admin/tree/rangetree.php
new file mode 100644
index 0000000..1e3e945
--- /dev/null
+++ b/app/views/admin/tree/rangetree.php
@@ -0,0 +1,9 @@
+<div data-studip-tree>
+ <studip-tree start-id="<?= htmlReady($startId) ?>" view-type="table" breadcrumb-icon="institute"
+ :with-search="false" :visible-children-only="false"
+ :editable="true" edit-url="<?= $controller->url_for('admin/tree/edit') ?>"
+ create-url="<?= $controller->url_for('admin/tree/create') ?>"
+ delete-url="<?= $controller->url_for('admin/tree/delete') ?>"
+ :with-courses="true" semester="<?= htmlReady($semester) ?>" :show-structure-as-navigation="true"
+ title="<?= _('Einrichtungshierarchie bearbeiten') ?>"></studip-tree>
+</div>
diff --git a/app/views/admin/tree/semtree.php b/app/views/admin/tree/semtree.php
new file mode 100644
index 0000000..0c48245
--- /dev/null
+++ b/app/views/admin/tree/semtree.php
@@ -0,0 +1,10 @@
+<div data-studip-tree>
+ <studip-tree start-id="<?= htmlReady($startId) ?>" view-type="table" breadcrumb-icon="literature"
+ :with-search="false" :visible-children-only="false"
+ :editable="true" edit-url="<?= $controller->url_for('admin/tree/edit') ?>"
+ create-url="<?= $controller->url_for('admin/tree/create') ?>"
+ delete-url="<?= $controller->url_for('admin/tree/delete') ?>"
+ :show-structure-as-navigation="true" :with-course-assign="true"
+ :with-courses="true" semester="<?= htmlReady($semester) ?>"
+ title="<?= _('Veranstaltungshierarchie bearbeiten') ?>"></studip-tree>
+</div>
diff --git a/app/views/search/courses/index.php b/app/views/search/courses/index.php
index 63b9dc4..c6f4905 100644
--- a/app/views/search/courses/index.php
+++ b/app/views/search/courses/index.php
@@ -1,2 +1,12 @@
-<?
-$sem_browse_obj->do_output();
+<?php
+/**
+ * @var String $startId
+ * @var String $nodeClass
+ */
+?>
+<div data-studip-tree>
+ <studip-tree start-id="<?= htmlReady($startId) ?>" view-type="list" :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>
+</div>
diff --git a/app/views/studyarea/edit.php b/app/views/studyarea/edit.php
new file mode 100644
index 0000000..45a48d1
--- /dev/null
+++ b/app/views/studyarea/edit.php
@@ -0,0 +1 @@
+<?= $form->render() ?>
diff --git a/db/migrations/5.4.6_tree_changes.php b/db/migrations/5.4.6_tree_changes.php
new file mode 100644
index 0000000..94d015c
--- /dev/null
+++ b/db/migrations/5.4.6_tree_changes.php
@@ -0,0 +1,66 @@
+<?
+
+final class TreeChanges extends Migration
+{
+
+ const FIELDS = [
+ 'RANGE_TREE_PERM',
+ 'SEM_TREE_PERM'
+ ];
+
+ public function description()
+ {
+ return 'Removes old sem_- and range_tree permission settings and institute assignments for sem_tree entries';
+ }
+
+ protected function up()
+ {
+ // Remove config fields for special permissions concerning sem_- and range_tree administration.
+ DBManager::get()->execute(
+ "DELETE FROM `config_values` WHERE `field` IN (:fields)",
+ ['fields' => self::FIELDS]
+ );
+ DBManager::get()->execute(
+ "DELETE FROM `config` WHERE `field` IN (:fields)",
+ ['fields' => self::FIELDS]
+ );
+
+ // "Transfer" names from assigned institutes to sem_tree entries.
+ $stmt = DBManager::get()->prepare("UPDATE `sem_tree` SET `name` = :name WHERE `studip_object_id` = :inst");
+ $query = "SELECT DISTINCT `Institut_id`, `Name` FROM `Institute` WHERE `Institut_id` IN (
+ SELECT DISTINCT `studip_object_id` FROM `sem_tree`
+ )";
+ foreach (DBManager::get()->fetchAll($query) as $institute) {
+ $stmt->execute(['name' => $institute['Name'], 'inst' => $institute['Institut_id']]);
+ }
+ // Remove institute assignments for sem_tree entries.
+ DBManager::get()->exec("ALTER TABLE `sem_tree` DROP `studip_object_id`");
+ }
+
+ protected function down()
+ {
+ // Restore config entries to their defaults.
+ DBManager::get()->exec("INSERT IGNORE INTO `config`
+ ( `config_id` , `parent_id` , `field` , `value` ,
+ `is_default` , `type` , `range` , `section` ,
+ `position` , `mkdate` , `chdate` , `description` ,
+ `comment` , `message_template` )
+ VALUES (
+ MD5( 'RANGE_TREE_ADMIN_PERM' ) , '', 'RANGE_TREE_ADMIN_PERM',
+ 'admin', '1', 'string', 'global', '', '0',
+ UNIX_TIMESTAMP( ) , UNIX_TIMESTAMP( ) ,
+ 'mit welchem Status darf die Einrichtungshierarchie bearbeitet werden (admin oder root)', '', ''
+ ), (
+ MD5( 'SEM_TREE_ADMIN_PERM' ) , '', 'SEM_TREE_ADMIN_PERM',
+ 'admin', '1', 'string', 'global', '', '0', UNIX_TIMESTAMP( ) ,
+ UNIX_TIMESTAMP( ) , 'mit welchem Status darf die Veranstaltungshierarchie bearbeitet werden (admin oder root)', '', ''
+ )");
+
+ // Add database column for sem_tree institute assignments.
+ DBManager::get()->exec("ALTER TABLE `sem_tree` ADD
+ `studip_object_id` CHAR(32) CHARACTER SET latin1 COLLATE latin1_bin NULL DEFAULT NULL AFTER `name`");
+ // Add index for studip_object_id.
+ DBManager::get()->exec("ALTER TABLE `sem_tree` ADD INDEX `studip_object_id` (`studip_object_id`)");
+ }
+
+}
diff --git a/lib/classes/JsonApi/RouteMap.php b/lib/classes/JsonApi/RouteMap.php
index f60c4dd..52d4402 100644
--- a/lib/classes/JsonApi/RouteMap.php
+++ b/lib/classes/JsonApi/RouteMap.php
@@ -132,6 +132,7 @@ class RouteMap
$this->addAuthenticatedMessagesRoutes($group);
$this->addAuthenticatedNewsRoutes($group);
$this->addAuthenticatedStudyAreasRoutes($group);
+ $this->addAuthenticatedTreeRoutes($group);
$this->addAuthenticatedWikiRoutes($group);
}
@@ -281,6 +282,17 @@ class RouteMap
$group->get('/study-areas/{id}/parent', Routes\StudyAreas\ParentOfStudyAreas::class);
}
+ private function addAuthenticatedTreeRoutes(RouteCollectorProxy $group): void
+ {
+ $group->get('/tree-node/{id}', Routes\Tree\TreeShow::class);
+
+ $group->get('/tree-node/{id}/children', Routes\Tree\ChildrenOfTreeNode::class);
+ $group->get('/tree-node/{id}/courseinfo', Routes\Tree\CourseInfoOfTreeNode::class);
+ $group->get('/tree-node/{id}/courses', Routes\Tree\CoursesOfTreeNode::class);
+ $group->get('/tree-node/course/pathinfo/{classname}/{id}', Routes\Tree\PathinfoOfTreeNodeCourse::class);
+ $group->get('/tree-node/course/details/{id}', Routes\Tree\DetailsOfTreeNodeCourse::class);
+ }
+
private function addAuthenticatedWikiRoutes(RouteCollectorProxy $group): void
{
$this->addRelationship($group, '/wiki-pages/{id:.+}/relationships/parent', Routes\Wiki\Rel\ParentPage::class);
diff --git a/lib/classes/JsonApi/Routes/RangeTree/ChildrenOfRangeTreeNode.php b/lib/classes/JsonApi/Routes/RangeTree/ChildrenOfRangeTreeNode.php
new file mode 100644
index 0000000..8773c0a
--- /dev/null
+++ b/lib/classes/JsonApi/Routes/RangeTree/ChildrenOfRangeTreeNode.php
@@ -0,0 +1,39 @@
+<?php
+
+namespace JsonApi\Routes\RangeTree;
+
+use Psr\Http\Message\ServerRequestInterface as Request;
+use Psr\Http\Message\ResponseInterface as Response;
+use JsonApi\Errors\AuthorizationFailedException;
+use JsonApi\Errors\RecordNotFoundException;
+use JsonApi\JsonApiController;
+
+class ChildrenOfRangeTreeNode extends JsonApiController
+{
+ protected $allowedIncludePaths = [
+ 'children',
+ 'courses',
+ 'institute',
+ 'parent',
+ ];
+ protected $allowedPagingParameters = ['offset', 'limit'];
+
+ /**
+ * @SuppressWarnings(PHPMD.UnusedFormalParameters)
+ */
+ public function __invoke(Request $request, Response $response, $args)
+ {
+ if (!RangeTreeNode::getNode($args['id'])) {
+ throw new RecordNotFoundException();
+ }
+
+ list($offset, $limit) = $this->getOffsetAndLimit();
+ $total = \RangeTreeNode::countByParent_id($args['id']);
+ $children = \RangeTreeNode::findByParent_id(
+ $args['id'],
+ "LIMIT {$offset}, {$limit}"
+ );
+
+ return $this->getPaginatedContentResponse($children, $total);
+ }
+}
diff --git a/lib/classes/JsonApi/Routes/RangeTree/CoursesOfRangeTreeNode.php b/lib/classes/JsonApi/Routes/RangeTree/CoursesOfRangeTreeNode.php
new file mode 100644
index 0000000..980cac1
--- /dev/null
+++ b/lib/classes/JsonApi/Routes/RangeTree/CoursesOfRangeTreeNode.php
@@ -0,0 +1,51 @@
+<?php
+
+namespace JsonApi\Routes\RangeTree;
+
+use Psr\Http\Message\ServerRequestInterface as Request;
+use Psr\Http\Message\ResponseInterface as Response;
+use JsonApi\Errors\AuthorizationFailedException;
+use JsonApi\Errors\RecordNotFoundException;
+use JsonApi\JsonApiController;
+
+class CoursesOfRangeTreeNode extends JsonApiController
+{
+ protected $allowedIncludePaths = [
+ 'blubber-threads',
+ 'end-semester',
+ 'events',
+ 'feedback-elements',
+ 'file-refs',
+ 'folders',
+ 'forum-categories',
+ 'institute',
+ 'memberships',
+ 'news',
+ 'participating-institutes',
+ 'sem-class',
+ 'sem-type',
+ 'start-semester',
+ 'status-groups',
+ 'wiki-pages',
+ ];
+ protected $allowedPagingParameters = ['offset', 'limit'];
+
+ /**
+ * @SuppressWarnings(PHPMD.UnusedFormalParameters)
+ */
+ public function __invoke(Request $request, Response $response, $args)
+ {
+ $node = \RangeTreeNode::find($args['id']);
+ if (!$node) {
+ throw new RecordNotFoundException();
+ }
+
+ list($offset, $limit) = $this->getOffsetAndLimit();
+ $courses = $node->getCourses();
+
+ return $this->getPaginatedContentResponse(
+ $courses->limit($offset, $limit),
+ count($courses)
+ );
+ }
+}
diff --git a/lib/classes/JsonApi/Routes/RangeTree/InstituteOfRangeTreeNode.php b/lib/classes/JsonApi/Routes/RangeTree/InstituteOfRangeTreeNode.php
new file mode 100644
index 0000000..6ecf52a
--- /dev/null
+++ b/lib/classes/JsonApi/Routes/RangeTree/InstituteOfRangeTreeNode.php
@@ -0,0 +1,30 @@
+<?php
+
+namespace JsonApi\Routes\RangeTree;
+
+use Psr\Http\Message\ServerRequestInterface as Request;
+use Psr\Http\Message\ResponseInterface as Response;
+use JsonApi\Errors\AuthorizationFailedException;
+use JsonApi\Errors\RecordNotFoundException;
+use JsonApi\JsonApiController;
+use JsonApi\Schemas\Institute as InstituteSchema;
+
+class InstituteOfRangeTreeNode extends JsonApiController
+{
+ protected $allowedIncludePaths = [
+ InstituteSchema::REL_STATUS_GROUPS,
+ ];
+
+ /**
+ * @SuppressWarnings(PHPMD.UnusedFormalParameters)
+ */
+ public function __invoke(Request $request, Response $response, $args)
+ {
+ $node = \RangeTreeNode::find($args['id']);
+ if (!$node) {
+ throw new RecordNotFoundException();
+ }
+
+ return $this->getContentResponse($node->institute);
+ }
+}
diff --git a/lib/classes/JsonApi/Routes/RangeTree/ParentOfRangeTreeNode.php b/lib/classes/JsonApi/Routes/RangeTree/ParentOfRangeTreeNode.php
new file mode 100644
index 0000000..01a40d3
--- /dev/null
+++ b/lib/classes/JsonApi/Routes/RangeTree/ParentOfRangeTreeNode.php
@@ -0,0 +1,32 @@
+<?php
+
+namespace JsonApi\Routes\RangeTree;
+
+use Psr\Http\Message\ServerRequestInterface as Request;
+use Psr\Http\Message\ResponseInterface as Response;
+use JsonApi\Errors\AuthorizationFailedException;
+use JsonApi\Errors\RecordNotFoundException;
+use JsonApi\JsonApiController;
+
+class ParentOfRangeTreeNode extends JsonApiController
+{
+ protected $allowedIncludePaths = [
+ 'children',
+ 'courses',
+ 'institute',
+ 'parent',
+ ];
+
+ /**
+ * @SuppressWarnings(PHPMD.UnusedFormalParameters)
+ */
+ public function __invoke(Request $request, Response $response, $args)
+ {
+ $node = \RangeTreeNode::find($args['id']);
+ if (!$node) {
+ throw new RecordNotFoundException();
+ }
+
+ return $this->getContentResponse($node->getParent());
+ }
+}
diff --git a/lib/classes/JsonApi/Routes/RangeTree/RangeTreeIndex.php b/lib/classes/JsonApi/Routes/RangeTree/RangeTreeIndex.php
new file mode 100644
index 0000000..c706c48
--- /dev/null
+++ b/lib/classes/JsonApi/Routes/RangeTree/RangeTreeIndex.php
@@ -0,0 +1,53 @@
+<?php
+
+namespace JsonApi\Routes\RangeTree;
+
+use Psr\Http\Message\ServerRequestInterface as Request;
+use Psr\Http\Message\ResponseInterface as Response;
+use JsonApi\Errors\AuthorizationFailedException;
+use JsonApi\Errors\RecordNotFoundException;
+use JsonApi\JsonApiController;
+
+/**
+ * Zeigt eine bestimmte Veranstaltung an.
+ */
+class RangeTreeIndex extends JsonApiController
+{
+
+ protected $allowedIncludePaths = [
+ 'children',
+ 'courses',
+ 'institute',
+ 'parent',
+ ];
+ protected $allowedPagingParameters = ['offset', 'limit'];
+
+ /**
+ * @SuppressWarnings(PHPMD.UnusedFormalParameter)
+ */
+ public function __invoke(Request $request, Response $response, $args)
+ {
+ $tree = \TreeAbstract::getInstance('StudipSemTree', ['visible_only' => 1]);
+ $studyAreas = self::mapTree('root', $tree);
+ list($offset, $limit) = $this->getOffsetAndLimit();
+
+ return $this->getPaginatedContentResponse(
+ array_slice($studyAreas, $offset, $limit),
+ count($studyAreas)
+ );
+ }
+
+ private function mapTree($parentId, &$tree)
+ {
+ $level = [];
+ $kids = $tree->getKids($parentId);
+ if (is_array($kids) && count($kids) > 0) {
+ foreach ($kids as $kid) {
+ $level[] = \StudipStudyArea::find($kid);
+ $level = array_merge($level, self::mapTree($kid, $tree));
+ }
+ }
+
+ return $level;
+ }
+}
diff --git a/lib/classes/JsonApi/Routes/RangeTree/RangeTreeShow.php b/lib/classes/JsonApi/Routes/RangeTree/RangeTreeShow.php
new file mode 100644
index 0000000..c9f52ed
--- /dev/null
+++ b/lib/classes/JsonApi/Routes/RangeTree/RangeTreeShow.php
@@ -0,0 +1,32 @@
+<?php
+
+namespace JsonApi\Routes\RangeTree;
+
+use Psr\Http\Message\ServerRequestInterface as Request;
+use Psr\Http\Message\ResponseInterface as Response;
+use JsonApi\Errors\AuthorizationFailedException;
+use JsonApi\Errors\RecordNotFoundException;
+use JsonApi\JsonApiController;
+
+class RangeTreeShow extends JsonApiController
+{
+ protected $allowedIncludePaths = [
+ 'children',
+ 'courses',
+ 'institute',
+ 'parent',
+ ];
+
+ /**
+ * @SuppressWarnings(PHPMD.UnusedFormalParameters)
+ */
+ public function __invoke(Request $request, Response $response, $args)
+ {
+ $node = \RangeTreeNode::find($args['id']);
+ if (!$node) {
+ throw new RecordNotFoundException();
+ }
+
+ return $this->getContentResponse($node);
+ }
+}
diff --git a/lib/classes/JsonApi/Routes/StudyAreas/StudyAreasShow.php b/lib/classes/JsonApi/Routes/StudyAreas/StudyAreasShow.php
index 628abc8..9ce5728 100644
--- a/lib/classes/JsonApi/Routes/StudyAreas/StudyAreasShow.php
+++ b/lib/classes/JsonApi/Routes/StudyAreas/StudyAreasShow.php
@@ -22,7 +22,8 @@ class StudyAreasShow extends JsonApiController
*/
public function __invoke(Request $request, Response $response, $args)
{
- if (!$studyArea = \StudipStudyArea::find($args['id'])) {
+ $studyArea = \StudipStudyArea::find($args['id']);
+ if (!$studyArea && $args['id'] !== 'root') {
throw new RecordNotFoundException();
}
diff --git a/lib/classes/JsonApi/Routes/Tree/ChildrenOfTreeNode.php b/lib/classes/JsonApi/Routes/Tree/ChildrenOfTreeNode.php
new file mode 100644
index 0000000..6419d03
--- /dev/null
+++ b/lib/classes/JsonApi/Routes/Tree/ChildrenOfTreeNode.php
@@ -0,0 +1,50 @@
+<?php
+
+namespace JsonApi\Routes\Tree;
+
+use Psr\Http\Message\ServerRequestInterface as Request;
+use Psr\Http\Message\ResponseInterface as Response;
+use JsonApi\Errors\RecordNotFoundException;
+use JsonApi\JsonApiController;
+
+class ChildrenOfTreeNode extends JsonApiController
+{
+ protected $allowedFilteringParameters = ['visible'];
+
+ protected $allowedIncludePaths = [
+ 'children',
+ 'courses',
+ 'institute',
+ 'parent',
+ ];
+
+ /**
+ * @SuppressWarnings(PHPMD.UnusedFormalParameters)
+ */
+ public function __invoke(Request $request, Response $response, $args)
+ {
+ list($classname, $id) = explode('_', $args['id']);
+
+ $node = $classname::getNode($id);
+ if (!$node) {
+ throw new RecordNotFoundException();
+ }
+
+ $filters = $this->getContextFilters();
+
+ $data = $node->getChildNodes((bool) $filters['visible']);
+
+ return $this->getContentResponse($data);
+ }
+
+ private function getContextFilters()
+ {
+ $defaults = [
+ 'visible' => false
+ ];
+
+ $filtering = $this->getQueryParameters()->getFilteringParameters() ?: [];
+
+ return array_merge($defaults, $filtering);
+ }
+}
diff --git a/lib/classes/JsonApi/Routes/Tree/CourseInfoOfTreeNode.php b/lib/classes/JsonApi/Routes/Tree/CourseInfoOfTreeNode.php
new file mode 100644
index 0000000..2835931
--- /dev/null
+++ b/lib/classes/JsonApi/Routes/Tree/CourseInfoOfTreeNode.php
@@ -0,0 +1,83 @@
+<?php
+
+namespace JsonApi\Routes\Tree;
+
+use JsonApi\Errors\BadRequestException;
+use Psr\Http\Message\ServerRequestInterface as Request;
+use Psr\Http\Message\ResponseInterface as Response;
+use JsonApi\Errors\RecordNotFoundException;
+use JsonApi\NonJsonApiController;
+
+class CourseinfoOfTreeNode extends NonJsonApiController
+{
+ protected $allowedFilteringParameters = ['q', 'semester', 'semclass', 'recursive'];
+
+ /**
+ * @SuppressWarnings(PHPMD.UnusedFormalParameters)
+ */
+ public function __invoke(Request $request, Response $response, $args)
+ {
+ list($classname, $id) = explode('_', $args['id']);
+
+ $node = $classname::getNode($id);
+ if (!$node) {
+ throw new RecordNotFoundException();
+ }
+
+ $error = $this->validateFilters($request);
+ if ($error) {
+ throw new BadRequestException($error);
+ }
+
+ $filters = $this->getContextFilters($request);
+
+ $info = [
+ 'courses' => (int) $node->countCourses($filters['semester'], $filters['semclass']),
+ 'allCourses' => (int) $node->countCourses($filters['semester'], $filters['semclass'], true)
+ ];
+
+ $response->getBody()->write(json_encode($info));
+
+ return $response->withHeader('Content-type', 'application/json');
+ }
+
+ private function validateFilters($request)
+ {
+ $filtering = $request->getQueryParams()['filter'] ?: [];
+
+ // keyword aka q
+ if (isset($filtering['q']) && mb_strlen($filtering['q']) < 3) {
+ return 'Search term too short.';
+ }
+
+ // semester
+ if (isset($filtering['semester']) && $filtering['semester'] !== 'all') {
+ $semester = \Semester::find($filtering['semester']);
+ if (!$semester) {
+ return 'Invalid "semester".';
+ }
+ }
+
+ // course category
+ if (!empty($filtering['semclass'])) {
+ $semclass = \SeminarCategories::Get($filtering['semclass']);
+ if (!$semclass) {
+ return 'Invalid "course category".';
+ }
+ }
+ }
+
+ private function getContextFilters($request)
+ {
+ $defaults = [
+ 'q' => '',
+ 'semester' => 'all',
+ 'semclass' => 0,
+ 'recursive' => false
+ ];
+
+ $filtering = $request->getQueryParams()['filter'] ?: [];
+
+ return array_merge($defaults, $filtering);
+ }
+}
diff --git a/lib/classes/JsonApi/Routes/Tree/CoursesOfTreeNode.php b/lib/classes/JsonApi/Routes/Tree/CoursesOfTreeNode.php
new file mode 100644
index 0000000..623e619
--- /dev/null
+++ b/lib/classes/JsonApi/Routes/Tree/CoursesOfTreeNode.php
@@ -0,0 +1,112 @@
+<?php
+
+namespace JsonApi\Routes\Tree;
+
+use JsonApi\Errors\BadRequestException;
+use Psr\Http\Message\ServerRequestInterface as Request;
+use Psr\Http\Message\ResponseInterface as Response;
+use JsonApi\Errors\AuthorizationFailedException;
+use JsonApi\Errors\RecordNotFoundException;
+use JsonApi\JsonApiController;
+
+class CoursesOfTreeNode extends JsonApiController
+{
+ protected $allowedFilteringParameters = ['q', 'semester', 'semclass', 'recursive', 'ids'];
+
+ protected $allowedIncludePaths = [
+ 'blubber-threads',
+ 'end-semester',
+ 'events',
+ 'feedback-elements',
+ 'file-refs',
+ 'folders',
+ 'forum-categories',
+ 'institute',
+ 'memberships',
+ 'news',
+ 'participating-institutes',
+ 'sem-class',
+ 'sem-type',
+ 'start-semester',
+ 'status-groups',
+ 'wiki-pages',
+ ];
+ protected $allowedPagingParameters = ['offset', 'limit'];
+
+ /**
+ * @SuppressWarnings(PHPMD.UnusedFormalParameters)
+ */
+ public function __invoke(Request $request, Response $response, $args)
+ {
+ list($classname, $id) = explode('_', $args['id']);
+
+ $node = $classname::getNode($id);
+ if (!$node) {
+ throw new RecordNotFoundException();
+ }
+
+ $error = $this->validateFilters();
+ if ($error) {
+ throw new BadRequestException($error);
+ }
+
+ $filters = $this->getContextFilters();
+
+ list($offset, $limit) = $this->getOffsetAndLimit();
+ $courses = \SimpleCollection::createFromArray(
+ $node->getCourses(
+ $filters['semester'],
+ $filters['semclass'],
+ $filters['q'],
+ (bool) $filters['recursive'],
+ $filters['ids']
+ )
+ );
+
+ return $this->getPaginatedContentResponse(
+ $courses->limit($offset, $limit),
+ count($courses)
+ );
+ }
+
+ private function validateFilters()
+ {
+ $filtering = $this->getQueryParameters()->getFilteringParameters() ?: [];
+
+ // keyword aka q
+ if (isset($filtering['q']) && mb_strlen($filtering['q']) < 3) {
+ return 'Search term too short.';
+ }
+
+ // semester
+ if (isset($filtering['semester']) && $filtering['semester'] !== 'all') {
+ $semester = \Semester::find($filtering['semester']);
+ if (!$semester) {
+ return 'Invalid "semester".';
+ }
+ }
+
+ // course category
+ if (!empty($filtering['semclass'])) {
+ $semclass = \SeminarCategories::Get($filtering['semclass']);
+ if (!$semclass) {
+ return 'Invalid "course category".';
+ }
+ }
+ }
+
+ private function getContextFilters()
+ {
+ $defaults = [
+ 'q' => '',
+ 'semester' => 'all',
+ 'semclass' => 0,
+ 'recursive' => false,
+ 'ids' => []
+ ];
+
+ $filtering = $this->getQueryParameters()->getFilteringParameters() ?: [];
+
+ return array_merge($defaults, $filtering);
+ }
+}
diff --git a/lib/classes/JsonApi/Routes/Tree/DetailsOfTreeNodeCourse.php b/lib/classes/JsonApi/Routes/Tree/DetailsOfTreeNodeCourse.php
new file mode 100644
index 0000000..cef1077
--- /dev/null
+++ b/lib/classes/JsonApi/Routes/Tree/DetailsOfTreeNodeCourse.php
@@ -0,0 +1,79 @@
+<?php
+
+namespace JsonApi\Routes\Tree;
+
+use Psr\Http\Message\ServerRequestInterface as Request;
+use Psr\Http\Message\ResponseInterface as Response;
+use JsonApi\Errors\AuthorizationFailedException;
+use JsonApi\Errors\RecordNotFoundException;
+use JsonApi\NonJsonApiController;
+
+class DetailsOfTreeNodeCourse extends NonJsonApiController
+{
+ /**
+ * @SuppressWarnings(PHPMD.UnusedFormalParameters)
+ */
+ public function __invoke(Request $request, Response $response, $args)
+ {
+ $course = \Course::find($args['id']);
+ if (!$course) {
+ throw new RecordNotFoundException();
+ }
+
+ // Get course dates in textual form
+ $dates = \Seminar::GetInstance($args['id'])->getDatesHTML([
+ 'semester_id' => null,
+ 'show_room' => true,
+ ]);
+
+ $data = [
+ 'semester' => $course->semester_text,
+ 'lecturers' => [],
+ 'admissionstate' => null,
+ 'dates' => $dates
+ ];
+
+ // Get lecturers
+ $lecturers = \SimpleCollection::createFromArray(
+ \CourseMember::findByCourseAndStatus($args['id'], 'dozent')
+ )->orderBy('position, nachname, vorname');
+ foreach ($lecturers as $l) {
+ $data['lecturers'][] = [
+ 'id' => $l->user_id,
+ 'username' => $l->username,
+ 'name' => $l->getUserFullname()
+ ];
+ }
+
+ // Get admission state indicator if necessary
+ if (\Config::get()->COURSE_SEARCH_SHOW_ADMISSION_STATE) {
+ switch (\GlobalSearchCourses::getStatusCourseAdmission($course->id, $course->admission_prelim)) {
+ case 1:
+ $data['admissionstate'] = [
+ 'icon' => 'decline-circle',
+ 'role' => \Icon::ROLE_STATUS_YELLOW,
+ 'info' => _('Eingeschränkter Zugang')
+ ];
+ break;
+ case 2:
+ $data['admissionstate'] = [
+ 'icon' => 'decline-circle',
+ 'role' => \Icon::ROLE_STATUS_RED,
+ 'info' => _('Kein Zugang')
+ ];
+ break;
+ default:
+ $data['admissionstate'] = [
+ 'icon' => 'check-circle',
+ 'role' => \Icon::ROLE_STATUS_GREEN,
+ 'info' => _('Uneingeschränkter Zugang')
+ ];
+ }
+
+ }
+
+ $response->getBody()->write(json_encode($data));
+
+ return $response->withHeader('Content-type', 'application/json');
+ }
+}
diff --git a/lib/classes/JsonApi/Routes/Tree/PathinfoOfTreeNodeCourse.php b/lib/classes/JsonApi/Routes/Tree/PathinfoOfTreeNodeCourse.php
new file mode 100644
index 0000000..282b7f9
--- /dev/null
+++ b/lib/classes/JsonApi/Routes/Tree/PathinfoOfTreeNodeCourse.php
@@ -0,0 +1,34 @@
+<?php
+
+namespace JsonApi\Routes\Tree;
+
+use Psr\Http\Message\ServerRequestInterface as Request;
+use Psr\Http\Message\ResponseInterface as Response;
+use JsonApi\Errors\AuthorizationFailedException;
+use JsonApi\Errors\RecordNotFoundException;
+use JsonApi\NonJsonApiController;
+
+class PathinfoOfTreeNodeCourse extends NonJsonApiController
+{
+ /**
+ * @SuppressWarnings(PHPMD.UnusedFormalParameters)
+ */
+ public function __invoke(Request $request, Response $response, $args)
+ {
+ $course = \Course::find($args['id']);
+ if (!$course) {
+ throw new RecordNotFoundException();
+ }
+
+ $classname = $args['classname'];
+
+ $path = [];
+ foreach ($classname::getCourseNodes($args['id']) as $node) {
+ $path[] = $node->getAncestors();
+ }
+
+ $response->getBody()->write(json_encode($path));
+
+ return $response->withHeader('Content-type', 'application/json');
+ }
+}
diff --git a/lib/classes/JsonApi/Routes/Tree/TreeShow.php b/lib/classes/JsonApi/Routes/Tree/TreeShow.php
new file mode 100644
index 0000000..1eaa797
--- /dev/null
+++ b/lib/classes/JsonApi/Routes/Tree/TreeShow.php
@@ -0,0 +1,40 @@
+<?php
+
+namespace JsonApi\Routes\Tree;
+
+use JsonApi\Errors\BadRequestException;
+use Neomerx\JsonApi\Contracts\Http\ResponsesInterface;
+use Neomerx\JsonApi\Contracts\Schema\ContextInterface;
+use Neomerx\JsonApi\Schema\Link;
+use Psr\Http\Message\ServerRequestInterface as Request;
+use Psr\Http\Message\ResponseInterface as Response;
+use JsonApi\Errors\AuthorizationFailedException;
+use JsonApi\Errors\RecordNotFoundException;
+use JsonApi\JsonApiController;
+
+class TreeShow extends JsonApiController
+{
+ protected $allowedIncludePaths = [
+ 'children',
+ 'courseinfo',
+ 'courses',
+ 'institute',
+ 'parent'
+ ];
+
+ /**
+ * @SuppressWarnings(PHPMD.UnusedFormalParameters)
+ */
+ public function __invoke(Request $request, Response $response, $args)
+ {
+ list($classname, $id) = explode('_', $args['id']);
+
+ $node = $classname::getNode($id);
+ if (!$node) {
+ throw new RecordNotFoundException();
+ }
+
+ return $this->getContentResponse($node);
+ }
+
+}
diff --git a/lib/classes/JsonApi/SchemaMap.php b/lib/classes/JsonApi/SchemaMap.php
index 19d21f5..f4ac207 100644
--- a/lib/classes/JsonApi/SchemaMap.php
+++ b/lib/classes/JsonApi/SchemaMap.php
@@ -43,7 +43,7 @@ class SchemaMap
\JsonApi\Models\StudipProperty::class => Schemas\StudipProperty::class,
\StudipComment::class => Schemas\StudipComment::class,
\StudipNews::class => Schemas\StudipNews::class,
- \StudipStudyArea::class => Schemas\StudyArea::class,
+ \StudipTreeNode::class => Schemas\TreeNode::class,
\WikiPage::class => Schemas\WikiPage::class,
\Studip\Activity\Activity::class => Schemas\Activity::class,
\User::class => Schemas\User::class,
diff --git a/lib/classes/JsonApi/Schemas/StudyArea.php b/lib/classes/JsonApi/Schemas/StudyArea.php
index f077d83..e9779c7 100644
--- a/lib/classes/JsonApi/Schemas/StudyArea.php
+++ b/lib/classes/JsonApi/Schemas/StudyArea.php
@@ -1,5 +1,4 @@
<?php
-
namespace JsonApi\Schemas;
use Neomerx\JsonApi\Contracts\Schema\ContextInterface;
@@ -25,6 +24,9 @@ class StudyArea extends SchemaProvider
'info' => (string) $resource['info'],
'priority' => (int) $resource['priority'],
'type-name' => (string) $resource->getTypeName(),
+ 'has-children' => (bool) $resource->hasChildNodes(),
+ 'ancestors' => (array) $resource->getAncestors(),
+ 'classname' => get_class($resource)
];
}
diff --git a/lib/classes/JsonApi/Schemas/TreeNode.php b/lib/classes/JsonApi/Schemas/TreeNode.php
new file mode 100644
index 0000000..90e6846
--- /dev/null
+++ b/lib/classes/JsonApi/Schemas/TreeNode.php
@@ -0,0 +1,151 @@
+<?php
+
+namespace JsonApi\Schemas;
+
+use Neomerx\JsonApi\Contracts\Factories\FactoryInterface;
+use Neomerx\JsonApi\Contracts\Schema\ContextInterface;
+use Neomerx\JsonApi\Contracts\Schema\SchemaContainerInterface;
+use Neomerx\JsonApi\Schema\Link;
+
+class TreeNode extends SchemaProvider
+{
+ const REL_CHILDREN = 'children';
+
+ const REL_COURSEINFO = 'courseinfo';
+ const REL_COURSES = 'courses';
+ const REL_INSTITUTE = 'institute';
+ const REL_PARENT = 'parent';
+
+ const TYPE = 'tree-node';
+
+ public function getId($resource): ?string
+ {
+ return get_class($resource) . '_' . $resource['id'];
+ }
+
+ public function getAttributes($resource, ContextInterface $context): iterable
+ {
+ $schema = [
+ 'id' => (string) $resource->getId(),
+ 'name' => (string) $resource->getName(),
+ 'description' => (string) $resource->getDescription(),
+ 'description-formatted' => (string) formatReady($resource->getDescription()),
+ 'has-children' => (bool) $resource->hasChildNodes(),
+ 'ancestors' => (array) $resource->getAncestors(),
+ 'classname' => get_class($resource),
+ 'visible' => true,
+ 'editable' => true,
+ 'assignable' => true
+ ];
+
+ // Some special options for sem_tree entries.
+ if (get_class($resource) === 'StudipStudyArea') {
+ if ($GLOBALS['SEM_TREE_TYPES'][$resource->type]['hidden'] ?? false) {
+ $schema['visible'] = false;
+ }
+ if ($GLOBALS['SEM_TREE_TYPES'][$resource->type]['editable'] ?? false) {
+ $schema['editable'] = false;
+ }
+ if (!\Config::get()->SEM_TREE_ALLOW_BRANCH_ASSIGN && $resource->hasChildNodes()) {
+ $schema['assignable'] = false;
+ }
+ }
+
+ return $schema;
+ }
+
+ public function getRelationships($resource, ContextInterface $context): iterable
+ {
+ $relationships = [];
+
+ $relationships = $this->addChildrenRelationship($relationships, $resource, $this->shouldInclude($context, self::REL_CHILDREN));
+
+ if (property_exists($resource, 'courses') || method_exists($resource, 'getCourses')) {
+ $relationships = $this->addCourseInfoRelationship($relationships, $resource, $this->shouldInclude($context, self::REL_COURSEINFO));
+ $relationships = $this->addCoursesRelationship($relationships, $resource, $this->shouldInclude($context, self::REL_COURSES));
+ }
+ $relationships = $this->addInstituteRelationship($relationships, $resource, $this->shouldInclude($context, self::REL_INSTITUTE));
+ $relationships = $this->addParentRelationship($relationships, $resource, $this->shouldInclude($context, self::REL_PARENT));
+
+ return $relationships;
+ }
+
+ private function addChildrenRelationship(array $relationships, $resource, $includeData)
+ {
+ $relationships[self::REL_CHILDREN] = [
+ self::RELATIONSHIP_LINKS => [
+ Link::RELATED => $this->getRelationshipRelatedLink($resource, self::REL_CHILDREN),
+ ]
+ ];
+
+ if ($includeData) {
+ $children = $resource->getChildNodes();
+ $relationships[self::REL_CHILDREN][self::RELATIONSHIP_DATA] = $children;
+ }
+
+ return $relationships;
+ }
+
+
+ private function addCourseInfoRelationship(array $relationships, $resource, $includeData)
+ {
+ $relationships[self::REL_COURSEINFO] = [
+ self::RELATIONSHIP_LINKS => [
+ Link::RELATED => $this->getRelationshipRelatedLink($resource, self::REL_COURSEINFO),
+ ],
+ ];
+
+ if ($includeData) {
+ $children = $resource->courses;
+ $relationships[self::REL_COURSES][self::RELATIONSHIP_DATA] = $children;
+ }
+
+ return $relationships;
+ }
+
+ private function addCoursesRelationship(array $relationships, $resource, $includeData)
+ {
+ $relationships[self::REL_COURSES] = [
+ self::RELATIONSHIP_LINKS => [
+ Link::RELATED => $this->getRelationshipRelatedLink($resource, self::REL_COURSES)
+ ]
+ ];
+
+ if ($includeData) {
+ $courses = $resource->courses;
+ $relationships[self::REL_COURSES][self::RELATIONSHIP_DATA] = $courses;
+ }
+
+ return $relationships;
+ }
+
+ private function addInstituteRelationship(array $relationships, $resource, $includeData)
+ {
+ $relationships[self::REL_INSTITUTE] = [
+ self::RELATIONSHIP_LINKS => [
+ Link::RELATED => $this->getRelationshipRelatedLink($resource, self::REL_INSTITUTE),
+ ],
+ ];
+
+ if ($includeData) {
+ $relationships[self::REL_INSTITUTE][self::RELATIONSHIP_DATA] = $resource->institute;
+ }
+
+ return $relationships;
+ }
+
+ private function addParentRelationship(array $relationships, $resource, $includeData)
+ {
+ $relationships[self::REL_PARENT] = [
+ self::RELATIONSHIP_LINKS => [
+ Link::RELATED => $this->getRelationshipRelatedLink($resource, self::REL_PARENT)
+ ],
+ ];
+
+ if ($includeData) {
+ $relationships[self::REL_PARENT][self::RELATIONSHIP_DATA] = $resource->getParent();
+ }
+
+ return $relationships;
+ }
+}
diff --git a/lib/classes/SemBrowse.class.php b/lib/classes/SemBrowse.class.php
index 16f13f1..65d2856 100644
--- a/lib/classes/SemBrowse.class.php
+++ b/lib/classes/SemBrowse.class.php
@@ -1185,19 +1185,13 @@ class SemBrowse {
return new Navigation(_('Vorlesungsverzeichnis'),
URLHelper::getURL('dispatch.php/search/courses',
[
- 'level' => 'vv',
- 'cmd' => 'qs',
- 'sset' => '0',
- 'option' => ''
+ 'type' => 'semtree'
], true));
case 'rangetree':
return new Navigation(_('Einrichtungsverzeichnis'),
URLHelper::getURL('dispatch.php/search/courses',
[
- 'level' => 'ev',
- 'cmd' => 'qs',
- 'sset' => '0',
- 'option' => ''
+ 'type' => 'rangetree'
], true));
case 'module':
return new MVVSearchNavigation(_('Modulverzeichnis'),
diff --git a/lib/classes/StudipTreeNode.php b/lib/classes/StudipTreeNode.php
new file mode 100644
index 0000000..a1d7258
--- /dev/null
+++ b/lib/classes/StudipTreeNode.php
@@ -0,0 +1,114 @@
+<?php
+
+/**
+ * Interface StudipTreeNode
+ * An abstract representation of a tree node in Stud.IP
+ *
+ * @author Thomas Hackl <hackl@data-quest.de>
+ * @license GPL2 or any later version
+ * @since Stud.IP 5.3
+ */
+
+interface StudipTreeNode
+{
+
+ /**
+ * Fetches a node by the given ID. The implementing class knows what to do.
+ *
+ * @param mixed $id
+ * @return StudipTreeNode
+ */
+ public static function getNode($id): StudipTreeNode;
+
+ /**
+ * Get all direct children of the given node.
+ *
+ * @param bool $onlyVisible fetch only visible nodes?
+ * @return StudipTreeNode[]
+ */
+ public function getChildNodes(bool $onlyVisible = false): array;
+
+ /**
+ * Fetches an array of all nodes the given course is assigned to.
+ *
+ * @param string $course_id
+ * @return array
+ */
+ public static function getCourseNodes(string $course_id): array;
+
+ /**
+ * This node's unique ID.
+ *
+ * @return mixed
+ */
+ public function getId();
+
+ /**
+ * A name (=label) for this node.
+ *
+ * @return string
+ */
+ public function getName(): string;
+
+ /**
+ * Optional description for this node.
+ *
+ * @return string
+ */
+ public function getDescription(): string;
+
+ /**
+ * Gets an optional Image (Icon or Avatar) for this node.
+ *
+ * @return Icon|Avatar|null
+ */
+ public function getImage();
+
+ /**
+ * Indicator if this node has children.
+ *
+ * @return bool
+ */
+ public function hasChildNodes(): bool;
+
+ /**
+ * How many courses are assigned to this node in the given semester?
+ *
+ * @param string $semester_id
+ * @param int $semclass
+ * @param bool $with_children
+ * @return int
+ */
+ public function countCourses(
+ string $semester_id = '',
+ int $semclass = 0,
+ bool $with_children = false
+ ): int;
+
+ /**
+ * Fetches courses assigned to this node in the given semester.
+ *
+ * @param string $semester_id
+ * @param int $semclass
+ * @param string $searchterm
+ * @param bool $with_children
+ * @param string[] $courses
+ *
+ * @return Course[]
+ */
+ public function getCourses(
+ string $semester_id = 'all',
+ int $semclass = 0,
+ string $searchterm = '',
+ bool $with_children = false,
+ array $courses = []
+ ): array;
+
+ /**
+ * Returns an array containing all ancestor nodes with id and name.
+ *
+ * @return array
+ */
+ public function getAncestors(): array;
+
+}
diff --git a/lib/classes/forms/QuicksearchInput.php b/lib/classes/forms/QuicksearchInput.php
index 2531a2e..f4be547 100644
--- a/lib/classes/forms/QuicksearchInput.php
+++ b/lib/classes/forms/QuicksearchInput.php
@@ -6,7 +6,7 @@ class QuicksearchInput extends Input
{
public function render()
{
- $template = $GLOBALS['template_factory']->open('forms/checkbox_input');
+ $template = $GLOBALS['template_factory']->open('forms/quicksearch_input');
$template->title = $this->title;
$template->name = $this->name;
$template->value = $this->value;
diff --git a/lib/classes/searchtypes/TreeSearch.class.php b/lib/classes/searchtypes/TreeSearch.class.php
new file mode 100644
index 0000000..2fecf60
--- /dev/null
+++ b/lib/classes/searchtypes/TreeSearch.class.php
@@ -0,0 +1,96 @@
+<?php
+/**
+ * TreeSearch.class.php - Class of type SearchType used for searches with QuickSearch
+ *
+ * This program is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU General Public License as
+ * published by the Free Software Foundation; either version 2 of
+ * the License, or (at your option) any later version.
+ *
+ * @author Thomas Hackl <hackl@data-quest.de>
+ * @license http://www.gnu.org/licenses/gpl-2.0.html GPL version 2
+ * @category Stud.IP
+ */
+
+class TreeSearch extends StandardSearch
+{
+ /**
+ *
+ * @param string $search The search type.
+ *
+ * @param Array $search_settings Settings for the selected seach type.
+ * Depending on the search type different settings are possible
+ * which can change the output or the display of the output
+ * of the search. The array must be an associative array
+ * with the setting as array key.
+ * The following settings are implemented:
+ * Search type 'room':
+ * - display_seats: If set to true, the seats will be displayed
+ * after the name of the room.
+ *
+ * @return void
+ */
+ public function __construct($search, $search_settings = [])
+ {
+ if (is_array($search_settings)) {
+ $this->search_settings = $search_settings;
+ }
+
+ $this->avatarLike = $this->search = $search;
+ $this->sql = $this->getSQL();
+ }
+
+ /**
+ * returns the title/description of the searchfield
+ *
+ * @return string title/description
+ */
+ public function getTitle()
+ {
+ switch ($this->search) {
+ case 'sem_tree_id':
+ return _('Studienbereich suchen');
+ case 'range_tree_id':
+ return _('Eintrag in der Einrichtungshierarchie suchen');
+ default:
+ throw new UnexpectedValueException('Invalid search type {$this->search}');
+ }
+ }
+
+ /**
+ * returns a sql-string appropriate for the searchtype of the current class
+ *
+ * @return string
+ */
+ private function getSQL()
+ {
+ switch ($this->search) {
+ case 'sem_tree_id':
+ return "SELECT `sem_tree_id`, `name`
+ FROM `sem_tree`
+ WHERE `name` LIKE :input
+ OR `info` LIKE :input
+ ORDER BY `name`";
+ case 'range_tree_id':
+ return "SELECT t.`item_id`, IF(t.`studip_object_id` IS NULL, t.`name`, i.`name`)
+ FROM `range_tree` t
+ LEFT JOIN `Institute` i ON (i.`Institut_id` = t.`studip_object_id`)
+ WHERE t.`name` LIKE :input
+ OR i.`Name` LIKE :input
+ ORDER BY t.`name`, i.`Name`";
+ default:
+ throw new UnexpectedValueException("Invalid search type {$this->search}");
+ }
+ }
+
+ /**
+ * A very simple overwrite of the same method from SearchType class.
+ * returns the absolute path to this class for autoincluding this class.
+ *
+ * @return: path to this class
+ */
+ public function includePath()
+ {
+ return studip_relative_path(__FILE__);
+ }
+}
diff --git a/lib/models/RangeTreeNode.php b/lib/models/RangeTreeNode.php
new file mode 100644
index 0000000..d1c1823
--- /dev/null
+++ b/lib/models/RangeTreeNode.php
@@ -0,0 +1,263 @@
+<?php
+
+/**
+ * RangeTreeNode.php
+ * model class for table range_tree
+ *
+ * This program is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU General Public License as
+ * published by the Free Software Foundation; either version 2 of
+ * the License, or (at your option) any later version.
+ *
+ * @author Thomas Hackl <hackl@data-quest.de>
+ * @copyright 2022 Stud.IP Core-Group
+ * @license http://www.gnu.org/licenses/gpl-2.0.html GPL version 2
+ * @category Stud.IP
+ * @since 5.3
+ *
+ *
+ * @property string id database column
+ * @property string item_id database column
+ * @property string parent_id database column
+ * @property int level database column
+ * @property int priority database column
+ * @property string name database column
+ * @property string studip_object database column
+ * @property string studip_object_id database column
+ */
+class RangeTreeNode extends SimpleORMap implements StudipTreeNode
+{
+ protected static function configure($config = [])
+ {
+ $config['db_table'] = 'range_tree';
+
+ $config['belongs_to']['institute'] = [
+ 'class_name' => Institute::class,
+ 'foreign_key' => 'studip_object_id',
+ ];
+ $config['belongs_to']['parent'] = [
+ 'class_name' => RangeTreeNode::class,
+ 'foreign_key' => 'parent_id',
+ ];
+ $config['has_many']['children'] = [
+ 'class_name' => RangeTreeNode::class,
+ 'foreign_key' => 'item_id',
+ 'assoc_foreign_key' => 'parent_id',
+ 'order_by' => 'ORDER BY priority, name',
+ 'on_delete' => 'delete'
+ ];
+
+ parent::configure($config);
+ }
+
+ public static function getNode($id): StudipTreeNode
+ {
+ if ($id === 'root') {
+ return static::build([
+ 'id' => 'root',
+ 'name' => Config::get()->UNI_NAME_CLEAN,
+ ]);
+ }
+
+ return static::find($id);
+ }
+
+ public static function getCourseNodes(string $course_id): array
+ {
+ $nodes = [];
+ foreach (Course::find($course_id)->institutes as $institute) {
+ $range = self::findOneByStudip_object_id($institute->id);
+ if ($range) {
+ $nodes[] = $range;
+ }
+ }
+ return $nodes;
+ }
+
+ public function getName(): string
+ {
+ if ($this->id === 'root') {
+ return Config::get()->UNI_NAME_CLEAN;
+ }
+
+ if ($this->institute) {
+ return (string) $this->institute->name;
+ }
+
+ return $this->content['name'];
+ }
+
+ public function getDescription(): string
+ {
+ return '';
+ }
+
+ public function getImage()
+ {
+ return $this->institute ?
+ Avatar::getAvatar($this->studip_object_id) :
+ Icon::create('institute');
+ }
+
+ public function hasChildNodes(): bool
+ {
+ return count($this->children) > 0;
+ }
+
+ /**
+ * @see StudipTreeNode::getChildNodes()
+ */
+ public function getChildNodes(bool $onlyVisible = false): array
+ {
+ return self::findByParent_id($this->id, "ORDER BY `priority`, `name`");
+ }
+
+ /**
+ * @see StudipTreeNode::countCourses()
+ */
+ public function countCourses($semester_id = '', $semclass = 0, $with_children = false): int
+ {
+ if ($semester_id) {
+ $query = "SELECT COUNT(DISTINCT i.`seminar_id`)
+ FROM `seminar_inst` i
+ JOIN `seminare` s ON (s.`Seminar_id` = i.`seminar_id`)
+ LEFT JOIN `semester_courses` sc ON (i.`seminar_id` = sc.`course_id`)
+ WHERE i.`institut_id` IN (
+ SELECT DISTINCT `studip_object_id`
+ FROM `range_tree`
+ WHERE `item_id` IN (:ids)
+ ) AND (
+ sc.`semester_id` = :semester
+ OR sc.`semester_id` IS NULL
+ )";
+ $parameters = [
+ 'ids' => $with_children ? $this->getDescendantIds() : [$this->id],
+ 'semester' => $semester_id
+ ];
+ } else {
+ $query = "SELECT COUNT(DISTINCT `seminar_id`)
+ FROM `seminar_inst` i
+ JOIN `seminare` s ON (s.`Seminar_id` = i.`seminar_id`)
+ WHERE `institut_id` IN (
+ SELECT DISTINCT `studip_object_id`
+ FROM `range_tree`
+ WHERE `item_id` IN (:ids)
+ )";
+ $parameters = ['ids' => $with_children ? $this->getDescendantIds() : [$this->id]];
+ }
+
+ if ($semclass !== 0) {
+ $query .= " AND s.`status` IN (:types)";
+ $parameters['types'] = array_map(
+ function ($type) {
+ return $type['id'];
+ },
+ array_filter(
+ SemType::getTypes(),
+ function ($t) use ($semclass) { return $t['class'] === $semclass; }
+ )
+ );
+ }
+
+ return !$this->institute && !$with_children ? 0 : DBManager::get()->fetchColumn($query, $parameters);
+ }
+
+ public function getCourses(
+ $semester_id = 'all',
+ $semclass = 0,
+ $searchterm = '',
+ $with_children = false,
+ array $courses = []
+ ): array
+ {
+ if ($semester_id !== 'all') {
+ $query = "SELECT DISTINCT s.*
+ FROM `seminare` s
+ JOIN `seminar_inst` i ON (i.`seminar_id` = s.`Seminar_id`)
+ LEFT JOIN `semester_courses` sem ON (sem.`course_id` = s.`Seminar_id`)
+ WHERE i.`institut_id` IN (
+ SELECT DISTINCT `studip_object_id`
+ FROM `range_tree`
+ WHERE `item_id` IN (:ids)
+ ) AND (
+ sem.`semester_id` = :semester
+ OR sem.`semester_id` IS NULL
+ )";
+
+ $parameters = [
+ 'ids' => $with_children ? $this->getDescendantIds() : [$this->id],
+ 'semester' => $semester_id
+ ];
+ } else {
+ $query = "SELECT DISTINCT s.*
+ FROM `seminare` s
+ JOIN `seminar_inst` i ON (i.`seminar_id` = s.`Seminar_id`)
+ WHERE i.`institut_id` IN (
+ SELECT DISTINCT `studip_object_id`
+ FROM `range_tree`
+ WHERE `item_id` IN (:ids)
+ )";
+ $parameters = ['ids' => $with_children ? $this->getDescendantIds() : [$this->id]];
+ }
+
+ if ($searchterm) {
+ $query .= " AND s.`Name` LIKE :searchterm";
+ $parameters['searchterm'] = '%' . trim($searchterm) . '%';
+ }
+
+ if ($courses) {
+ $query .= " AND s.`Seminar_id` IN (:courses)";
+ $parameters['courses'] = $courses;
+ }
+
+ if ($semclass !== 0) {
+ $query .= " AND s.`status` IN (:types)";
+ $parameters['types'] = array_map(
+ function ($type) {
+ return $type['id'];
+ },
+ array_filter(
+ SemType::getTypes(),
+ function ($t) use ($semclass) { return $t['class'] === $semclass; }
+ )
+ );
+ }
+
+ if (Config::get()->IMPORTANT_SEMNUMBER) {
+ $query .= " ORDER BY s.`start_time`, s.`VeranstaltungsNummer`, s.`Name`";
+ } else {
+ $query .= " ORDER BY s.`start_time`, s.`Name`";
+ }
+
+ return DBManager::get()->fetchAll($query, $parameters, 'Course::buildExisting');
+ }
+
+ public function getDescendantIds()
+ {
+ $ids = [];
+
+ foreach ($this->children as $child) {
+ $ids = array_merge($ids, [$child->id], $child->getDescendantIds());
+ }
+
+ return $ids;
+ }
+
+ public function getAncestors(): array
+ {
+ $path = [
+ [
+ 'id' => $this->id,
+ 'name' => $this->getName(),
+ 'classname' => self::class
+ ]
+ ];
+
+ if ($this->parent_id) {
+ $path = array_merge($this->getNode($this->parent_id)->getAncestors(), $path);
+ }
+
+ return $path;
+ }
+
+}
diff --git a/lib/models/StudipStudyArea.class.php b/lib/models/StudipStudyArea.class.php
index 6650526..49f008a 100644
--- a/lib/models/StudipStudyArea.class.php
+++ b/lib/models/StudipStudyArea.class.php
@@ -29,7 +29,7 @@
* @property SimpleORMapCollection courses has_and_belongs_to_many Course
*/
-class StudipStudyArea extends SimpleORMap
+class StudipStudyArea extends SimpleORMap implements StudipTreeNode
{
/**
* This constant represents the key of the root area.
@@ -50,10 +50,6 @@ class StudipStudyArea extends SimpleORMap
'class_name' => Course::class,
'thru_table' => 'seminar_sem_tree',
];
- $config['belongs_to']['institute'] = [
- 'class_name' => Institute::class,
- 'foreign_key' => 'studip_object_id',
- ];
$config['belongs_to']['_parent'] = [
'class_name' => StudipStudyArea::class,
'foreign_key' => 'parent_id',
@@ -124,11 +120,8 @@ class StudipStudyArea extends SimpleORMap
/**
* Get the display name of this study area.
*/
- public function getName()
+ public function getName(): string
{
- if ($this->studip_object_id) {
- return $this->institute ? $this->institute->name : _('Unbekannte Einrichtung');
- }
return $this->content['name'];
}
@@ -284,26 +277,6 @@ class StudipStudyArea extends SimpleORMap
/**
- * Get the studip_object_id of this study area.
- */
- public function getStudipObjectId()
- {
- return $this->studip_object_id;
- }
-
-
- /**
- * Set the studip_object_id of this study area.
- */
- public function setStudipObjectId($id)
- {
- $this->studip_object_id = (string) $id;
- $this->resetRelation('institute');
- return $this;
- }
-
-
- /**
* Returns the children of this study area.
*/
public function getChildren()
@@ -450,4 +423,191 @@ class StudipStudyArea extends SimpleORMap
return $root;
}
+ public static function getNode($id): StudipTreeNode
+ {
+ if ($id === 'root') {
+ return static::build([
+ 'id' => 'root',
+ 'name' => Config::get()->UNI_NAME_CLEAN,
+ ]);
+ }
+
+ return static::find($id);
+ }
+
+ public static function getCourseNodes(string $course_id): array
+ {
+ return Course::find($course_id)->study_areas->getArrayCopy();
+ }
+
+ public function getDescription(): string
+ {
+ return $this->getInfo();
+ }
+
+ /**
+ * @see StudipTreeNode::getImage()
+ */
+ public function getImage()
+ {
+ return null;
+ }
+
+ public function hasChildNodes(): bool
+ {
+ return count($this->_children) > 0;
+ }
+
+ /**
+ * @see StudipTreeNode::getChildNodes()
+ */
+ public function getChildNodes(bool $onlyVisible = false): array
+ {
+ if ($onlyVisible) {
+ $visibleTypes = array_filter($GLOBALS['SEM_TREE_TYPES'], function ($t) {
+ return isset($t['hidden']) ? !$t['hidden'] : true;
+ });
+
+ return static::findBySQL(
+ "`parent_id` = :parent AND `type` IN (:types) ORDER BY `priority`, `name`",
+ ['parent' => $this->id, 'types' => $visibleTypes]
+ );
+ } else {
+ return static::findByParent_id($this->id, "ORDER BY `priority`, `name`");
+ }
+ }
+
+ /**
+ * @see StudipTreeNode::countCourses()
+ */
+ public function countCourses($semester_id = 'all', $semclass = 0, $with_children = false) :int
+ {
+ if ($semester_id !== 'all') {
+ $query = "SELECT COUNT(DISTINCT t.`seminar_id`)
+ FROM `seminar_sem_tree` t
+ JOIN `seminare` s ON (s.`Seminar_id` = t.`seminar_id`)
+ LEFT JOIN `semester_courses` sc ON (t.`seminar_id` = sc.`course_id`)
+ WHERE t.`sem_tree_id` IN (:ids)
+ AND (
+ sc.`semester_id` = :semester
+ OR sc.`semester_id` IS NULL
+ )";
+ $parameters = [
+ 'ids' => $with_children ? $this->getDescendantIds() : [$this->id],
+ 'semester' => $semester_id
+ ];
+ } else {
+ $query = "SELECT COUNT(DISTINCT t.`seminar_id`)
+ 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]];
+ }
+
+ if ($semclass !== 0) {
+ $query .= " AND s.`status` IN (:types)";
+ $parameters['types'] = array_map(
+ function ($type) {
+ return $type['id'];
+ },
+ array_filter(
+ SemType::getTypes(),
+ function ($t) use ($semclass) { return $t['class'] === $semclass; }
+ )
+ );
+ }
+
+ return $this->id === 'root' && !$with_children ? 0 : DBManager::get()->fetchColumn($query, $parameters);
+ }
+
+ public function getCourses(
+ $semester_id = 'all',
+ $semclass = 0,
+ $searchterm = '',
+ $with_children = false,
+ array $courses = []
+ ): array
+ {
+ if ($semester_id !== 'all') {
+ $query = "SELECT DISTINCT s.*
+ FROM `seminare` s
+ JOIN `seminar_sem_tree` t ON (t.`seminar_id` = s.`Seminar_id`)
+ LEFT JOIN `semester_courses` sem ON (sem.`course_id` = s.`Seminar_id`)
+ WHERE t.`sem_tree_id` IN (:ids)
+ AND (
+ sem.`semester_id` = :semester
+ OR sem.`semester_id` IS NULL
+ )";
+ $parameters = [
+ 'ids' => $with_children ? $this->getDescendantIds() : [$this->id],
+ 'semester' => $semester_id
+ ];
+ } else {
+ $query = "SELECT DISTINCT s.*
+ FROM `seminare` s
+ JOIN `seminar_sem_tree` t ON (t.`seminar_id` = s.`Seminar_id`)
+ WHERE t.`sem_tree_id` IN (:ids)";
+ $parameters = ['ids' => $with_children ? $this->getDescendantIds() : [$this->id]];
+ }
+
+ if ($semclass !== 0) {
+ $query .= " AND s.`status` IN (:types)";
+ $parameters['types'] = array_map(
+ function ($type) {
+ return $type['id'];
+ },
+ array_filter(
+ SemType::getTypes(),
+ function ($t) use ($semclass) { return $t['class'] === $semclass; }
+ )
+ );
+ }
+
+ if ($searchterm) {
+ $query .= " AND s.`Name` LIKE :searchterm";
+ $parameters['searchterm'] = '%' . trim($searchterm) . '%';
+ }
+
+ if ($courses) {
+ $query .= " AND t.`seminar_id` IN (:courses)";
+ $parameters['courses'] = $courses;
+ }
+
+ if (Config::get()->IMPORTANT_SEMNUMBER) {
+ $query .= " ORDER BY s.`start_time`, s.`VeranstaltungsNummer`, s.`Name`";
+ } else {
+ $query .= " ORDER BY s.`start_time`, s.`Name`";
+ }
+
+ return DBManager::get()->fetchAll($query, $parameters, 'Course::buildExisting');
+ }
+
+ public function getAncestors(): array
+ {
+ $path = [
+ [
+ 'id' => $this->id,
+ 'name' => $this->getName(),
+ 'classname' => static::class
+ ]
+ ];
+
+ if ($this->parent_id) {
+ $path = array_merge($this->getNode($this->parent_id)->getAncestors(), $path);
+ }
+
+ return $path;
+ }
+
+ private function getDescendantIds()
+ {
+ $ids = [];
+
+ foreach ($this->_children as $child) {
+ $ids = array_merge($ids, [$child->id], $child->getDescendantIds());
+ }
+
+ return $ids;
+ }
+
}
diff --git a/lib/navigation/AdminNavigation.php b/lib/navigation/AdminNavigation.php
index 1b28e24..6c9d7e3 100644
--- a/lib/navigation/AdminNavigation.php
+++ b/lib/navigation/AdminNavigation.php
@@ -91,11 +91,11 @@ class AdminNavigation extends Navigation
$navigation = new Navigation(_('Standort'));
if ($perm->have_perm(Config::get()->RANGE_TREE_ADMIN_PERM ? Config::get()->RANGE_TREE_ADMIN_PERM : 'admin')) {
- $navigation->addSubNavigation('range_tree', new Navigation(_('Einrichtungshierarchie'), 'admin_range_tree.php'));
+ $navigation->addSubNavigation('range_tree', new Navigation(_('Einrichtungshierarchie'), 'dispatch.php/admin/tree/rangetree'));
}
if ($perm->have_perm(Config::get()->SEM_TREE_ADMIN_PERM ? Config::get()->SEM_TREE_ADMIN_PERM : 'admin') && $perm->is_fak_admin()) {
- $navigation->addSubNavigation('sem_tree', new Navigation(_('Veranstaltungshierarchie'), 'admin_sem_tree.php'));
+ $navigation->addSubNavigation('sem_tree', new Navigation(_('Veranstaltungshierarchie'), 'dispatch.php/admin/tree/semtree'));
}
if ($perm->have_perm(Config::get()->LOCK_RULE_ADMIN_PERM ? Config::get()->LOCK_RULE_ADMIN_PERM : 'admin')) {
diff --git a/public/admin_range_tree.php b/public/admin_range_tree.php
deleted file mode 100644
index 364f4fc..0000000
--- a/public/admin_range_tree.php
+++ /dev/null
@@ -1,59 +0,0 @@
-<?php
-# Lifter002: TODO
-# Lifter007: TODO
-# Lifter003: TODO
-# Lifter010: TODO
-/**
-* Frontend
-*
-*
-*
-* @author André Noack <andre.noack@data.quest.de>
-* @access public
-* @modulegroup admin_modules
-* @module admin_range_tree
-* @package Admin
-*/
-// +---------------------------------------------------------------------------+
-// This file is part of Stud.IP
-// admin_range_tree.php
-//
-// Copyright (c) 2002 André Noack <noack@data-quest.de>
-// Suchi & Berg GmbH <info@data-quest.de>
-// +---------------------------------------------------------------------------+
-// This program is free software; you can redistribute it and/or
-// modify it under the terms of the GNU General Public License
-// as published by the Free Software Foundation; either version 2
-// of the License, or any later version.
-// +---------------------------------------------------------------------------+
-// This program is distributed in the hope that it will be useful,
-// but WITHOUT ANY WARRANTY; without even the implied warranty of
-// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
-// GNU General Public License for more details.
-// You should have received a copy of the GNU General Public License
-// along with this program; if not, write to the Free Software
-// Foundation, Inc., 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA.
-// +---------------------------------------------------------------------------+
-
-
-require '../lib/bootstrap.php';
-
-page_open(["sess" => "Seminar_Session", "auth" => "Seminar_Auth", "perm" => "Seminar_Perm", "user" => "Seminar_User"]);
-$perm->check(Config::get()->RANGE_TREE_ADMIN_PERM ?: 'admin');
-
-include 'lib/seminar_open.php'; //hier werden die sessions initialisiert
-
-PageLayout::setTitle(_('Einrichtungshierarchie bearbeiten'));
-Navigation::activateItem('/admin/locations/range_tree');
-
-ob_start();
-
-$the_tree = new StudipRangeTreeViewAdmin();
-$the_tree->open_ranges['root'] = true;
-$the_tree->showTree();
-
-$template = $GLOBALS['template_factory']->open('layouts/base.php');
-$template->content_for_layout = ob_get_clean();
-echo $template->render();
-
-page_close();
diff --git a/public/admin_sem_tree.php b/public/admin_sem_tree.php
deleted file mode 100644
index e981be7..0000000
--- a/public/admin_sem_tree.php
+++ /dev/null
@@ -1,310 +0,0 @@
-<?php
-# Lifter001: TEST
-# Lifter002: TODO
-# Lifter007: TODO
-# Lifter003: TODO
-# Lifter010: TODO
-// +---------------------------------------------------------------------------+
-// This file is part of Stud.IP
-// admin_sem_tree.php
-//
-//
-// Copyright (c) 2003 André Noack <noack@data-quest.de>
-// Suchi & Berg GmbH <info@data-quest.de>
-// +---------------------------------------------------------------------------+
-// This program is free software; you can redistribute it and/or
-// modify it under the terms of the GNU General Public License
-// as published by the Free Software Foundation; either version 2
-// of the License, or any later version.
-// +---------------------------------------------------------------------------+
-// This program is distributed in the hope that it will be useful,
-// but WITHOUT ANY WARRANTY; without even the implied warranty of
-// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
-// GNU General Public License for more details.
-// You should have received a copy of the GNU General Public License
-// along with this program; if not, write to the Free Software
-// Foundation, Inc., 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA.
-// +---------------------------------------------------------------------------+
-
-use Studip\Button, Studip\LinkButton;
-
-require '../lib/bootstrap.php';
-
-page_open(["sess" => "Seminar_Session", "auth" => "Seminar_Auth", "perm" => "Seminar_Perm", "user" => "Seminar_User"]);
-$perm->check(Config::get()->SEM_TREE_ADMIN_PERM ?: 'admin');
-if (!$perm->is_fak_admin()){
- $perm->perm_invalid(0,0);
- page_close();
- die;
-}
-
-include 'lib/seminar_open.php'; // initialise Stud.IP-Session
-
-PageLayout::setTitle(_('Veranstaltungshierachie bearbeiten'));
-Navigation::activateItem('/admin/locations/sem_tree');
-
-// Start of Output
-ob_start();
-
-$view = DbView::getView('sem_tree');
-$the_tree = new StudipSemTreeViewAdmin(Request::option('start_item_id'));
-$search_obj = new StudipSemSearch();
-
-$_open_items =& $the_tree->open_items;
-$_open_ranges =& $the_tree->open_ranges;
-$_possible_open_items = [];
-
-if (!Config::GetInstance()->getValue('SEM_TREE_ALLOW_BRANCH_ASSIGN')){
- if(is_array($_open_items)){
- foreach($_open_items as $item_id => $value){
- if(!$the_tree->tree->getNumKids($item_id)) $_possible_open_items[$item_id] = $value;
- }
- }
-} else {
- $_possible_open_items = $_open_items;
-}
-
-// allow add only for items where user has admin permission and which are not hidden
-if (is_array($_possible_open_items)) {
- foreach ($_possible_open_items as $item_id => $value) {
- if (!$the_tree->isItemAdmin($item_id) || $the_tree->tree->isHiddenItem($item_id)) {
- unset($_possible_open_items[$item_id]);
- }
- }
-}
-
-if ($search_obj->search_done){
- if ($search_obj->search_result->numRows > 50){
- PageLayout::postError(_("Es wurden mehr als 50 Veranstaltungen gefunden! Bitte schränken Sie Ihre Suche weiter ein."));
- } elseif ($search_obj->search_result->numRows > 0){
- PageLayout::postSuccess(sprintf(
- _("Es wurden %s Veranstaltungen gefunden, und in Ihre Merkliste eingefügt"),
- $search_obj->search_result->numRows
- ));
- if (is_array($_SESSION['_marked_sem']) && count($_SESSION['_marked_sem'])){
- $_SESSION['_marked_sem'] = array_merge(
- (array)$_SESSION['_marked_sem'],
- (array)$search_obj->search_result->getDistinctRows("seminar_id")
- );
- } else {
- $_SESSION['_marked_sem'] = $search_obj->search_result->getDistinctRows("seminar_id");
- }
- } else {
- PageLayout::postInfo(_("Es wurden keine Veranstaltungen gefunden, auf die Ihre Suchkriterien zutreffen."));
- }
-}
-
-if (Request::option('cmd') === "MarkList"){
- $sem_mark_list = Request::quotedArray('sem_mark_list');
- if ($sem_mark_list){
- if (Request::quoted('mark_list_aktion') == "del"){
- $count_del = 0;
- for ($i = 0; $i < count($sem_mark_list); ++$i){
- if (isset($_SESSION['_marked_sem'][$sem_mark_list[$i]])){
- ++$count_del;
- unset($_SESSION['_marked_sem'][$sem_mark_list[$i]]);
- }
- }
- PageLayout::postSuccess(sprintf(
- _("%s Veranstaltung(en) wurde(n) aus Ihrer Merkliste entfernt."),
- $count_del
- ));
- } else {
- $tmp = explode("_",Request::quoted('mark_list_aktion'));
- $item_ids[0] = $tmp[1];
- if ($item_ids[0] === "all"){
- $item_ids = [];
- foreach ($_possible_open_items as $key => $value){
- if($key !== 'root')
- $item_ids[] = $key;
- }
- }
- for ($i = 0; $i < count($item_ids); ++$i){
- $count_ins = 0;
- for ($j = 0; $j < count($sem_mark_list); ++$j){
- if ($sem_mark_list[$j]){
- $count_ins += StudipSemTree::InsertSemEntry($item_ids[$i], $sem_mark_list[$j]);
- }
- }
- $_msg .= sprintf(
- _("%s Veranstaltung(en) in <b>" .htmlReady($the_tree->tree->tree_data[$item_ids[$i]]['name']) . "</b> eingetragen.<br>"),
- $count_ins
- );
- }
- if ($_msg) {
- PageLayout::postSuccess($_msg);
- }
- $the_tree->tree->init();
- }
- }
-}
-if ($the_tree->mode === "MoveItem" || $the_tree->mode === "CopyItem"){
- if ($_msg){
- $_msg .= "§";
- }
- if ($the_tree->mode === "MoveItem"){
- $text = _("Der Verschiebemodus ist aktiviert. Bitte wählen Sie ein Einfügesymbol %s aus, um das Element <b>%s</b> an diese Stelle zu verschieben.%s");
- } else {
- $text = _("Der Kopiermodus ist aktiviert. Bitte wählen Sie ein Einfügesymbol %s aus, um das Element <b>%s</b> an diese Stelle zu kopieren.%s");
- }
- PageLayout::postInfo(sprintf(
- $text ,
- Icon::create('arr_2right', 'sort', ['title' => _('Einfügesymbol')])->asImg(),
- htmlReady($the_tree->tree->tree_data[$the_tree->move_item_id]['name']),
- "<div align=\"right\">"
- . LinkButton::createCancel(
- _('Abbrechen'),
- $the_tree->getSelf("cmd=Cancel&item_id=$the_tree->move_item_id"),
- ['title' => _("Verschieben / Kopieren abbrechen")]
- )
- ."</div>"
- ));
-}
-
-?>
- <?
- $search_obj->attributes_default = ['style' => ''];
- // $search_obj->search_fields['type']['size'] = 30 ;
- echo $search_obj->getFormStart(URLHelper::getLink($the_tree->getSelf()), ['class' => 'default narrow']);
- ?>
- <fieldset>
- <legend><?= _("Veranstaltungssuche") ?></legend>
-
- <label class="col-3">
- <?=_("Titel")?>
- <?=$search_obj->getSearchField("title")?>
- </label>
-
- <label class="col-3">
- <?=_("Untertitel")?>
- <?=$search_obj->getSearchField("sub_title")?>
- </label>
-
- <label class="col-3">
- <?=_("Nummer")?>
- <?=$search_obj->getSearchField("number")?>
- </label>
-
- <label class="col-3">
- <?=_("Kommentar")?>
- <?=$search_obj->getSearchField("comment")?>
- </label>
-
- <label class="col-3">
- <?=_("Lehrende")?>
- <?=$search_obj->getSearchField("lecturer")?>
- </label>
-
- <label class="col-3">
- <?=_("Bereich")?>
- <?=$search_obj->getSearchField("scope")?>
- </label>
-
- <label>
- <?=_("Kombination")?>
- <?=$search_obj->getSearchField('combination')?>
- </label>
-
- <label class="col-3">
- <?=_("Typ")?>
- <?=$search_obj->getSearchField("type", ['class' => 'size-s'])?>
- </label>
-
- <label class="col-3">
- <?=_("Semester")?>
- <?=$search_obj->getSearchField("sem", ['class' => 'size-s'])?>
- </label>
- </fieldset>
-
- <footer>
- <?=$search_obj->getSearchButton();?>
- <?=$search_obj->getNewSearchButton();?>
- </footer>
-
- <?=$search_obj->getFormEnd();?>
-<br>
-<table width="100%" border="0" cellpadding="0" cellspacing="0">
- <tr>
- <td class="blank" width="75%" align="left" valign="top" colspan="2">
- <? $the_tree->showSemTree(); ?>
- </td>
- </tr>
-</table>
-
-<?
-// Create Clipboard (use a second output buffer)
-ob_start();
-?>
- <form action="<?=URLHelper::getLink($the_tree->getSelf("cmd=MarkList"))?>" method="post" class="default">
- <?= CSRFProtection::tokenTag() ?>
- <select multiple size="10" name="sem_mark_list[]" style="font-size:8pt;width:100%" class="nested-select">
- <?
- $cols = 50;
- if (is_array($_SESSION['_marked_sem']) && count($_SESSION['_marked_sem'])){
- $view->params[0] = array_keys($_SESSION['_marked_sem']);
- $entries = new DbSnapshot($view->get_query("view:SEMINAR_GET_SEMDATA"));
- $sem_data = $entries->getGroupedResult("seminar_id");
- $sem_number = -1;
- foreach ($sem_data as $seminar_id => $data) {
- if ((int)key($data['sem_number']) !== $sem_number){
- if ($sem_number !== -1) {
- echo '</optgroup>';
- }
- $sem_number = key($data['sem_number']);
- echo "\n<optgroup label=\"" . $the_tree->tree->sem_dates[$sem_number]['name'] . "\">";
- }
- $sem_name = key($data["Name"]);
- $sem_number_end = (int)key($data["sem_number_end"]);
- if ($sem_number !== $sem_number_end){
- $sem_name .= " (" . $the_tree->tree->sem_dates[$sem_number]['name'] . " - ";
- $sem_name .= (($sem_number_end === -1) ? _("unbegrenzt") : $the_tree->tree->sem_dates[$sem_number_end]['name']) . ")";
- }
- $line = htmlReady(my_substr($sem_name,0,$cols));
- $tooltip = $sem_name . " (" . join(",",array_keys($data["doz_name"])) . ")";
- echo "\n<option value=\"$seminar_id\" " . tooltip($tooltip,false) . ">$line</option>";
- }
- echo '</optgroup>';
- }
- ?>
- </select>
- <select name="mark_list_aktion" style="font-size:8pt;width:100%;margin-top:5px;">
- <?
- if (is_array($_possible_open_items) && count($_possible_open_items) && !(count($_possible_open_items) === 1 && $_possible_open_items['root'])){
- echo "\n<option value=\"insert_all\">" . _("Markierte in alle geöffneten Bereiche eintragen") . "</option>";
- foreach ($_possible_open_items as $item_id => $value){
- echo "\n<option value=\"insert_{$item_id}\">"
- . sprintf(
- _('Markierte in "%s" eintragen'),
- htmlReady(my_substr($the_tree->tree->tree_data[$item_id]['name'],0,floor($cols * .8))
- ))
- . "</option>";
- }
- }
- ?>
- <option value="del"><?=_("Markierte aus der Merkliste löschen")?></option>
- </select>
- <div align="center">
- <?= Button::create(
- _('OK'),
- [
- 'title' => _("Gewählte Aktion starten"),
- 'style' => 'vertical-align:middle;margin:3px;',
- 'class' => 'accept button'
- ]
- ); ?>
- </div>
- </form>
-<?
-
-// Add Clipboard to Sidebar (get the inner/second output buffer)
-$content = ob_get_clean();
-$widget = new SidebarWidget();
-$widget->setTitle(_('Merkliste'));
-$widget->addElement(new WidgetElement($content));
-Sidebar::get()->addWidget($widget);
-
-$template = $GLOBALS['template_factory']->open('layouts/base.php');
-$template->content_for_layout = ob_get_clean();
-echo $template->render();
-
-page_close();
diff --git a/resources/assets/javascripts/bootstrap/treeview.js b/resources/assets/javascripts/bootstrap/treeview.js
new file mode 100644
index 0000000..998a70e
--- /dev/null
+++ b/resources/assets/javascripts/bootstrap/treeview.js
@@ -0,0 +1,12 @@
+import StudipTree from '../../../vue/components/tree/StudipTree.vue'
+
+STUDIP.ready(() => {
+ document.querySelectorAll('[data-studip-tree]').forEach(element => {
+ STUDIP.Vue.load().then(({ createApp }) => {
+ createApp({
+ el: element,
+ components: { StudipTree }
+ })
+ })
+ });
+});
diff --git a/resources/assets/javascripts/entry-base.js b/resources/assets/javascripts/entry-base.js
index 5de07aa..d40d4e0 100644
--- a/resources/assets/javascripts/entry-base.js
+++ b/resources/assets/javascripts/entry-base.js
@@ -83,6 +83,7 @@ import "./bootstrap/cache-admin.js"
import "./bootstrap/oer.js"
import "./bootstrap/courseware.js"
import "./bootstrap/responsive-navigation.js"
+import "./bootstrap/treeview.js"
import "./mvv_course_wizard.js"
import "./mvv.js"
diff --git a/resources/assets/stylesheets/scss/sidebar.scss b/resources/assets/stylesheets/scss/sidebar.scss
index c009e0e..f03d7a2 100644
--- a/resources/assets/stylesheets/scss/sidebar.scss
+++ b/resources/assets/stylesheets/scss/sidebar.scss
@@ -330,7 +330,7 @@ select.sidebar-selectlist {
.reset-search {
background-color: transparent;
- border: 1px solid $base-color-60;
+ border: 1px solid var(--dark-gray-color-30);
border-left: 0;
border-right: 0;
display: inline-block;
diff --git a/resources/assets/stylesheets/scss/tree.scss b/resources/assets/stylesheets/scss/tree.scss
new file mode 100644
index 0000000..dbad68b
--- /dev/null
+++ b/resources/assets/stylesheets/scss/tree.scss
@@ -0,0 +1,384 @@
+$tree-outline: 1px solid var(--light-gray-color-40);
+
+.studip-tree {
+ &.studip-tree-navigatable {
+ > header {
+ display: flex;
+ flex-direction: row;
+ flex-wrap: nowrap;
+
+ h1 {
+ display: inline-block;
+ width: calc(100% - 28px);
+ }
+ }
+
+ .contentbar {
+ display: relative;
+
+ .contentbar-wrapper-right {
+ display: inherit;
+
+ .action-menu {
+ button {
+ top: -2px;
+ }
+ }
+ }
+ }
+
+ .studip-tree-navigation-wrapper {
+ margin-right: 15px;
+ text-indent: 0;
+
+ .studip-tree-navigation {
+ background-color: var(--white);
+ border: 1px solid var(--content-color-40);
+ box-shadow: 2px 2px mix($base-gray, $white, 20%);
+ right: -20px;
+ padding: 10px;
+ position: absolute;
+ top: -15px;
+ width: 400px;
+ z-index: 3;
+
+ > header {
+ border-bottom: 1px solid var(--content-color-40);
+ display: flex;
+ height: 60px;
+ margin-bottom: 15px;
+ margin-top: -15px;
+ padding: 2px 0;
+
+ h1 {
+ line-height:60px;
+ margin-bottom: 0;
+ width: calc(100% - 40px);
+ }
+
+ button {
+ flex: 0;
+ padding-top: 10px;
+ }
+ }
+
+ .studip-tree-node {
+ width: 100%;
+ }
+ }
+ }
+ }
+
+ section {
+ margin-left: 0;
+ margin-right: 0;
+ }
+
+ button {
+ background: transparent;
+ border: 0;
+ color: var(--base-color);
+ cursor: pointer;
+ padding: 0;
+
+ &:hover {
+ .studip-tree-child-title {
+ text-decoration: underline;
+ }
+ }
+ }
+
+ .studip-tree-course {
+ .course-dates {
+ color: var(--dark-gray-color-80);
+ font-size: $font-size-small;
+ padding-left: 35px;
+ }
+
+ .course-details {
+ color: var(--dark-gray-color-80);
+ font-size: $font-size-small;
+ text-align: right;
+
+ .admission-state {
+ height: 18px;
+ }
+
+ .course-lecturers {
+ list-style: none;
+ padding-left: 0;
+ }
+ }
+ }
+
+ /* Display as foldable tree */
+ .studip-tree-node {
+
+ width: 100%;
+
+ a {
+ cursor: pointer;
+ display: flex;
+
+ img {
+ vertical-align: bottom;
+ }
+ }
+
+ .studip-tree-node-content {
+
+ display: flex;
+
+ &.studip-tree-node-active {
+ background-color: var(--light-gray-color-20);
+ margin: -5px;
+ padding: 5px;
+ }
+
+ .studip-tree-node-toggle {
+ margin-left: -2px;
+ margin-right: 5px;
+ }
+
+ .tooltip {
+ line-height: 24px;
+ margin-left: 5px;
+ }
+
+ .studip-tree-node-assignment-state {
+ margin-right: 10px;
+
+ img, svg {
+ vertical-align: text-bottom;
+ }
+ }
+
+ a.studip-tree-node-edit-link {
+ opacity: 0;
+ visibility: hidden;
+
+ }
+
+ &:hover {
+ background-color: var(--light-gray-color-20);
+
+ a.studip-tree-node-edit-link {
+ opacity: 1;
+ visibility: visible;
+ }
+ }
+ }
+
+ .studip-tree-children {
+ list-style: none;
+ padding-left: 38px;
+
+ li {
+ border-left: $tree-outline;
+ display: flex;
+ margin-left: -31px;
+ padding: 5px 0 5px 5px;
+
+ &:before {
+ border-bottom: $tree-outline;
+ content: "";
+ display: inline-block;
+ height: 1em;
+ left: -5px;
+ position: relative;
+ top: -5px;
+ vertical-align: top;
+ width: 10px;
+ }
+
+ &:last-child {
+ border-left: none;
+
+ &:before {
+ border-left: $tree-outline;
+ }
+ }
+ }
+ }
+ }
+
+ > .studip-tree-node {
+ width: calc(100% - 25px);
+ }
+
+ /* Top breadcrumb */
+ .studip-tree-breadcrumb {
+ display: flex;
+ flex-direction: row;
+ flex-wrap: nowrap;
+ max-width: 100%;
+ padding: 1em;
+ top: 2px;
+
+ .contentbar-wrapper-left {
+ max-width: calc(100% - 25px);
+
+ &.with-navigation {
+ max-width: calc(100% - 50px);
+ }
+
+ &.editable {
+ max-width: calc(100% - 50px);
+ }
+
+ &.with-navigation-and-editable {
+ max-width: calc(100% - 75px);
+ }
+
+ img {
+ vertical-align: text-bottom;
+ }
+
+ .studip-tree-breadcrumb-list {
+ display: inline-block;
+ flex: 1;
+ line-height: 24px;
+ margin-left: 15px;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ }
+ }
+
+ .action-menu {
+ position: relative;
+ top: 5px;
+ width: 30px;
+ }
+
+ }
+
+ /* Display as tiled list */
+ .studip-tree-list {
+ section, nav:not(.contentbar-nav) {
+ padding: 15px;
+ }
+
+ .studip-tree-children {
+ display: grid;
+ grid-gap: 15px;
+ grid-template-columns: repeat(auto-fit, $sidebar-width);
+ list-style: none;
+ overflow-wrap: break-word;
+ padding-left: 0;
+
+ .studip-tree-child {
+ background: var(--dark-gray-color-5);
+ border: solid thin var(--light-gray-color-40);
+ display: flex;
+ height: 130px;
+ padding: 10px;
+
+ /* Handle for drag&drop */
+ .drag-handle {
+ background-position-y: 8px;
+ }
+
+ a {
+ display: flex;
+ flex-direction: column;
+ padding: 10px;
+ text-align: left;
+
+ .studip-tree-child-title {
+ font-size: 1.1em;
+ font-weight: bold;
+ }
+ }
+
+ &:hover {
+ background: var(--white);
+
+ button {
+ .studip-tree-child-title {
+ color: var(--red);
+ }
+ }
+ }
+ }
+ }
+
+ table {
+ tr {
+ td {
+ line-height: 24px;
+ padding: 10px;
+ vertical-align: top;
+
+ a {
+ img {
+ margin-right: 5px;
+ vertical-align: bottom;
+ }
+ }
+ }
+ }
+ }
+ }
+
+ /* Display as table */
+ .studip-tree-table {
+ table {
+ .studip-tree-node-info {
+ font-size: 0.9em;
+ padding: 15px;
+ }
+
+ tbody {
+ tr {
+
+ &.studip-tree-course {
+ .course-dates {
+ padding-left: 0;
+ }
+ }
+
+ td {
+ line-height: 28px;
+ padding: 5px;
+ vertical-align: top;
+
+ /* Handle for drag&drop */
+ .drag-handle {
+ background-position-y: -5px;
+ padding-right: 10px;
+ }
+
+ button {
+ background: transparent;
+ border: 0;
+ color: var(--base-color);
+ cursor: pointer;
+
+ &:hover {
+ text-decoration: underline;
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+
+ .studip-tree-course-path {
+ font-size: 0.9em;
+ list-style: none;
+ padding: 5px;
+
+ button {
+ padding: 0;
+ }
+ }
+}
+
+form.default {
+ .studip-tree-node {
+ padding-top: unset !important;
+ }
+}
+
diff --git a/resources/assets/stylesheets/studip.scss b/resources/assets/stylesheets/studip.scss
index e47024b..be72f0a 100644
--- a/resources/assets/stylesheets/studip.scss
+++ b/resources/assets/stylesheets/studip.scss
@@ -98,6 +98,7 @@
@import "scss/tooltip";
@import "scss/tfa";
@import "scss/tour";
+@import "scss/tree";
@import "scss/typography";
@import "scss/user-administration";
@import "scss/wiki";
diff --git a/resources/vue/components/SearchWidget.vue b/resources/vue/components/SearchWidget.vue
new file mode 100644
index 0000000..d590b00
--- /dev/null
+++ b/resources/vue/components/SearchWidget.vue
@@ -0,0 +1,56 @@
+<template>
+ <sidebar-widget id="search-widget" class="sidebar-search" :title="$gettext('Suche')">
+ <template #content>
+ <form class="sidebar-search">
+ <ul class="needles">
+ <li>
+ <div class="input-group files-search">
+ <input type="text" id="searchterm" name="searchterm" v-model="searchterm"
+ :placeholder="$gettext('Veranstaltung suchen')"
+ :aria-label="$gettext('Veranstaltung suchen')">
+ <a v-if="isActive" @click.prevent="cancelSearch" class="reset-search">
+ <studip-icon shape="decline" size="20"></studip-icon>
+ </a>
+ <button type="submit" class="submit-search" :title="$gettext('Suchen')"
+ @click.prevent="doSearch">
+ <studip-icon shape="search" :size="20"></studip-icon>
+ </button>
+ </div>
+ </li>
+ </ul>
+ </form>
+ </template>
+ </sidebar-widget>
+</template>
+
+<script>
+import SidebarWidget from './SidebarWidget.vue';
+import StudipIcon from './StudipIcon.vue';
+
+export default {
+ name: 'search-widget',
+ components: {
+ StudipIcon,
+ SidebarWidget
+ },
+ data() {
+ return {
+ searchterm: '',
+ isActive: false
+ };
+ },
+ methods: {
+ doSearch() {
+ if (this.searchterm !== '') {
+ this.isActive = true;
+ STUDIP.eventBus.emit('do-search', this.searchterm);
+ }
+ },
+ cancelSearch() {
+ this.isActive = false;
+ this.searchterm = '';
+ STUDIP.eventBus.emit('cancel-search');
+ }
+ }
+}
+</script>
diff --git a/resources/vue/components/StudipProgressIndicator.vue b/resources/vue/components/StudipProgressIndicator.vue
index b6f7ee2..62a33a8 100644
--- a/resources/vue/components/StudipProgressIndicator.vue
+++ b/resources/vue/components/StudipProgressIndicator.vue
@@ -31,7 +31,7 @@ export default {
};
},
hasDescription () {
- return this.description.trim().length > 0;
+ return this.description?.trim().length > 0;
}
}
}
diff --git a/resources/vue/components/tree/AssignLinkWidget.vue b/resources/vue/components/tree/AssignLinkWidget.vue
new file mode 100644
index 0000000..fec478f
--- /dev/null
+++ b/resources/vue/components/tree/AssignLinkWidget.vue
@@ -0,0 +1,46 @@
+<template>
+ <sidebar-widget v-if="node" id="assignwidget" class="sidebar-assign" :title="$gettext('Zuordnung')">
+ <template #content>
+ <a :href="assignUrl" :title="$gettext('Angezeigte Veranstaltungen zuordnen')"
+ @click.prevent="assignCurrentCourses">
+ <studip-icon shape="arr_2right"></studip-icon>
+ {{ $gettext('Angezeigte Veranstaltungen zuordnen') }}
+ </a>
+ </template>
+ </sidebar-widget>
+</template>
+
+<script>
+import SidebarWidget from '../SidebarWidget.vue';
+import StudipIcon from '../StudipIcon.vue';
+import { TreeMixin } from '../../mixins/TreeMixin';
+
+export default {
+ name: 'AssignLinkWidget',
+ components: { SidebarWidget, StudipIcon },
+ mixins: [ TreeMixin ],
+ props: {
+ node: {
+ type: String,
+ required: true
+ },
+ courses: {
+ type: Array,
+ default: () => []
+ }
+ },
+ computed: {
+ assignUrl() {
+ return STUDIP.URLHelper.getURL('dispatch.php/admin/tree/batch_assign_semtree');
+ }
+ },
+ methods: {
+ assignCurrentCourses() {
+ STUDIP.Dialog.fromURL(this.assignUrl, { data: {
+ assign_semtree: this.courses.map(course => course.id),
+ return: window.location.href
+ }});
+ }
+ }
+}
+</script>
diff --git a/resources/vue/components/tree/StudipTree.vue b/resources/vue/components/tree/StudipTree.vue
new file mode 100644
index 0000000..ff49373
--- /dev/null
+++ b/resources/vue/components/tree/StudipTree.vue
@@ -0,0 +1,214 @@
+<template>
+ <div>
+ <div v-if="!isSearching"
+ class="studip-tree" :class="{'studip-tree-navigatable': showStructureAsNavigation}">
+ <studip-progress-indicator v-if="isLoading" :size="48"></studip-progress-indicator>
+ <studip-tree-list v-if="viewType === 'list' && startNode" :with-children="withChildren"
+ :visible-children-only="visibleChildrenOnly"
+ :with-courses="withCourses" :semester="semester" :sem-class="semClass" :node="startNode"
+ :breadcrumb-icon="breadcrumbIcon" :editable="editable" :edit-url="editUrl"
+ :create-url="createUrl" :delete-url="deleteUrl" :with-export="withExport"
+ :show-structure-as-navigation="showStructureAsNavigation" :assignable="assignable"
+ :with-course-assign="withCourseAssign"
+ @change-current-node="changeCurrentNode"></studip-tree-list>
+ <studip-tree-table v-else-if="viewType === 'table' && startNode" :with-children="withChildren"
+ :visible-children-only="visibleChildrenOnly"
+ :with-courses="withCourses" :semester="semester" :sem-class="semClass" :node="startNode"
+ :breadcrumb-icon="breadcrumbIcon" :editable="editable" :edit-url="editUrl"
+ :create-url="createUrl" :delete-url="deleteUrl" :with-export="withExport"
+ :show-structure-as-navigation="showStructureAsNavigation" :assignable="assignable"
+ :with-course-assign="withCourseAssign"
+ @change-current-node="changeCurrentNode"></studip-tree-table>
+ <studip-tree-node v-else-if="viewType === 'tree' && startNode" :with-info="withInfo"
+ :visible-children-only="visibleChildrenOnly" :node="startNode"
+ :open-levels="openLevels" :openNodes="openNodes" :breadcrumb-icon="breadcrumbIcon"
+ :editable="editable" :edit-url="editUrl" :create-url="createUrl" :delete-url="deleteUrl"
+ :assignable="assignable" :assign-leaves-only="assignLeavesOnly"
+ :not-assignable-nodes="notAssignableNodes"></studip-tree-node>
+
+ </div>
+ <div v-else class="studip-tree">
+ <tree-search-result :search-config="searchConfig"></tree-search-result>
+ </div>
+ <MountingPortal v-if="withSearch" mountTo="#search-widget" name="sidebar-search">
+ <search-widget></search-widget>
+ </MountingPortal>
+ </div>
+</template>
+
+<script>
+import axios from 'axios';
+import { TreeMixin } from '../../mixins/TreeMixin';
+import StudipProgressIndicator from '../StudipProgressIndicator.vue';
+import SearchWidget from '../SearchWidget.vue';
+import StudipTreeList from './StudipTreeList.vue';
+import StudipTreeTable from './StudipTreeTable.vue';
+import StudipTreeNode from './StudipTreeNode.vue';
+import TreeSearchResult from './TreeSearchResult.vue';
+
+export default {
+ name: 'StudipTree',
+ components: {
+ TreeSearchResult, SearchWidget, StudipProgressIndicator, StudipTreeList, StudipTreeTable, StudipTreeNode
+ },
+ mixins: [ TreeMixin ],
+ props: {
+ viewType: {
+ type: String,
+ default: 'tree'
+ },
+ treeId: {
+ type: String,
+ default: ''
+ },
+ startId: {
+ type: String,
+ required: true
+ },
+ title: {
+ type: String,
+ default: ''
+ },
+ openNodes: {
+ type: Array,
+ default: () => []
+ },
+ openLevels: {
+ type: Number,
+ default: 0
+ },
+ withChildren: {
+ type: Boolean,
+ default: true
+ },
+ withInfo: {
+ type: Boolean,
+ default: true
+ },
+ visibleChildrenOnly: {
+ type: Boolean,
+ default: true
+ },
+ withCourses: {
+ type: Boolean,
+ default: false
+ },
+ semester: {
+ type: String,
+ default: ''
+ },
+ semClass: {
+ type: Number,
+ default: 0
+ },
+ breadcrumbIcon: {
+ type: String,
+ default: 'literature'
+ },
+ itemIcon: {
+ type: String,
+ default: 'literature'
+ },
+ withSearch: {
+ type: Boolean,
+ default: false
+ },
+ withExport: {
+ type: Boolean,
+ default: false
+ },
+ withCourseAssign: {
+ type: Boolean,
+ default: false
+ },
+ editable: {
+ type: Boolean,
+ default: false
+ },
+ editUrl: {
+ type: String,
+ default: ''
+ },
+ createUrl: {
+ type: String,
+ default: ''
+ },
+ deleteUrl: {
+ type: String,
+ default: ''
+ },
+ showStructureAsNavigation: {
+ type: Boolean,
+ default: false
+ },
+ assignable: {
+ type: Boolean,
+ default: false
+ },
+ assignLeavesOnly: {
+ type: Boolean,
+ default: false
+ },
+ notAssignableNodes: {
+ type: Array,
+ default: () => []
+ }
+ },
+ data() {
+ return {
+ nodeId: this.startId,
+ startNode: null,
+ currentNode: this.startNode,
+ loaded: false,
+ isLoading: false,
+ showStructuralNavigation: false,
+ searchConfig: {},
+ isSearching: false
+ }
+ },
+ methods: {
+ changeCurrentNode(node) {
+ this.currentNode = node;
+ this.$nextTick(() => {
+ document.getElementById('tree-breadcrumb-' + node.attributes.id)?.focus();
+ });
+ },
+ exportUrl() {
+ return STUDIP.URLHelper.getURL('dispatch.php/tree/export_csv');
+ }
+ },
+ mounted() {
+ window.focus();
+
+ const loadingIndicator = axios.interceptors.request.use(config => {
+ setTimeout(() => {
+ if (!this.loaded) {
+ this.isLoading = true;
+ }
+ }, this.showProgressIndicatorTimeout);
+ return config;
+ });
+
+ this.getNode(this.startId).then(response => {
+ this.startNode = response.data.data;
+ this.currentNode = this.startNode;
+ this.loaded = true;
+ this.isLoading = false;
+ });
+
+ axios.interceptors.request.eject(loadingIndicator);
+
+ this.globalOn('do-search', searchterm => {
+ this.searchConfig.searchterm = searchterm;
+ this.searchConfig.semester = this.semester;
+ this.searchConfig.classname = this.startNode.attributes.classname;
+ this.isSearching = true;
+ });
+
+ this.globalOn('cancel-search', () => {
+ this.searchConfig = {};
+ this.isSearching = false;
+ });
+ }
+}
+</script>
diff --git a/resources/vue/components/tree/StudipTreeList.vue b/resources/vue/components/tree/StudipTreeList.vue
new file mode 100644
index 0000000..d773f43
--- /dev/null
+++ b/resources/vue/components/tree/StudipTreeList.vue
@@ -0,0 +1,359 @@
+<template>
+ <article class="studip-tree-list">
+ <header>
+ <tree-breadcrumb v-if="currentNode.id !== 'root'" :node="currentNode"
+ :edit-url="editUrl" :icon="breadcrumbIcon" :assignable="assignable"
+ :num-children="children.length" :num-courses="courses.length"
+ :show-navigation="showStructureAsNavigation"
+ :visible-children-only="visibleChildrenOnly"></tree-breadcrumb>
+ </header>
+ <studip-progress-indicator v-if="isLoading"></studip-progress-indicator>
+ <section v-else>
+ <h1>
+ {{ currentNode.attributes.name }}
+ <a v-if="isEditable && currentNode.attributes.id !== 'root'"
+ :href="editUrl + '/' + currentNode.attributes.id"
+ @click.prevent="editNode(editUrl, currentNode.id)" data-dialog="size=medium"
+ :title="$gettextInterpolate($gettext('%{name} bearbeiten'), {name: currentNode.attributes.name})">
+ <studip-icon shape="edit" :size="20"></studip-icon>
+ </a>
+ </h1>
+ <p v-if="currentNode.attributes.description?.trim() !== ''" class="studip-tree-node-info"
+ v-html="currentNode.attributes['description-formatted']">
+ </p>
+ </section>
+
+ <span aria-live="assertive" class="sr-only">{{ assistiveLive }}</span>
+
+ <nav v-if="withChildren && currentNode.attributes['has-children']" >
+ <h1>
+ {{ $gettext('Unterebenen') }}
+ </h1>
+ <draggable v-model="children" handle=".drag-handle" :animation="300" tag="ul"
+ class="studip-tree-children" @end="dropChild">
+ <li v-for="(child, index) in children" :key="index" class="studip-tree-child">
+ <a v-if="editable && children.length > 1" class="drag-link"
+ tabindex="0"
+ :title="$gettextInterpolate($gettext('Sortierelement für Element %{node}. Drücken Sie die Tasten Pfeil-nach-oben oder Pfeil-nach-unten, um dieses Element in der Liste zu verschieben.'), {node: child.attributes.name})"
+ @keydown="keyHandler($event, index)"
+ :ref="'draghandle-' + index">
+ <span class="drag-handle"></span>
+ </a>
+ <tree-node-tile :node="child" :semester="withCourses ? semester : 'all'" :sem-class="semClass"
+ :url="nodeUrl(child.id, semester !== 'all' ? semester : null)"></tree-node-tile>
+ </li>
+ </draggable>
+ </nav>
+ <section v-else-if="withChildren && !currentNode.attributes['has-children']" class="studip-tree-node-no-children">
+ {{ $gettext('Auf dieser Ebene existieren keine weiteren Unterebenen.') }}
+ </section>
+ <section v-if="withCourses && thisLevelCourses === 0" class="studip-tree-node-no-courses">
+ {{ $gettext('Auf dieser Ebene sind keine Veranstaltungen zugeordnet.')}}
+ </section>
+
+ <section v-if="thisLevelCourses + subLevelsCourses > 0">
+ <span v-if="withCourses && showingAllCourses">
+ <button type="button" @click="showAllCourses(false)"
+ :title="$gettext('Veranstaltungen auf dieser Ebene anzeigen')">
+ Veranstaltungen auf dieser Ebene anzeigen
+ </button>
+ </span>
+ <template v-if="thisLevelCourses > 0 && subLevelsCourses > 0">
+ |
+ </template>
+ <span v-if="withCourses && subLevelsCourses > 0 && !showingAllCourses">
+ <button type="button" @click="showAllCourses(true)"
+ :title="$gettext('Veranstaltungen auf allen Unterebenen anzeigen')">
+ Veranstaltungen auf allen Unterebenen anzeigen
+ </button>
+ </span>
+ </section>
+ <table v-if="courses.length > 0" class="default">
+ <caption>{{ $gettext('Veranstaltungen') }}</caption>
+ <colgroup>
+ <col>
+ <col>
+ </colgroup>
+ <thead>
+ <tr>
+ <th>{{ $gettext('Name') }}</th>
+ <th>{{ $gettext('Information') }}</th>
+ </tr>
+ </thead>
+ <tbody>
+ <tr v-for="(course) in courses" :key="course.id" class="studip-tree-child studip-tree-course">
+ <td>
+ <a :href="courseUrl(course.id)"
+ :title="$gettextInterpolate($gettext('Zur Veranstaltung %{ course }'),
+ { course: course.attributes.title })">
+ <studip-icon shape="seminar" :size="26"></studip-icon>
+ <template v-if="course.attributes['course-number']">
+ {{ course.attributes['course-number'] }}
+ </template>
+ {{ course.attributes.title }}
+ </a>
+ <div :id="'course-dates-' + course.id" class="course-dates"></div>
+ </td>
+ <td>
+ <tree-course-details :course="course.id"></tree-course-details>
+ </td>
+ </tr>
+ </tbody>
+ </table>
+ <MountingPortal v-if="withExport" mountTo="#export-widget" name="sidebar-export">
+ <tree-export-widget v-if="courses.length > 0"
+ :title="$gettext('Veranstaltungen exportieren')" :url="exportUrl()"
+ :export-data="courses"></tree-export-widget>
+ </MountingPortal>
+ <MountingPortal v-if="withCourseAssign" mountTo="#assign-widget" name="sidebar-assign-courses">
+ <assign-link-widget v-if="courses.length > 0" :node="currentNode" :courses="courses"></assign-link-widget>
+ </MountingPortal>
+ </article>
+</template>
+
+<script>
+import draggable from 'vuedraggable';
+import { TreeMixin } from '../../mixins/TreeMixin';
+import TreeExportWidget from './TreeExportWidget.vue';
+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";
+
+export default {
+ name: 'StudipTreeList',
+ components: {
+ draggable, StudipProgressIndicator, TreeExportWidget, TreeBreadcrumb, TreeNodeTile, TreeCourseDetails,
+ AssignLinkWidget
+ },
+ mixins: [ TreeMixin ],
+ props: {
+ node: {
+ type: Object,
+ required: true
+ },
+ breadcrumbIcon: {
+ type: String,
+ default: 'literature'
+ },
+ editable: {
+ type: Boolean,
+ default: false
+ },
+ editUrl: {
+ type: String,
+ default: ''
+ },
+ createUrl: {
+ type: String,
+ default: ''
+ },
+ deleteUrl: {
+ type: String,
+ default: ''
+ },
+ withCourses: {
+ type: Boolean,
+ default: false
+ },
+ withExport: {
+ type: Boolean,
+ default: false
+ },
+ withChildren: {
+ type: Boolean,
+ default: true
+ },
+ visibleChildrenOnly: {
+ type: Boolean,
+ default: true
+ },
+ assignable: {
+ type: Boolean,
+ default: false
+ },
+ withCourseAssign: {
+ type: Boolean,
+ default: false
+ },
+ semester: {
+ type: String,
+ default: ''
+ },
+ semClass: {
+ type: Number,
+ default: 0
+ },
+ showStructureAsNavigation: {
+ type: Boolean,
+ default: false
+ }
+ },
+ data() {
+ return {
+ currentNode: this.node,
+ isLoading: false,
+ isLoaded: false,
+ children: [],
+ courses: [],
+ assistiveLive: '',
+ subLevelsCourses: 0,
+ thisLevelCourses: 0,
+ showingAllCourses: false
+ }
+ },
+ methods: {
+ openNode(node, pushState = true) {
+ this.currentNode = node;
+ this.$emit('change-current-node', node);
+
+ if (this.withChildren) {
+ this.getNodeChildren(node, this.visibleChildrenOnly).then(response => {
+ this.children = response.data.data;
+ });
+ }
+
+ this.getNodeCourseInfo(node, this.semester, this.semClass)
+ .then(response => {
+ this.thisLevelCourses = response?.data.courses;
+ this.subLevelsCourses = response?.data.allCourses;
+ });
+
+ if (this.withCourses) {
+ this.getNodeCourses(node, this.semester, this.semClass, '', false)
+ .then(courses => {
+ this.courses = courses.data.data;
+ });
+ }
+
+ // Update browser history.
+ if (pushState) {
+ const nodeId = node.id;
+ const url = STUDIP.URLHelper.getURL('', {node_id: nodeId});
+ window.history.pushState({nodeId}, '', url);
+ }
+
+ // Update node_id for semester selector.
+ const semesterSelector = document.querySelector('#semester-selector-node-id');
+ semesterSelector.value = node.id;
+ },
+ dropChild() {
+ this.updateSorting(this.currentNode.id, this.children);
+ },
+ keyHandler(e, index) {
+ switch (e.keyCode) {
+ case 38: // up
+ e.preventDefault();
+ this.decreasePosition(index);
+ this.$nextTick(() => {
+ this.$refs['draghandle-' + (index - 1)][0].focus();
+ this.assistiveLive = this.$gettextInterpolate(
+ this.$gettext('Aktuelle Position in der Liste: %{pos} von %{listLength}.'),
+ { pos: index, listLength: this.children.length }
+ );
+ });
+ break;
+ case 40: // down
+ e.preventDefault();
+ this.increasePosition(index);
+ this.$nextTick(function () {
+ this.$refs['draghandle-' + (index + 1)][0].focus();
+ this.assistiveLive = this.$gettextInterpolate(
+ this.$gettext('Aktuelle Position in der Liste: %{pos} von %{listLength}.'),
+ { pos: index + 2, listLength: this.children.length }
+ );
+ });
+ break;
+ }
+ },
+ decreasePosition(index) {
+ if (index > 0) {
+ const temp = this.children[index - 1];
+ this.children[index - 1] = this.children[index];
+ this.children[index] = temp;
+ this.updateSorting(this.currentNode.id, this.children);
+ }
+ },
+ increasePosition(index) {
+ if (index < this.children.length) {
+ const temp = this.children[index + 1];
+ this.children[index + 1] = this.children[index];
+ this.children[index] = temp;
+ this.updateSorting(this.currentNode.id, this.children);
+ }
+ },
+ showAllCourses(state) {
+ this.getNodeCourses(this.currentNode, this.semester, this.semClass, '', state)
+ .then(courses => {
+ this.courses = courses.data.data;
+ this.showingAllCourses = state;
+ });
+ }
+ },
+ mounted() {
+ if (this.withChildren) {
+ this.getNodeChildren(this.currentNode, this.visibleChildrenOnly).then(response => {
+ this.children = response.data.data;
+ });
+ }
+
+ this.getNodeCourseInfo(this.currentNode, this.semester, this.semClass)
+ .then(response => {
+ this.thisLevelCourses = response?.data.courses;
+ this.subLevelsCourses = response?.data.allCourses;
+ });
+
+ if (this.withCourses) {
+ this.getNodeCourses(this.currentNode, this.semester, this.semClass)
+ .then(courses => {
+ this.courses = courses.data.data;
+ });
+ }
+
+ this.globalOn('open-tree-node', node => {
+ this.openNode(node);
+ });
+
+ this.globalOn('load-tree-node', id => {
+ this.getNode(id).then(response => {
+ this.openNode(response.data.data);
+ });
+ });
+
+ this.globalOn('sort-tree-children', data => {
+ if (this.currentNode.id === data.parent) {
+ this.children = data.children;
+ }
+ });
+
+ window.addEventListener('popstate', (event) => {
+ if (event.state) {
+ if ('nodeId' in event.state) {
+ this.getNode(event.state.nodeId).then(response => {
+ this.openNode(response.data.data, false);
+ });
+ }
+ } else {
+ this.openNode(this.node, false);
+ }
+ });
+
+ // Add current node to semester selector widget.
+ this.$nextTick(() => {
+ const semesterForm = document.querySelector('#semester-selector .sidebar-widget-content form');
+ const nodeField = document.createElement('input');
+ nodeField.id = 'semester-selector-node-id';
+ nodeField.type = 'hidden';
+ nodeField.name = 'node_id';
+ nodeField.value = this.node.id;
+ semesterForm.appendChild(nodeField);
+ });
+ },
+ beforeDestroy() {
+ STUDIP.eventBus.off('open-tree-node');
+ STUDIP.eventBus.off('load-tree-node');
+ STUDIP.eventBus.off('sort-tree-children');
+ }
+}
+</script>
diff --git a/resources/vue/components/tree/StudipTreeNode.vue b/resources/vue/components/tree/StudipTreeNode.vue
new file mode 100644
index 0000000..39f696c
--- /dev/null
+++ b/resources/vue/components/tree/StudipTreeNode.vue
@@ -0,0 +1,277 @@
+<template>
+ <section class="studip-tree-node">
+ <span :class="{ 'studip-tree-node-content': true, 'studip-tree-node-active': node?.id === activeNode?.id }">
+ <a @click.prevent="toggleNode(true)">
+ <div v-if="node.attributes['has-children']" class="studip-tree-node-toggle">
+ <studip-icon :shape="openState ? 'arr_1down': 'arr_1right'" :size="20"/>
+ </div>
+ </a>
+ <button v-if="isAssignable && node.attributes.id !== 'root'" class="studip-tree-node-assignment-state"
+ @click.prevent="changeAssignmentState()" :title="$gettext('Zuordnung ändern')">
+ <studip-icon :shape="assignmentState === 0
+ ? 'checkbox-unchecked'
+ : (assignmentState === 1 ? 'checkbox-checked' : 'checkbox-indeterminate')"></studip-icon>
+ </button>
+ <a @click.prevent="toggleNode(true)">
+ <div class="studip-tree-node-name">
+ {{ node.attributes.name }}
+ </div>
+ </a>
+ <studip-tooltip-icon v-if="withInfo && !isLoading && node.attributes.description?.trim() !== ''"
+ :text="node.attributes['description-formatted'].trim()"></studip-tooltip-icon>
+ <input v-if="isAssignable && node.attributes.id !== 'root'" type="hidden" :name="assignmentAction"
+ :value="node.attributes.id">
+ <a v-if="editable && node.attributes.id !== 'root'" :href="editUrl + '/' + node.attributes.id"
+ @click.prevent="editNode(editUrl, node.id)" data-dialog="size=medium"
+ class="studip-tree-node-edit-link">
+ <studip-icon shape="edit"></studip-icon>
+ </a>
+ </span>
+ <div v-if="isLoading" class="studip-spinner">
+ <studip-asset-img file="ajax-indicator-black.svg" width="20"/>
+ {{ $gettext('Daten werden geladen...' )}}
+ </div>
+ <ul v-if="node.attributes['has-children'] && openState" class="studip-tree-children">
+ <li v-for="(child) in children" :key="child.id" >
+ <studip-tree-node :node="child" :editable="editable" :edit-url="editUrl" :create-url="createUrl"
+ :delete-url="deleteUrl" :assignable="assignable" :ancestors="theAncestors"
+ :not-assignable-nodes="notAssignableNodes" :open-nodes="openNodes"
+ :open-levels="openLevels > 0 ? (openLevels - 1) : 0"
+ :visible-children-only="visibleChildrenOnly"
+ :active-node="activeNode" :with-info="withInfo"></studip-tree-node>
+ </li>
+ </ul>
+ </section>
+</template>
+
+<script>
+import { TreeMixin } from '../../mixins/TreeMixin';
+import StudipIcon from '../StudipIcon.vue';
+import StudipAssetImg from '../StudipAssetImg.vue';
+import axios from 'axios';
+import StudipTooltipIcon from '../StudipTooltipIcon.vue';
+
+export default {
+ name: 'StudipTreeNode',
+ components: { StudipTooltipIcon, StudipAssetImg, StudipIcon },
+ mixins: [ TreeMixin ],
+ props: {
+ node: {
+ type: Object,
+ required: true
+ },
+ activeNode: {
+ type: Object,
+ default: null
+ },
+ isOpen: {
+ type: Boolean,
+ default: false
+ },
+ breadcrumbIcon: {
+ type: String,
+ default: 'literature'
+ },
+ withInfo: {
+ type: Boolean,
+ default: true
+ },
+ editable: {
+ type: Boolean,
+ default: false
+ },
+ editUrl: {
+ type: String,
+ default: ''
+ },
+ createUrl: {
+ type: String,
+ default: ''
+ },
+ deleteUrl: {
+ type: String,
+ default: ''
+ },
+ visibleChildrenOnly: {
+ type: Boolean,
+ default: true
+ },
+ withCourses: {
+ type: Boolean,
+ default: true
+ },
+ assignable: {
+ type: Boolean,
+ default: false
+ },
+ assignLeavesOnly: {
+ type: Boolean,
+ default: false
+ },
+ notAssignableNodes: {
+ type: Array,
+ default: () => []
+ },
+ openLevels: {
+ type: Number,
+ default: 0
+ },
+ openNodes: {
+ type: Array,
+ default: () => []
+ },
+ ancestors: {
+ type: Array,
+ default: () => []
+ }
+ },
+ data() {
+ return {
+ isLoading: false,
+ childrenLoaded: false,
+ children: [],
+ semester: 'all',
+ openState: this.isOpen,
+ theAncestors: this.ancestors,
+ assignedCourses: 0,
+ assignmentState: 0,
+ assignmentAction: ''
+ }
+ },
+ methods: {
+ toggleNode(emitEvent = false) {
+ this.courses = [];
+ this.openState = !this.openState;
+ if (emitEvent) {
+ STUDIP.eventBus.emit('load-tree-node', this.node.id);
+ }
+ if (!this.childrenLoaded) {
+ this.children = [];
+ const loadingIndicator = axios.interceptors.request.use(config => {
+ setTimeout(() => {
+ if (!this.childrenLoaded) {
+ this.isLoading = true;
+ }
+ }, 500);
+ return config;
+ });
+ this.getNodeChildren(this.node, this.visibleChildrenOnly)
+ .then(response => {
+ this.isLoading = false;
+ this.children = response.data.data;
+ this.childrenLoaded = true;
+ });
+ axios.interceptors.request.eject(loadingIndicator);
+ }
+ },
+ /**
+ * Check whether currently selected course are assigned to this node.
+ */
+ checkAssignments() {
+ const courses = document.querySelectorAll('table.selected-courses input[name="courses[]"]') ?? [];
+ let ids = [];
+ for (const course of courses) {
+ ids.push(course.value);
+ }
+
+ if (ids.length > 0) {
+ this.getNodeCourses(this.node, 'all', 0, '', false, ids)
+ .then(response => {
+ // None of the given courses are assigned here.
+ if (response.data.data.length === 0) {
+ this.assignedCourses = this.assignmentState = 0;
+ // All of the given courses are assigned here.
+ } else if (response.data.data.length === ids.length) {
+ this.assignedCourses = this.assignmentState = 1;
+ // Some of the given courses are assigned here.
+ } else {
+ this.assignedCourses = this.assignmentState = -1;
+ }
+ });
+ }
+ },
+ /**
+ * Change what shall be done on submitting the form.
+ */
+ changeAssignmentState() {
+ // Current state is 0 -> remove all assignments.
+ if (this.assignmentState === 0) {
+ // Not all courses are assigned here -> next state is indeterminate.
+ if (this.assignedCourses === -1) {
+ this.assignmentState = -1;
+ // Next state is 1 -> add assignments here.
+ } else {
+ this.assignmentState = 1;
+ }
+ // Current state is 1 -> next state is 0 -> remove assignments here.
+ } else if (this.assignmentState === 1) {
+ this.assignmentState = 0;
+ // Current state is indeterminate -> next state is 1 -> add assignments here.
+ } else {
+ this.assignmentState = 1;
+ }
+
+ // Current state returned to original, nothing needs to be done.
+ if (this.assignmentState === this.assignedCourses) {
+ this.assignmentAction = '';
+ // Current state is different from original state -> add or remove.
+ } else {
+ switch (this.assignmentState) {
+ case 0:
+ this.assignmentAction = 'delete_assignments[]';
+ break;
+ case 1:
+ this.assignmentAction = 'add_assignments[]';
+ break;
+ }
+ }
+
+ }
+ },
+ computed: {
+ isAssignable() {
+ return this.assignable && !this.notAssignableNodes?.includes(this.node.id);
+ }
+ },
+ mounted() {
+ if (this.openLevels > 0) {
+ this.toggleNode();
+ }
+
+ if (this.ancestors.length === 0) {
+ for (const open of this.openNodes) {
+ this.getNode(open).then((response) => {
+ const haystack = response.data.data.attributes.ancestors?.map(element => {
+ return element.classname + '_' + element.id;
+ });
+ if (haystack) {
+ this.theAncestors = haystack;
+ if (this.theAncestors.includes(this.node.id)) {
+ this.toggleNode();
+ }
+ }
+ });
+
+ }
+ }
+
+ this.globalOn('sort-tree-children', data => {
+ if (this.node.id === data.parent) {
+ this.children = data.children;
+ }
+ });
+
+ this.$nextTick(() => {
+ if (this.theAncestors?.includes(this.node.id) && !this.openState) {
+ this.toggleNode();
+ }
+ if (this.isAssignable && this.node.attributes.id !== 'root') {
+ this.checkAssignments();
+ }
+ });
+ },
+ beforeDestroy() {
+ STUDIP.eventBus.off('sort-tree-children');
+ }
+}
+</script>
diff --git a/resources/vue/components/tree/StudipTreeTable.vue b/resources/vue/components/tree/StudipTreeTable.vue
new file mode 100644
index 0000000..0bfc244
--- /dev/null
+++ b/resources/vue/components/tree/StudipTreeTable.vue
@@ -0,0 +1,377 @@
+<template>
+ <div v-if="isLoading">
+ <studip-progress-indicator></studip-progress-indicator>
+ </div>
+ <article v-else class="studip-tree-table">
+ <header>
+ <tree-breadcrumb v-if="currentNode.id !== 'root'" :node="currentNode"
+ :icon="breadcrumbIcon" :editable="editable" :edit-url="editUrl" :create-url="createUrl"
+ :delete-url="deleteUrl" :show-navigation="showStructureAsNavigation"
+ :num-children="children.length" :num-courses="courses.length"
+ :assignable="assignable" :visible-children-only="visibleChildrenOnly"></tree-breadcrumb>
+ </header>
+ <section v-if="withChildren && !currentNode.attributes['has-children']" class="studip-tree-node-no-children">
+ {{ $gettext('Auf dieser Ebene existieren keine weiteren Unterebenen.')}}
+ </section>
+
+ <span aria-live="assertive" class="sr-only">{{ assistiveLive }}</span>
+
+ <div v-if="currentNode.attributes.description?.trim() !== ''"
+ v-html="currentNode.attributes['description-formatted']"></div>
+
+ <section v-if="thisLevelCourses === 0" class="studip-tree-node-no-courses">
+ {{ $gettext('Auf dieser Ebene sind keine Veranstaltungen zugeordnet.')}}
+ </section>
+
+ <section v-if="thisLevelCourses + subLevelsCourses > 0">
+ <span v-if="withCourses && showingAllCourses">
+ <button type="button" @click="showAllCourses(false)"
+ :title="$gettext('Veranstaltungen auf dieser Ebene anzeigen')">
+ {{ $gettext('Veranstaltungen auf dieser Ebene anzeigen') }}
+ </button>
+ </span>
+ <template v-if="thisLevelCourses > 0 && subLevelsCourses > 0">
+ |
+ </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') }}
+ </button>
+ </span>
+ </section>
+
+ <table v-if="currentNode.attributes['has-children'] || courses.length > 0" class="default">
+ <caption class="studip-tree-node-info">
+ <span v-if="withChildren && children.length > 0">
+ {{ $gettextInterpolate($gettext('%{ count } Unterebenen'), { count: children.length }) }}
+ </span>
+ <span v-if="withChildren && children.length > 0 && withCourses && courses.length > 0">
+ ,
+ </span>
+ </caption>
+ <colgroup>
+ <col style="width: 20px">
+ <col style="width: 30px">
+ <col>
+ <col style="width: 40%">
+ </colgroup>
+ <thead>
+ <tr>
+ <th></th>
+ <th>{{ $gettext('Typ') }}</th>
+ <th>{{ $gettext('Name') }}</th>
+ <th>{{ $gettext('Information') }}</th>
+ </tr>
+ </thead>
+ <draggable v-model="children" handle=".drag-handle" :animation="300"
+ @end="dropChild" tag="tbody" role="listbox">
+ <tr v-for="(child, index) in children" :key="index" class="studip-tree-child">
+ <td>
+ <a v-if="editable && children.length > 1" class="drag-link" role="option"
+ tabindex="0"
+ :title="$gettextInterpolate($gettext('Sortierelement für Element %{node}. Drücken Sie die Tasten Pfeil-nach-oben oder Pfeil-nach-unten, um dieses Element in der Liste zu verschieben.'), {node: child.attributes.name})"
+ @keydown="keyHandler($event, index)"
+ :ref="'draghandle-' + index">
+ <span class="drag-handle"></span>
+ </a>
+ </td>
+ <td>
+ <studip-icon :shape="child.attributes['has-children'] ? 'folder-full' : 'folder-empty'"
+ :size="26"></studip-icon>
+ </td>
+ <td>
+ <a :href="nodeUrl(child.id, semester !== 'all' ? semester : null)" tabindex="0"
+ @click.prevent="openNode(child)"
+ :title="$gettextInterpolate($gettext('Unterebene %{ node } öffnen'),
+ { node: node.attributes.name })">
+ {{ child.attributes.name }}
+ </a>
+ </td>
+ <td>
+ <tree-node-course-info :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">
+ <td></td>
+ <td>
+ <studip-icon shape="seminar" :size="26"></studip-icon>
+ </td>
+ <td>
+ <a :href="courseUrl(course.id)" tabindex="0"
+ :title="$gettextInterpolate($gettext('Zur Veranstaltung %{ course }'),
+ { course: course.attributes.title })">
+ <template v-if="course.attributes['course-number']">
+ {{ course.attributes['course-number'] }}
+ </template>
+ {{ course.attributes.title }}
+ </a>
+ <div :id="'course-dates-' + course.id" class="course-dates"></div>
+ </td>
+ <td :colspan="editable ? 2 : null">
+ <tree-course-details :course="course.id"></tree-course-details>
+ </td>
+ </tr>
+ </draggable>
+ </table>
+ <MountingPortal v-if="withExport" mountTo="#export-widget" name="sidebar-export">
+ <tree-export-widget v-if="courses.length > 0" :title="$gettext('Download des Ergebnisses')" :url="exportUrl()"
+ :export-data="courses"></tree-export-widget>
+ </MountingPortal>
+ <MountingPortal v-if="withCourseAssign" mountTo="#assign-widget" name="sidebar-assign-courses">
+ <assign-link-widget v-if="courses.length > 0" :node="currentNode" :courses="courses"></assign-link-widget>
+ </MountingPortal>
+ </article>
+</template>
+
+<script>
+import draggable from 'vuedraggable';
+import { TreeMixin } from '../../mixins/TreeMixin';
+import TreeExportWidget from './TreeExportWidget.vue';
+import TreeBreadcrumb from './TreeBreadcrumb.vue';
+import StudipProgressIndicator from '../StudipProgressIndicator.vue';
+import StudipIcon from '../StudipIcon.vue';
+import TreeNodeCourseInfo from './TreeNodeCourseInfo.vue';
+import TreeCourseDetails from "./TreeCourseDetails.vue";
+import AssignLinkWidget from "./AssignLinkWidget.vue";
+
+export default {
+ name: 'StudipTreeTable',
+ components: {
+ draggable, TreeExportWidget, TreeCourseDetails, StudipIcon, StudipProgressIndicator, TreeBreadcrumb,
+ TreeNodeCourseInfo, AssignLinkWidget
+ },
+ mixins: [ TreeMixin ],
+ props: {
+ node: {
+ type: Object,
+ required: true
+ },
+ breadcrumbIcon: {
+ type: String,
+ default: 'literature'
+ },
+ editable: {
+ type: Boolean,
+ default: false
+ },
+ editUrl: {
+ type: String,
+ default: ''
+ },
+ createUrl: {
+ type: String,
+ default: ''
+ },
+ deleteUrl: {
+ type: String,
+ default: ''
+ },
+ withCourses: {
+ type: Boolean,
+ default: false
+ },
+ withExport: {
+ type: Boolean,
+ default: false
+ },
+ withChildren: {
+ type: Boolean,
+ default: true
+ },
+ visibleChildrenOnly: {
+ type: Boolean,
+ default: true
+ },
+ assignable: {
+ type: Boolean,
+ default: false
+ },
+ withCourseAssign: {
+ type: Boolean,
+ default: false
+ },
+ semester: {
+ type: String,
+ default: ''
+ },
+ semClass: {
+ type: Number,
+ default: 0
+ },
+ showStructureAsNavigation: {
+ type: Boolean,
+ default: false
+ }
+ },
+ data() {
+ return {
+ currentNode: this.node,
+ isLoading: false,
+ isLoaded: false,
+ children: [],
+ courses: [],
+ assistiveLive: '',
+ subLevelsCourses: 0,
+ thisLevelCourses: 0,
+ showingAllCourses: false
+ }
+ },
+ methods: {
+ openNode(node, pushState = true) {
+ this.currentNode = node;
+ this.$emit('change-current-node', node);
+
+ if (this.withChildren) {
+ this.getNodeChildren(node, this.visibleChildrenOnly).then(response => {
+ this.children = response.data.data;
+ });
+ }
+
+ this.getNodeCourseInfo(node, this.semester, this.semClass)
+ .then(response => {
+ this.thisLevelCourses = response?.data.courses;
+ this.subLevelsCourses = response?.data.allCourses;
+ });
+
+ if (this.withCourses) {
+
+ this.getNodeCourses(node, this.semester, this.semClass, '', false)
+ .then(response => {
+ this.courses = response.data.data;
+ });
+ }
+
+ // Update browser history.
+ if (pushState) {
+ const nodeId = node.id;
+ const url = STUDIP.URLHelper.getURL('', {node_id: nodeId});
+ window.history.pushState({nodeId}, '', url);
+ }
+
+ // Update node_id for semester selector.
+ const semesterSelector = document.querySelector('#semester-selector-node-id');
+ semesterSelector.value = node.id;
+ },
+ dropChild() {
+ this.updateSorting(this.currentNode.id, this.children);
+ },
+ keyHandler(e, index) {
+ switch (e.keyCode) {
+ case 38: // up
+ e.preventDefault();
+ this.decreasePosition(index);
+ this.$nextTick(() => {
+ this.$refs['draghandle-' + (index - 1)][0].focus();
+ this.assistiveLive = this.$gettextInterpolate(
+ this.$gettext('Aktuelle Position in der Liste: %{pos} von %{listLength}.'),
+ { pos: index, listLength: this.children.length }
+ );
+ });
+ break;
+ case 40: // down
+ e.preventDefault();
+ this.increasePosition(index);
+ this.$nextTick(function () {
+ this.$refs['draghandle-' + (index + 1)][0].focus();
+ this.assistiveLive = this.$gettextInterpolate(
+ this.$gettext('Aktuelle Position in der Liste: %{pos} von %{listLength}.'),
+ { pos: index + 2, listLength: this.children.length }
+ );
+ });
+ break;
+ }
+ },
+ decreasePosition(index) {
+ if (index > 0) {
+ const temp = this.children[index - 1];
+ this.children[index - 1] = this.children[index];
+ this.children[index] = temp;
+ this.updateSorting(this.currentNode.id, this.children);
+ }
+ },
+ increasePosition(index) {
+ if (index < this.children.length) {
+ const temp = this.children[index + 1];
+ this.children[index + 1] = this.children[index];
+ this.children[index] = temp;
+ this.updateSorting(this.currentNode.id, this.children);
+ }
+ },
+ showAllCourses(state) {
+ this.getNodeCourses(this.currentNode, this.semester, this.semClass, '', state)
+ .then(courses => {
+ this.courses = courses.data.data;
+ this.showingAllCourses = state;
+ });
+ }
+ },
+ mounted() {
+ if (this.withChildren) {
+ this.getNodeChildren(this.node, this.visibleChildrenOnly).then(response => {
+ this.children = response.data.data;
+ });
+ }
+
+ this.getNodeCourseInfo(this.currentNode, this.semester, this.semClass)
+ .then(response => {
+ this.thisLevelCourses = response?.data.courses;
+ this.subLevelsCourses = response?.data.allCourses;
+ });
+
+ if (this.withCourses) {
+ this.getNodeCourses(this.currentNode, this.semester, this.semClass)
+ .then(courses => {
+ this.courses = courses.data.data;
+ });
+ }
+
+ this.globalOn('open-tree-node', node => {
+ STUDIP.eventBus.emit('cancel-search');
+ this.openNode(node);
+ });
+
+ this.globalOn('load-tree-node', id => {
+ STUDIP.eventBus.emit('cancel-search');
+ this.getNode(id).then(response => {
+ this.openNode(response.data.data);
+ });
+ });
+
+ this.globalOn('sort-tree-children', data => {
+ if (this.currentNode.id === data.parent) {
+ this.children = data.children;
+ }
+ });
+
+ window.addEventListener('popstate', (event) => {
+ if (event.state) {
+ if ('nodeId' in event.state) {
+ this.getNode(event.state.nodeId).then(response => {
+ this.openNode(response.data.data, false);
+ });
+ }
+ } else {
+ this.openNode(this.node, false);
+ }
+ });
+
+ // Add current node to semester selector widget.
+ this.$nextTick(() => {
+ const semesterForm = document.querySelector('#semester-selector .sidebar-widget-content form');
+ const nodeField = document.createElement('input');
+ nodeField.id = 'semester-selector-node-id';
+ nodeField.type = 'hidden';
+ nodeField.name = 'node_id';
+ nodeField.value = this.node.id;
+ semesterForm.appendChild(nodeField);
+ });
+ },
+ beforeDestroy() {
+ STUDIP.eventBus.off('open-tree-node');
+ STUDIP.eventBus.off('load-tree-node');
+ STUDIP.eventBus.off('sort-tree-children');
+ }
+}
+</script>
diff --git a/resources/vue/components/tree/TreeBreadcrumb.vue b/resources/vue/components/tree/TreeBreadcrumb.vue
new file mode 100644
index 0000000..33b04b3
--- /dev/null
+++ b/resources/vue/components/tree/TreeBreadcrumb.vue
@@ -0,0 +1,194 @@
+<template>
+ <div class="studip-tree-breadcrumb contentbar">
+ <nav class="contentbar-nav"></nav>
+ <div :class="{'contentbar-wrapper-left': true, 'with-navigation': showNavigation, 'editable': editable,
+ 'with-navigation-and-editable': showNavigation && editable}">
+ <studip-icon :shape="icon" :size="24"></studip-icon>
+ <nav v-if="node.attributes.ancestors" class="studip-tree-breadcrumb-list contentbar-nav">
+ <span v-for="(ancestor, index) in node.attributes.ancestors"
+ :key="ancestor.id">
+ <a :href="nodeUrl(ancestor.classname + '_' + ancestor.id)" :ref="ancestor.id"
+ @click.prevent="openNode(ancestor.id, ancestor.classname)" tabindex="0"
+ :id="'tree-breadcrumb-' + ancestor.id"
+ :title="$gettextInterpolate($gettext('%{ node } öffnen'), { node: ancestor.name})">
+ {{ ancestor.name }}
+ </a>
+ <template v-if="index !== node.attributes.ancestors.length - 1">
+ /
+ </template>
+ </span>
+ </nav>
+ </div>
+ <div class="contentbar-wrapper-right">
+ <div v-if="showNavigation" class="studip-tree-navigation-wrapper">
+ <button type="button" tabindex="0"
+ :title="navigationOpen ? $gettext('Navigation schließen') : $gettext('Navigation öffnen')"
+ @click.prevent="toggleNavigation" :aria-expanded="navigationOpen">
+ <studip-icon shape="table-of-contents" :size="24"></studip-icon>
+ </button>
+ <article class="studip-tree-navigation" v-if="navigationOpen">
+ <header>
+ <h1>{{ $gettext('Inhalt') }}</h1>
+ <button type="button" tabindex="0"
+ @click.prevent="toggleNavigation">
+ <studip-icon shape="decline" :size="24"></studip-icon>
+ </button>
+ </header>
+ <studip-tree-node :with-info="false" :node="rootNode" :active-node="node" :open-nodes="[ node.id ]"
+ :visible-children-only="visibleChildrenOnly"></studip-tree-node>
+ </article>
+ </div>
+ <button v-if="assignable" type="submit" class="assign-button"
+ :title="$gettext('Diesen Eintrag zuweisen')">
+ <studip-icon shape="arr_2right" :size="20"></studip-icon>
+ </button>
+ <studip-action-menu v-if="editable" :items="actionMenuItems()"
+ @add-tree-node="addNode" @edit-tree-node="editNode" @delete-tree-node="deleteNode"/>
+ </div>
+ </div>
+</template>
+
+<script>
+import { TreeMixin } from '../../mixins/TreeMixin';
+import StudipIcon from '../StudipIcon.vue';
+import StudipTreeNode from './StudipTreeNode.vue';
+import axios from 'axios';
+
+export default {
+ name: 'TreeBreadcrumb',
+ components: { StudipIcon, StudipTreeNode },
+ mixins: [ TreeMixin ],
+ props: {
+ node: {
+ type: Object,
+ required: true
+ },
+ icon: {
+ type: String,
+ required: true
+ },
+ editable: {
+ type: Boolean,
+ default: false
+ },
+ editUrl: {
+ type: String,
+ default: ''
+ },
+ createUrl: {
+ type: String,
+ default: ''
+ },
+ deleteUrl: {
+ type: String,
+ default: ''
+ },
+ showNavigation: {
+ type: Boolean,
+ default: false
+ },
+ assignable: {
+ type: Boolean,
+ default: false
+ },
+ numChildren: {
+ type: Number,
+ default: 0
+ },
+ numCourses: {
+ type: Number,
+ default: 0
+ },
+ visibleChildrenOnly: {
+ type: Boolean,
+ default: true
+ }
+ },
+ data() {
+ return {
+ navigationOpen: false,
+ rootNode: null
+ }
+ },
+ methods: {
+ openNode(id, classname) {
+ STUDIP.eventBus.emit('load-tree-node', classname + '_' + id);
+ this.$refs[id][0].focus();
+ },
+ actionMenuItems() {
+ let entries = [];
+
+ if (this.editable && this.createUrl !== '') {
+ entries.push({
+ id: 'create',
+ label: this.$gettext('Neues Unterelement anlegen'),
+ icon: 'add',
+ emit: 'add-tree-node',
+ emitArguments: this.node
+ });
+ }
+
+ if (this.editable && this.node.attributes.id !== 'root') {
+ entries.push({
+ id: 'edit',
+ label: this.$gettext('Dieses Element bearbeiten'),
+ icon: 'edit',
+ emit: 'edit-tree-node',
+ emitArguments: this.node
+ });
+ entries.push({
+ id: 'delete',
+ label: this.$gettext('Dieses Element löschen'),
+ icon: 'trash',
+ emit: 'delete-tree-node',
+ emitArguments: this.node
+ });
+ }
+
+ return entries;
+ },
+ toggleNavigation() {
+ this.navigationOpen = !this.navigationOpen;
+ },
+ addNode(parent) {
+ STUDIP.Dialog.fromURL(this.createUrl + '/' + parent.id, { data: { from: this.nodeUrl(parent.id) }});
+ },
+ editNode(node) {
+ STUDIP.Dialog.fromURL(this.editUrl + '/' + node.id, { data: { from: this.nodeUrl(node.id) }});
+ },
+ deleteNode(node) {
+ let text = this.$gettext('Sind sie sicher, dass der Eintrag "%{ node }" gelöscht werden soll?');
+ let context = {
+ node: node.attributes.name
+ };
+
+ if (this.numChildren > 0 && this.numCourses === 0) {
+ text = this.$gettext('Sind sie sicher, dass der Eintrag "%{ node }" gelöscht werden soll? Er hat %{ children } Unterelemente.');
+ context.children = this.numChildren;
+ } else if (this.numChildren === 0 && this.numCourses > 0) {
+ text = this.$gettext('Sind sie sicher, dass der Eintrag "%{ node }" gelöscht werden soll? Er hat %{ courses } Veranstaltungszuordnungen.');
+ context.courses = this.numCourses;
+ } else if (this.numChildren > 0 && this.numCourses > 0) {
+ text = this.$gettext('Sind sie sicher, dass der Eintrag "%{ node }" gelöscht werden soll? Er hat %{ children } Unterelemente und %{ courses } Veranstaltungszuordnungen.');
+ context.children = this.numChildren;
+ context.courses = this.numCourses;
+ }
+
+ STUDIP.Dialog.confirm(
+ this.$gettextInterpolate(text, context)
+ ).done(() => {
+ axios.post(this.deleteUrl + '/' + node.id).then(() => {
+ const parent = node.attributes.ancestors[node.attributes.ancestors.length - 2];
+ window.location = this.nodeUrl(parent.classname + '_' + parent.id);
+ });
+ });
+ }
+ },
+ mounted() {
+ const root = this.node.attributes.ancestors[0];
+ this.getNode(root.classname + '_' + root.id).then(response => {
+ this.rootNode = response.data.data;
+ });
+ }
+}
+</script>
diff --git a/resources/vue/components/tree/TreeCourseDetails.vue b/resources/vue/components/tree/TreeCourseDetails.vue
new file mode 100644
index 0000000..30f101b
--- /dev/null
+++ b/resources/vue/components/tree/TreeCourseDetails.vue
@@ -0,0 +1,51 @@
+<template>
+ <div v-if="details" class="course-details">
+ <div class="semester">
+ ({{ details.semester }})
+ </div>
+ <div class="admission-state" v-if="details.admissionstate">
+ <studip-icon :shape="details.admissionstate.icon" :role="details.admissionstate.role"
+ :title="details.admissionstate.info"></studip-icon>
+ </div>
+ <ul class="course-lecturers">
+ <li v-for="(lecturer, index) in details.lecturers" :key="index">
+ <a :href="profileUrl(lecturer.username)"
+ :title="$gettextInterpolate($gettext('Zum Profil von %{ user }'),
+ { user: lecturer.name })">
+ {{ lecturer.name }}
+ </a>
+ </li>
+ </ul>
+ <MountingPortal :mountTo="'#course-dates-' + course" :append="true">
+ <span v-html="details.dates"></span>
+ </MountingPortal>
+ </div>
+</template>
+
+<script>
+import axios from 'axios';
+import { TreeMixin } from '../../mixins/TreeMixin';
+
+export default {
+ name: 'TreeCourseDetails',
+ mixins: [ TreeMixin ],
+ props: {
+ course: {
+ type: String,
+ required: true
+ }
+ },
+ data() {
+ return {
+ details: null
+ }
+ },
+ mounted() {
+ axios.get(
+ STUDIP.URLHelper.getURL('jsonapi.php/v1/tree-node/course/details/' + this.course)
+ ).then(response => {
+ this.details = response.data;
+ });
+ }
+}
+</script>
diff --git a/resources/vue/components/tree/TreeExportWidget.vue b/resources/vue/components/tree/TreeExportWidget.vue
new file mode 100644
index 0000000..62b73b0
--- /dev/null
+++ b/resources/vue/components/tree/TreeExportWidget.vue
@@ -0,0 +1,50 @@
+<template>
+ <sidebar-widget v-if="exportData.length > 0" id="export-widget" class="sidebar-export" :title="$gettext('Export')">
+ <template #content>
+ <form class="sidebar-export">
+ <studip-icon shape="export" :size="16"></studip-icon>
+ <a :href="url" :title="title" @click.prevent="createExport()">{{ title }}</a>
+ </form>
+ </template>
+ </sidebar-widget>
+</template>
+
+<script>
+import axios from 'axios';
+import SidebarWidget from '../SidebarWidget.vue';
+import StudipIcon from '../StudipIcon.vue';
+
+export default {
+ name: 'TreeExportWidget',
+ components: {
+ SidebarWidget, StudipIcon
+ },
+ props: {
+ url: {
+ type: String,
+ required: true
+ },
+ title: {
+ type: String,
+ required: true
+ },
+ exportData: {
+ type: Array,
+ default: () => []
+ }
+ },
+ methods: {
+ createExport() {
+ const fd = new FormData();
+ fd.append('courses', this.exportData.map(entry => entry.id));
+ axios.post(
+ this.url,
+ fd,
+ { headers: { 'Content-Type': 'multipart/form-data' }}
+ ).then(response => {
+ window.open(response.data);
+ });
+ }
+ }
+}
+</script>
diff --git a/resources/vue/components/tree/TreeNodeCourseInfo.vue b/resources/vue/components/tree/TreeNodeCourseInfo.vue
new file mode 100644
index 0000000..db31d14
--- /dev/null
+++ b/resources/vue/components/tree/TreeNodeCourseInfo.vue
@@ -0,0 +1,76 @@
+<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>
+ <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.
+ </div>
+ </div>
+</template>
+
+<script>
+import { TreeMixin } from '../../mixins/TreeMixin';
+
+export default {
+ name: 'TreeNodeCourseInfo',
+ mixins: [ TreeMixin ],
+ props: {
+ node: {
+ type: Object,
+ required: true
+ },
+ semester: {
+ type: String,
+ default: 'all'
+ },
+ semClass: {
+ type: Number,
+ default: 0
+ }
+ },
+ data() {
+ return {
+ courseCount: 0,
+ allCourseCount: 0,
+ showingAllCourses: false
+ }
+ },
+ methods: {
+ showAllCourses(state) {
+ this.showingAllCourses = state;
+ this.$emit('showAllCourses', state);
+ }
+ },
+ mounted() {
+ this.getNodeCourseInfo(this.node, this.semester, this.semClass)
+ .then(info => {
+ this.courseCount = info?.data.courses;
+ this.allCourseCount = info?.data.allCourses;
+ });
+ },
+ watch: {
+ node(newNode) {
+ this.getNodeCourseInfo(newNode, this.semester, this.semClass)
+ .then(info => {
+ this.courseCount = info?.data.courses;
+ this.allCourseCount = info?.data.allCourses;
+ });
+ }
+ }
+}
+</script>
diff --git a/resources/vue/components/tree/TreeNodeCoursePath.vue b/resources/vue/components/tree/TreeNodeCoursePath.vue
new file mode 100644
index 0000000..26ab88e
--- /dev/null
+++ b/resources/vue/components/tree/TreeNodeCoursePath.vue
@@ -0,0 +1,54 @@
+<template>
+ <div>
+ <studip-icon shape="info-circle" @click="togglePathInfo"></studip-icon>
+ <ul v-if="showPaths" class="studip-tree-course-path">
+ <li v-for="(path, pindex) in paths" :key="pindex">
+ <button @click.prevent="openNode(path[path.length - 1].id)">
+ <template v-for="(segment) in path">
+ / {{ segment.name }}
+ </template>
+ </button>
+ </li>
+ </ul>
+ </div>
+</template>
+<script>
+import axios from 'axios';
+import StudipIcon from '../StudipIcon.vue';
+
+export default {
+ name: 'TreeNodeCoursePath',
+ components: { StudipIcon },
+ props: {
+ courseId: {
+ type: String,
+ required: true
+ },
+ nodeClass: {
+ type: String,
+ required: true
+ }
+ },
+ data() {
+ return {
+ paths: [],
+ showPaths: false
+ }
+ },
+ methods: {
+ openNode(id) {
+ STUDIP.eventBus.emit('load-tree-node', this.nodeClass + '_' + id);
+ },
+ togglePathInfo() {
+ this.showPaths = !this.showPaths;
+ }
+ },
+ mounted() {
+ axios.get(
+ STUDIP.URLHelper.getURL('jsonapi.php/v1/tree-node/course/pathinfo/' + this.nodeClass + '/' + this.courseId)
+ ).then(response => {
+ this.paths = response.data;
+ });
+ }
+}
+</script>
diff --git a/resources/vue/components/tree/TreeNodeTile.vue b/resources/vue/components/tree/TreeNodeTile.vue
new file mode 100644
index 0000000..cbb0679
--- /dev/null
+++ b/resources/vue/components/tree/TreeNodeTile.vue
@@ -0,0 +1,46 @@
+<template>
+ <a :href="url" @click.prevent="openNode" :title="$gettextInterpolate($gettext('Unterebene %{ node } öffnen'),
+ { node: node.attributes.name })">
+ <p class="studip-tree-child-title">
+ {{ node.attributes.name }}
+ </p>
+
+ <tree-node-course-info :node="node" :semester="semester"
+ :sem-class="semClass"></tree-node-course-info>
+ </a>
+</template>
+
+<script>
+import TreeNodeCourseInfo from './TreeNodeCourseInfo.vue';
+export default {
+ name: 'TreeNodeTile',
+ components: { TreeNodeCourseInfo },
+ props: {
+ node: {
+ type: Object,
+ required: true
+ },
+ url: {
+ type: String,
+ required: true
+ },
+ withChildren: {
+ type: Boolean,
+ default: true
+ },
+ semester: {
+ type: String,
+ default: 'all'
+ },
+ semClass: {
+ type: Number,
+ default: 0
+ }
+ },
+ methods: {
+ openNode() {
+ STUDIP.eventBus.emit('open-tree-node', this.node);
+ }
+ }
+}
+</script>
diff --git a/resources/vue/components/tree/TreeSearchResult.vue b/resources/vue/components/tree/TreeSearchResult.vue
new file mode 100644
index 0000000..19bc6b9
--- /dev/null
+++ b/resources/vue/components/tree/TreeSearchResult.vue
@@ -0,0 +1,85 @@
+<template>
+ <div v-if="isLoading">
+ <studip-progress-indicator></studip-progress-indicator>
+ </div>
+ <article v-else class="studip-tree-table">
+ <table v-if="courses.length > 0" class="default studip-tree-table">
+ <caption>
+ <studip-icon shape="search" :size="20"></studip-icon>
+ {{ $gettextInterpolate($ngettext('Ein Eintrag für den Begriff "%{searchterm}" gefunden',
+ '%{count} Einträge für den Begriff "%{searchterm}" gefunden', courses.length),
+ { count: courses.length, searchterm: searchConfig.searchterm}) }}
+ </caption>
+ <colgroup>
+ <col style="width: 30px">
+ <col>
+ <col>
+ </colgroup>
+ <thead>
+ <tr>
+ <th></th>
+ <th>{{ $gettext('Name') }}</th>
+ <th>{{ $gettext('Information') }}</th>
+ </tr>
+ </thead>
+ <tbody>
+ <tr v-for="(course) in courses" :key="course.id" class="studip-tree-child studip-tree-course">
+ <td>
+ <studip-icon shape="seminar" :size="26"></studip-icon>
+ </td>
+ <td>
+ <a :href="courseUrl(course.id)"
+ :title="$gettextInterpolate($gettext('Zur Veranstaltung %{name}'), {name: + course.attributes.title})">
+ <template v-if="course.attributes['course-number']">
+ {{ course.attributes['course-number'] }}
+ </template>
+ {{ course.attributes.title }}
+ <div :id="'course-dates-' + course.id" class="course-dates"></div>
+ </a>
+ <tree-node-course-path :node-class="searchConfig.classname"
+ :course-id="course.id"></tree-node-course-path>
+ </td>
+ <td>
+ <tree-course-details :course="course.id"></tree-course-details>
+ </td>
+ </tr>
+ </tbody>
+ </table>
+ </article>
+</template>
+
+<script>
+import { TreeMixin } from '../../mixins/TreeMixin';
+import StudipProgressIndicator from '../StudipProgressIndicator.vue';
+import StudipIcon from '../StudipIcon.vue';
+import TreeNodeCoursePath from './TreeNodeCoursePath.vue';
+import TreeCourseDetails from './TreeCourseDetails.vue';
+
+export default {
+ name: 'TreeSearchResult',
+ components: { StudipIcon, StudipProgressIndicator, TreeNodeCoursePath, TreeCourseDetails },
+ mixins: [ TreeMixin ],
+ props: {
+ searchConfig: {
+ type: Object,
+ required: true
+ }
+ },
+ data() {
+ return {
+ node: null,
+ isLoading: false,
+ isLoaded: false,
+ courses: []
+ }
+ },
+ mounted() {
+ this.getNode(this.searchConfig.classname + '_root').then(response => {
+ this.getNodeCourses(response.data.data, this.searchConfig.semester,0, this.searchConfig.searchterm, true)
+ .then(courses => {
+ this.courses = courses.data.data;
+ });
+ });
+ }
+}
+</script>
diff --git a/resources/vue/mixins/TreeMixin.js b/resources/vue/mixins/TreeMixin.js
new file mode 100644
index 0000000..9a0292e
--- /dev/null
+++ b/resources/vue/mixins/TreeMixin.js
@@ -0,0 +1,108 @@
+import axios from 'axios';
+
+export const TreeMixin = {
+ data() {
+ return {
+ showProgressIndicatorTimeout: 500
+ };
+ },
+ methods: {
+ async getNode(id) {
+ return axios.get(STUDIP.URLHelper.getURL('jsonapi.php/v1/tree-node/' + id));
+ },
+ async getNodeChildren(node, visibleOnly = true) {
+ let parameters = {};
+
+ if (visibleOnly) {
+ parameters['filter[visible]'] = true;
+ }
+
+ return axios.get(
+ STUDIP.URLHelper.getURL('jsonapi.php/v1/tree-node/' + node.id + '/children'),
+ { params: parameters }
+ );
+ },
+ async getNodeCourses(node, semesterId = 'all', semClass = 0, searchterm = '', recursive = false, ids = []) {
+ let parameters = {};
+
+ if (semesterId !== 'all' && semesterId !== '0') {
+ parameters['filter[semester]'] = semesterId;
+ }
+
+ if (searchterm !== '') {
+ parameters['filter[q]'] = searchterm;
+ }
+
+ if (semClass !== 0) {
+ parameters['filter[semclass]'] = semClass;
+ }
+
+ if (recursive) {
+ parameters['filter[recursive]'] = true;
+ }
+
+ if (ids.length > 0) {
+ parameters['filter[ids]'] = ids;
+ }
+
+ return axios.get(
+ STUDIP.URLHelper.getURL('jsonapi.php/v1/tree-node/' + node.id + '/courses'),
+ {params: parameters}
+ );
+ },
+ async getNodeCourseInfo(node, semesterId, semClass = 0) {
+ let parameters = {};
+
+ if (semesterId !== 'all' && semesterId !== '0') {
+ parameters['filter[semester]'] = semesterId;
+ }
+
+ if (semClass !== 0) {
+ parameters['filter[semclass]'] = semClass;
+ }
+
+ return axios.get(
+ STUDIP.URLHelper.getURL('jsonapi.php/v1/tree-node/' + node.id + '/courseinfo'),
+ { params: parameters }
+ );
+ },
+ nodeUrl(node_id, semester = null ) {
+ return STUDIP.URLHelper.getURL('', { node_id, semester })
+ },
+ courseUrl(courseId) {
+ return STUDIP.URLHelper.getURL('dispatch.php/course/details', { cid: courseId })
+ },
+ profileUrl(username) {
+ return STUDIP.URLHelper.getURL('dispatch.php/profile', { username })
+ },
+ exportUrl() {
+ return STUDIP.URLHelper.getURL('dispatch.php/tree/export_csv');
+ },
+ editNode(editUrl, id) {
+ STUDIP.Dialog.fromURL(
+ editUrl + '/' + id,
+ {
+ size: 'medium'
+ }
+ );
+ },
+ updateSorting(parentId, children) {
+ let data = {};
+
+ let position = 0;
+ for (const child of children) {
+ data[child.attributes.id] = position;
+ position++;
+ }
+
+ const fd = new FormData();
+ fd.append('sorting', JSON.stringify(data));
+ axios.post(
+ STUDIP.URLHelper.getURL('dispatch.php/admin/tree/sort/' + parentId),
+ fd,
+ { headers: { 'Content-Type': 'multipart/form-data' }}
+ );
+ STUDIP.Vue.emit('sort-tree-children', { parent: parentId, children: children });
+ }
+ }
+}