From 559ab723fabd4d10f26e7df631808e4cb8d91c9b Mon Sep 17 00:00:00 2001 From: Thomas Hackl <hackl@data-quest.de> Date: Wed, 28 Jun 2023 13:27:46 +0000 Subject: [PATCH] Resolve "Neuentwicklung Verzeichnisstrukturen" Closes #1664, #2693, and #2692 Merge request studip/studip!1081 --- app/controllers/admin/courses.php | 8 + app/controllers/admin/tree.php | 292 +++++++++++++ app/controllers/search/courses.php | 143 +++---- app/controllers/studyarea.php | 51 +++ app/controllers/tree.php | 55 +++ .../admin/courses/batch_assign_semtree.php | 8 + app/views/admin/courses/courses.php | 3 +- app/views/admin/tree/assign_courses.php | 10 + app/views/admin/tree/batch_assign_semtree.php | 43 ++ app/views/admin/tree/create.php | 49 +++ app/views/admin/tree/edit.php | 54 +++ app/views/admin/tree/rangetree.php | 9 + app/views/admin/tree/semtree.php | 10 + app/views/search/courses/index.php | 14 +- app/views/studyarea/edit.php | 1 + db/migrations/5.4.6_tree_changes.php | 66 +++ lib/classes/JsonApi/RouteMap.php | 12 + .../RangeTree/ChildrenOfRangeTreeNode.php | 39 ++ .../RangeTree/CoursesOfRangeTreeNode.php | 51 +++ .../RangeTree/InstituteOfRangeTreeNode.php | 30 ++ .../RangeTree/ParentOfRangeTreeNode.php | 32 ++ .../Routes/RangeTree/RangeTreeIndex.php | 53 +++ .../Routes/RangeTree/RangeTreeShow.php | 32 ++ .../Routes/StudyAreas/StudyAreasShow.php | 3 +- .../Routes/Tree/ChildrenOfTreeNode.php | 50 +++ .../Routes/Tree/CourseInfoOfTreeNode.php | 83 ++++ .../JsonApi/Routes/Tree/CoursesOfTreeNode.php | 112 +++++ .../Routes/Tree/DetailsOfTreeNodeCourse.php | 79 ++++ .../Routes/Tree/PathinfoOfTreeNodeCourse.php | 34 ++ lib/classes/JsonApi/Routes/Tree/TreeShow.php | 40 ++ lib/classes/JsonApi/SchemaMap.php | 2 +- lib/classes/JsonApi/Schemas/StudyArea.php | 4 +- lib/classes/JsonApi/Schemas/TreeNode.php | 151 +++++++ lib/classes/SemBrowse.class.php | 10 +- lib/classes/StudipTreeNode.php | 114 ++++++ lib/classes/forms/QuicksearchInput.php | 2 +- lib/classes/searchtypes/TreeSearch.class.php | 96 +++++ lib/models/RangeTreeNode.php | 263 ++++++++++++ lib/models/StudipStudyArea.class.php | 218 ++++++++-- lib/navigation/AdminNavigation.php | 4 +- public/admin_range_tree.php | 59 --- public/admin_sem_tree.php | 310 -------------- .../assets/javascripts/bootstrap/treeview.js | 12 + resources/assets/javascripts/entry-base.js | 1 + .../assets/stylesheets/scss/sidebar.scss | 2 +- resources/assets/stylesheets/scss/tree.scss | 384 ++++++++++++++++++ resources/assets/stylesheets/studip.scss | 1 + resources/vue/components/SearchWidget.vue | 56 +++ .../components/StudipProgressIndicator.vue | 2 +- .../vue/components/tree/AssignLinkWidget.vue | 46 +++ resources/vue/components/tree/StudipTree.vue | 214 ++++++++++ .../vue/components/tree/StudipTreeList.vue | 359 ++++++++++++++++ .../vue/components/tree/StudipTreeNode.vue | 277 +++++++++++++ .../vue/components/tree/StudipTreeTable.vue | 377 +++++++++++++++++ .../vue/components/tree/TreeBreadcrumb.vue | 194 +++++++++ .../vue/components/tree/TreeCourseDetails.vue | 51 +++ .../vue/components/tree/TreeExportWidget.vue | 50 +++ .../components/tree/TreeNodeCourseInfo.vue | 76 ++++ .../components/tree/TreeNodeCoursePath.vue | 54 +++ .../vue/components/tree/TreeNodeTile.vue | 46 +++ .../vue/components/tree/TreeSearchResult.vue | 85 ++++ resources/vue/mixins/TreeMixin.js | 108 +++++ 62 files changed, 4581 insertions(+), 513 deletions(-) create mode 100644 app/controllers/admin/tree.php create mode 100644 app/controllers/studyarea.php create mode 100644 app/controllers/tree.php create mode 100644 app/views/admin/courses/batch_assign_semtree.php create mode 100644 app/views/admin/tree/assign_courses.php create mode 100644 app/views/admin/tree/batch_assign_semtree.php create mode 100644 app/views/admin/tree/create.php create mode 100644 app/views/admin/tree/edit.php create mode 100644 app/views/admin/tree/rangetree.php create mode 100644 app/views/admin/tree/semtree.php create mode 100644 app/views/studyarea/edit.php create mode 100644 db/migrations/5.4.6_tree_changes.php create mode 100644 lib/classes/JsonApi/Routes/RangeTree/ChildrenOfRangeTreeNode.php create mode 100644 lib/classes/JsonApi/Routes/RangeTree/CoursesOfRangeTreeNode.php create mode 100644 lib/classes/JsonApi/Routes/RangeTree/InstituteOfRangeTreeNode.php create mode 100644 lib/classes/JsonApi/Routes/RangeTree/ParentOfRangeTreeNode.php create mode 100644 lib/classes/JsonApi/Routes/RangeTree/RangeTreeIndex.php create mode 100644 lib/classes/JsonApi/Routes/RangeTree/RangeTreeShow.php create mode 100644 lib/classes/JsonApi/Routes/Tree/ChildrenOfTreeNode.php create mode 100644 lib/classes/JsonApi/Routes/Tree/CourseInfoOfTreeNode.php create mode 100644 lib/classes/JsonApi/Routes/Tree/CoursesOfTreeNode.php create mode 100644 lib/classes/JsonApi/Routes/Tree/DetailsOfTreeNodeCourse.php create mode 100644 lib/classes/JsonApi/Routes/Tree/PathinfoOfTreeNodeCourse.php create mode 100644 lib/classes/JsonApi/Routes/Tree/TreeShow.php create mode 100644 lib/classes/JsonApi/Schemas/TreeNode.php create mode 100644 lib/classes/StudipTreeNode.php create mode 100644 lib/classes/searchtypes/TreeSearch.class.php create mode 100644 lib/models/RangeTreeNode.php delete mode 100644 public/admin_range_tree.php delete mode 100644 public/admin_sem_tree.php create mode 100644 resources/assets/javascripts/bootstrap/treeview.js create mode 100644 resources/assets/stylesheets/scss/tree.scss create mode 100644 resources/vue/components/SearchWidget.vue create mode 100644 resources/vue/components/tree/AssignLinkWidget.vue create mode 100644 resources/vue/components/tree/StudipTree.vue create mode 100644 resources/vue/components/tree/StudipTreeList.vue create mode 100644 resources/vue/components/tree/StudipTreeNode.vue create mode 100644 resources/vue/components/tree/StudipTreeTable.vue create mode 100644 resources/vue/components/tree/TreeBreadcrumb.vue create mode 100644 resources/vue/components/tree/TreeCourseDetails.vue create mode 100644 resources/vue/components/tree/TreeExportWidget.vue create mode 100644 resources/vue/components/tree/TreeNodeCourseInfo.vue create mode 100644 resources/vue/components/tree/TreeNodeCoursePath.vue create mode 100644 resources/vue/components/tree/TreeNodeTile.vue create mode 100644 resources/vue/components/tree/TreeSearchResult.vue create mode 100644 resources/vue/mixins/TreeMixin.js diff --git a/app/controllers/admin/courses.php b/app/controllers/admin/courses.php index a683639e416..71b75ce7aff 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 00000000000..18ddb065277 --- /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 536c361ae41..71b7f7d689a 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 00000000000..e43c9b0af15 --- /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 00000000000..7665b60c10a --- /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 00000000000..b28e96bb611 --- /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 0d19b001782..830ac4da623 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 00000000000..df57aef0aca --- /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 00000000000..c2866028118 --- /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 00000000000..6b7255fdc6a --- /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 00000000000..0076b95c8ff --- /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 00000000000..1e3e9453f53 --- /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 00000000000..0c48245a6ed --- /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 63b9dc44aa2..c6f49050601 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 00000000000..45a48d133d2 --- /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 00000000000..94d015caed9 --- /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 f60c4dd5d15..52d4402e54b 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 00000000000..8773c0a5b60 --- /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 00000000000..980cac104af --- /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 00000000000..6ecf52a6033 --- /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 00000000000..01a40d3cd36 --- /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 00000000000..c706c482204 --- /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 00000000000..c9f52ed1178 --- /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 628abc828bd..9ce57289658 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 00000000000..6419d03c6ce --- /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 00000000000..283593150cf --- /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 00000000000..623e6198520 --- /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 00000000000..cef1077ecb7 --- /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 00000000000..282b7f94890 --- /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 00000000000..1eaa7972c6e --- /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 19d21f5648d..f4ac20796ed 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 f077d83b70b..e9779c70109 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 00000000000..90e6846de9d --- /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 16f13f16b42..65d28565f9b 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 00000000000..a1d72586d71 --- /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 2531a2e19c3..f4be5473934 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 00000000000..2fecf60e0cd --- /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 00000000000..d1c18238396 --- /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 66505261d24..49f008a6f87 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']; } @@ -283,26 +276,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. */ @@ -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 1b28e2423f8..6c9d7e3c488 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 364f4fc95ce..00000000000 --- 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 e981be7230d..00000000000 --- 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 00000000000..998a70e72ff --- /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 5de07aa22e1..d40d4e02b7e 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 c009e0e8d07..f03d7a2e8fb 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 00000000000..dbad68b1bc5 --- /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 e47024bd988..be72f0ab70b 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 00000000000..d590b009244 --- /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 b6f7ee23537..62a33a8840f 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 00000000000..fec478f43b9 --- /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 00000000000..ff493739f98 --- /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 00000000000..d773f433f89 --- /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 00000000000..39f696ccc14 --- /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 00000000000..0bfc244fb75 --- /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 00000000000..33b04b3c0a2 --- /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 00000000000..30f101b2413 --- /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 00000000000..62b73b0378c --- /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 00000000000..db31d148c3f --- /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 00000000000..26ab88eb4d4 --- /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 00000000000..cbb0679eaef --- /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 00000000000..19bc6b927a4 --- /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 00000000000..9a0292ecf33 --- /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 }); + } + } +} -- GitLab