diff --git a/app/controllers/contents/courseware.php b/app/controllers/contents/courseware.php index 854f35d6302eab12701be9e4ca3733f1f98a68fe..f618d7965986c892439d64a47ab010ed16a64e75 100755 --- a/app/controllers/contents/courseware.php +++ b/app/controllers/contents/courseware.php @@ -123,6 +123,12 @@ class Contents_CoursewareController extends AuthenticatedController ); $sidebar->addWidget($actions)->addLayoutCSSClass('courseware-action-widget'); + $views = new TemplateWidget( + _('Suche'), + $this->get_template_factory()->open('course/courseware/search_widget') + ); + $sidebar->addWidget($views)->addLayoutCSSClass('courseware-search-widget'); + $views = new \TemplateWidget( _('Ansichten'), $this->get_template_factory()->open('course/courseware/view_widget') diff --git a/app/controllers/course/courseware.php b/app/controllers/course/courseware.php index 0e609e7f9cbaf5b54b9442cfe09167cc118ff3fa..7c41756a92f2c56aac003a71eca35c7f8a19766e 100755 --- a/app/controllers/course/courseware.php +++ b/app/controllers/course/courseware.php @@ -116,6 +116,12 @@ class Course_CoursewareController extends AuthenticatedController ); $sidebar->addWidget($actions)->addLayoutCSSClass('courseware-action-widget'); + $views = new TemplateWidget( + _('Suche'), + $this->get_template_factory()->open('course/courseware/search_widget') + ); + $sidebar->addWidget($views)->addLayoutCSSClass('courseware-search-widget'); + $views = new TemplateWidget( _('Ansichten'), $this->get_template_factory()->open('course/courseware/view_widget') diff --git a/app/views/course/courseware/search_widget.php b/app/views/course/courseware/search_widget.php new file mode 100644 index 0000000000000000000000000000000000000000..6be4ed2a6b1a564d44f97745dd198a5b47dde4e8 --- /dev/null +++ b/app/views/course/courseware/search_widget.php @@ -0,0 +1 @@ +<aside id="courseware-search-widget" class="widget-sidebar"></aside> diff --git a/db/migrations/5.2.11_courseware_search.php b/db/migrations/5.2.11_courseware_search.php new file mode 100644 index 0000000000000000000000000000000000000000..324770c9b9cb167e81949b621db4e010362898dd --- /dev/null +++ b/db/migrations/5.2.11_courseware_search.php @@ -0,0 +1,101 @@ +<?php +final class CoursewareSearch extends Migration +{ + public function description() + { + return "Add Courseware to global search."; + } + + public function up() + { + $statement = DBManager::get()->prepare(" + SELECT * + FROM config + WHERE field = 'GLOBALSEARCH_MODULES' + "); + $statement->execute(); + $config = $statement->fetch(PDO::FETCH_ASSOC); + $config['value'] = json_decode($config['value'], true); + $config['value']['GlobalSearchCourseware'] = [ + 'order' => 14, + 'active' => true, + 'fulltext' => true + ]; + + $statement = DBManager::get()->prepare(" + UPDATE config + SET `value` = :json + WHERE field = 'GLOBALSEARCH_MODULES' + "); + $statement->execute([ + 'json' => json_encode($config['value']) + ]); + + $statement = DBManager::get()->prepare(" + SELECT * + FROM config_values + WHERE field = 'GLOBALSEARCH_MODULES' + "); + $statement->execute(); + $config = $statement->fetch(PDO::FETCH_ASSOC); + if ($config) { + $config['value'] = json_decode($config['value'], true); + $config['value']['GlobalSearchCourseware'] = [ + 'order' => 14, + 'active' => true, + 'fulltext' => true + ]; + + $statement = DBManager::get()->prepare(" + UPDATE config_values + SET `value` = :json + WHERE field = 'GLOBALSEARCH_MODULES' + "); + $statement->execute([ + 'json' => json_encode($config['value']) + ]); + } + } + + public function down() + { + $statement = DBManager::get()->prepare(" + SELECT * + FROM config_values + WHERE field = 'GLOBALSEARCH_MODULES' + "); + $statement->execute(); + $config = $statement->fetch(PDO::FETCH_ASSOC); + if ($config) { + $config['value'] = json_decode($config['value'], true); + unset($config['value']['GlobalSearchCourseware']); + $statement = DBManager::get()->prepare(" + UPDATE config_values + SET `value` = :json + WHERE field = 'GLOBALSEARCH_MODULES' + "); + $statement->execute([ + 'json' => json_encode($config['value']) + ]); + } + + $statement = DBManager::get()->prepare(" + SELECT * + FROM config + WHERE field = 'GLOBALSEARCH_MODULES' + "); + $statement->execute(); + $config = $statement->fetch(PDO::FETCH_ASSOC); + $config['value'] = json_decode($config['value'], true); + unset($config['value']['GlobalSearchCourseware']); + $statement = DBManager::get()->prepare(" + UPDATE config + SET `value` = :json + WHERE field = 'GLOBALSEARCH_MODULES' + "); + $statement->execute([ + 'json' => json_encode($config['value']) + ]); + + } +} diff --git a/lib/classes/globalsearch/GlobalSearchCourseware.php b/lib/classes/globalsearch/GlobalSearchCourseware.php new file mode 100644 index 0000000000000000000000000000000000000000..9f1f243fef01085adf4c6634ef1bae5493a3a786 --- /dev/null +++ b/lib/classes/globalsearch/GlobalSearchCourseware.php @@ -0,0 +1,177 @@ +<?php + +use Courseware\StructuralElement; +use Courseware\Block; +use Courseware\Container; + +/** + * Global search module for files + * + * @author Ron Lucke <lucke@elan-ev.de> + * @category Stud.IP + * @since 5.2 + */ +class GlobalSearchCourseware extends GlobalSearchModule implements GlobalSearchFulltext +{ + /** + * Returns the displayname for this module + * + * @return string + */ + public static function getName() + { + return _('Courseware'); + } + + /** + * Returns the filters that are displayed in the sidebar of the global search. + * + * @return array Filters for this class. + */ + public static function getFilters() + { + return []; + } + + public static function getSQL($search, $filter, $limit) + { + if (!$search) { + return null; + } + + $query = DBManager::get()->quote("%{$search}%"); + if ($filter['rangeId']) { + $range_id = $filter['rangeId']; + $sql = "(SELECT `cw_structural_elements` . `id` AS id, CONCAT('', 'cw_structural_elements') AS type + FROM `cw_structural_elements` + WHERE (`title` LIKE {$query} OR `payload` LIKE {$query}) + AND `range_id` = '{$range_id}' + ORDER BY `cw_structural_elements`.`mkdate` DESC) + UNION ( + SELECT se . `id` AS id, CONCAT('', 'cw_containers') AS type + FROM `cw_containers` c + JOIN cw_structural_elements se + ON se . `id` = c . `structural_element_id` + WHERE c. `payload` LIKE {$query} + AND `container_type` != 'list' + AND se . `range_id` = '{$range_id}' + ORDER BY c . `mkdate` DESC) + UNION ( + SELECT se . `id` AS id, CONCAT('', 'cw_blocks') AS type + FROM `cw_blocks` b + JOIN cw_containers c + ON c.id = b.container_id + JOIN cw_structural_elements se + ON se . `id` = c . `structural_element_id` + WHERE b.payload LIKE {$query} + AND se . `range_id` = '{$range_id}' + ORDER BY b . `mkdate` DESC + ) LIMIT {$limit}"; + } else { + $sql = "(SELECT `cw_structural_elements` . `id` AS id, CONCAT('', 'cw_structural_elements') AS type + FROM `cw_structural_elements` + WHERE (`title` LIKE {$query} OR `payload` LIKE {$query}) + ORDER BY `cw_structural_elements`.`mkdate` DESC) + UNION ( + SELECT se . `id` AS id, CONCAT('', 'cw_containers') AS type + FROM `cw_containers` c + JOIN cw_structural_elements se + ON se . `id` = c . `structural_element_id` + WHERE c. `payload` LIKE {$query} + AND `container_type` != 'list' + ORDER BY c . `mkdate` DESC) + UNION ( + SELECT se . `id` AS id, CONCAT('', 'cw_blocks') AS type + FROM `cw_blocks` b + JOIN cw_containers c + ON c.id = b.container_id + JOIN cw_structural_elements se + ON se . `id` = c . `structural_element_id` + WHERE b.payload LIKE {$query} + ORDER BY b . `mkdate` DESC + ) LIMIT {$limit}"; + } + + return $sql; + } + + public static function filter($data, $search) + { + $user = $GLOBALS['user']; + $structural_element = StructuralElement::find($data['id']); + if ($structural_element->canRead($user)) { + if ($data['type'] === 'cw_structural_elements') { + $description = self::mark($structural_element->payload['description'], $search, true); + } + if ($data['type'] === 'cw_containers') { + $description = _('Suchbegriff wurde in einem Abschnitt gefunden'); + } + if ($data['type'] === 'cw_blocks') { + $description = _('Suchbegriff wurde in einem Block gefunden'); + } + $pageData = self::getPageData($structural_element); + $date = new DateTime(); + $date->setTimestamp($structural_element->chdate); + + return [ + 'name' => self::mark($structural_element->title, $search, true), + 'description' => $description, + 'url' => $pageData['url'], + 'img' => $structural_element->image ? $structural_element->getImageUrl(): null, + 'additional' => '<a href="' . $pageData['originUrl'] . '" title="' . $pageData['originName'] . '">' . $pageData['originName'] . '</a>', + 'date' => $date->format('d.m.Y H:i'), + 'structural-element-id' => $structural_element->id + ]; + } + return []; + } + + private static function getPageData(StructuralElement $structural_element): Array + { + $url = ''; + $originUrl = ''; + $originName = ''; + if ($structural_element->range_type === 'course') { + $url = URLHelper::getURL( + "dispatch.php/course/courseware?cid={$structural_element->range_id}#/structural_element/{$structural_element->id}", + [], + true); + $originUrl = URLHelper::getURL( + "dispatch.php/course/overview?cid={$structural_element->range_id}", + [], + true); + $originName = Course::find($structural_element->range_id)->name; + } + if ($structural_element->range_type === 'user') { + $url = URLHelper::getURL( + "dispatch.php/contents/courseware/courseware#/structural_element/{$structural_element->id}", + [], + true); + $originUrl = URLHelper::getURL( + "dispatch.php/contents/courseware/index", + [], + true); + $originName = _('Persönliche Lernmaterialien'); + } + + return array( + 'url' => $url, + 'originUrl' => $originUrl, + 'originName' => $originName + ); + } + + public static function enable() + { + } + + public static function disable() + { + } + + public static function getSearchURL($searchterm) + { + return null; + } + +} diff --git a/resources/assets/stylesheets/scss/courseware.scss b/resources/assets/stylesheets/scss/courseware.scss index 4db8ab07d109179734cacb99f44834ef8f726fc1..175cbd70741d53246ec5f67a8500723f6efb738b 100755 --- a/resources/assets/stylesheets/scss/courseware.scss +++ b/resources/assets/stylesheets/scss/courseware.scss @@ -280,6 +280,10 @@ $consum_ribbon_width: calc(100% - 58px); .cw-ribbon-nav { display: flex; min-width: 75px; + + &.single-icon { + min-width: 45px; + } } .cw-ribbon-breadcrumb { @@ -326,6 +330,12 @@ $consum_ribbon_width: calc(100% - 58px); .cw-ribbon-wrapper-right { display: flex; + + button { + border: none; + background-color: transparent; + cursor: pointer; + } } .cw-ribbon-button { diff --git a/resources/vue/components/courseware/CoursewareRibbon.vue b/resources/vue/components/courseware/CoursewareRibbon.vue index 051ae4361045ad96e837643082a17e6f1deedd76..4b18c15fadbb153b3e66adf97913bd619606b1ac 100755 --- a/resources/vue/components/courseware/CoursewareRibbon.vue +++ b/resources/vue/components/courseware/CoursewareRibbon.vue @@ -3,7 +3,7 @@ <div v-if="stickyRibbon" class="cw-ribbon-sticky-top"></div> <header class="cw-ribbon" :class="{ 'cw-ribbon-sticky': stickyRibbon, 'cw-ribbon-consume': consumeMode }"> <div class="cw-ribbon-wrapper-left"> - <nav class="cw-ribbon-nav"> + <nav class="cw-ribbon-nav" :class="buttonsClass"> <slot name="buttons" /> </nav> <nav class="cw-ribbon-breadcrumb"> @@ -17,7 +17,7 @@ <button class="cw-ribbon-button cw-ribbon-button-menu" :title="textRibbon.toolbar" - @click="activeToolbar" + @click.prevent="activeToolbar" > </button> <button @@ -54,6 +54,15 @@ export default { }, props: { canEdit: Boolean, + showToolbarButton: { + default: true, + type: Boolean + }, + showModeSwitchButton: { + default: true, + type: Boolean + }, + buttonsClass: String, }, data() { return { diff --git a/resources/vue/components/courseware/CoursewareSearchResults.vue b/resources/vue/components/courseware/CoursewareSearchResults.vue new file mode 100644 index 0000000000000000000000000000000000000000..cdac1eefeaf61ca1307ed1231f72559ff1dd46c5 --- /dev/null +++ b/resources/vue/components/courseware/CoursewareSearchResults.vue @@ -0,0 +1,80 @@ +<template> + <div role="region" id="search" aria-live="polite"> + <courseware-ribbon + :showToolbarButton="false" + :showModeSwitchButton="false" + buttonsClass="single-icon" + > + <template #buttons> + <studip-icon shape="search" size="24" /> + </template> + <template #breadcrumbList> + <translate>Suchergebnisse</translate> + </template> + <template #menu> + <button :title="$gettext('Suchergebnisse schließen')" @click="closeResults"> + <studip-icon shape="decline" size="24"/> + </button> + </template> + </courseware-ribbon> + <div id="search-results"> + <article v-if="searchResults.length > 0"> + <section v-for="result in searchResults" :key="result['structural-element-id']"> + <router-link + :to="'/structural_element/' + result['structural-element-id']" + @click.native="closeResults" + > + <div v-show="result.img !== null" class="search-result-img hidden-tiny-down"> + <img :src="result.img" /> + </div> + <div class="search-result-data"> + <div class="search-result-title" v-html="result.name"></div> + <div class="search-result-details"> + <div class="search-result-description" v-html="result.description"></div> + </div> + </div> + <div class="search-result-information"> + <div class="search-result-time" v-html="result.date"></div> + </div> + </router-link> + </section> + </article> + <courseware-companion-box + v-else + :msgCompanion="$gettext('Es wurden keine Suchergebnisse gefunden.')" + mood="sad" + /> + </div> + </div> +</template> + +<script> +import CoursewareRibbon from './CoursewareRibbon.vue'; +import CoursewareCompanionBox from './CoursewareCompanionBox.vue'; +import StudipIcon from '../StudipIcon.vue'; +import { mapActions, mapGetters } from 'vuex'; + +export default { + name: 'courseware-search-results', + components: { + CoursewareRibbon, + CoursewareCompanionBox, + StudipIcon + }, + computed: { + ...mapGetters({ + searchResults: 'searchResults' + }), + }, + methods: { + ...mapActions({ + setShowSearchResults: 'setShowSearchResults', + setSearchResults: 'setSearchResults', + }), + closeResults() { + this.setShowSearchResults(false); + this.setSearchResults([]); + }, + } +} +</script> diff --git a/resources/vue/components/courseware/CoursewareSearchWidget.vue b/resources/vue/components/courseware/CoursewareSearchWidget.vue new file mode 100644 index 0000000000000000000000000000000000000000..bbb5de9cef34382fc959bacfbfbe42640edd25bb --- /dev/null +++ b/resources/vue/components/courseware/CoursewareSearchWidget.vue @@ -0,0 +1,72 @@ +<template> + <form class="sidebar-search" @submit.prevent=""> + <ul class="needles"> + <li> + <form @submit.prevent=""> + <input type="text" v-model="searchTerm"/> + <input + type="submit" + :value="$gettext('Suchen')" + aria-controls="search" + @click="loadResults" + /> + </form> + </li> + </ul> + </form> +</template> + +<script> +import axios from 'axios'; +import { mapActions, mapGetters } from 'vuex'; + +export default { + name: 'courseware-search-widget', + data() { + return { + searchTerm: '', + } + }, + computed: { + ...mapGetters({ + courseware: 'courseware', + context: 'context', + }), + }, + methods: { + ...mapActions({ + setShowSearchResults: 'setShowSearchResults', + setSearchResults: 'setSearchResults', + companionWarning: 'companionWarning' + }), + loadResults() { + if (this.searchTerm.length < 3) { + this.companionWarning({ info: this.$gettext('Leider ist Ihr Suchbegriff zu kurz. Der Suchbegriff muss mindestens 3 Zeichen lang sein.')}); + return; + } + const limit = 100; + let params = { + search: this.searchTerm, + filters: { category: 'GlobalSearchCourseware', contextType: this.context.type, rangeId: this.context.id} + }; + + axios({ + method: 'get', + url: STUDIP.URLHelper.getURL('dispatch.php/globalsearch/find/' + limit), + params: params, + }).then( result => { + this.setShowSearchResults(true); + if (result.data.GlobalSearchCourseware) { + this.setSearchResults((result.data.GlobalSearchCourseware.content)); + } else { + this.setSearchResults([]); + } + }).catch(error => { + console.debug(error); + }); + } + } + + +} +</script> diff --git a/resources/vue/components/courseware/CoursewareStructuralElement.vue b/resources/vue/components/courseware/CoursewareStructuralElement.vue index 36381bf82d45566776298639d52c3cee14e224c0..a1786b178c625a848b0f2aa74e83c6579d4e738a 100755 --- a/resources/vue/components/courseware/CoursewareStructuralElement.vue +++ b/resources/vue/components/courseware/CoursewareStructuralElement.vue @@ -170,8 +170,6 @@ </div> </div> - <courseware-companion-overlay /> - <studip-dialog v-if="showEditDialog" :title="textEdit.title" @@ -581,7 +579,6 @@ import CoursewareAccordionContainer from './CoursewareAccordionContainer.vue'; import CoursewareCompanionBox from './CoursewareCompanionBox.vue'; import CoursewareWellcomeScreen from './CoursewareWellcomeScreen.vue'; import CoursewareEmptyElementBox from './CoursewareEmptyElementBox.vue'; -import CoursewareCompanionOverlay from './CoursewareCompanionOverlay.vue'; import CoursewareListContainer from './CoursewareListContainer.vue'; import CoursewareTabsContainer from './CoursewareTabsContainer.vue'; import CoursewareRibbon from './CoursewareRibbon.vue'; @@ -605,7 +602,6 @@ export default { CoursewareAccordionContainer, CoursewareTabsContainer, CoursewareCompanionBox, - CoursewareCompanionOverlay, CoursewareWellcomeScreen, CoursewareEmptyElementBox, CoursewareTabs, diff --git a/resources/vue/components/courseware/IndexApp.vue b/resources/vue/components/courseware/IndexApp.vue index 1c39d2011b8d0527bb17de9290b06d18a9a2fc8c..2d50abfba430a1078c61aa1eb71143811f905f75 100755 --- a/resources/vue/components/courseware/IndexApp.vue +++ b/resources/vue/components/courseware/IndexApp.vue @@ -1,7 +1,9 @@ <template> <div> <div v-if="structureLoadingState === 'done'"> + <courseware-search-results v-show="showSearchResults" /> <courseware-structural-element + v-show="!showSearchResults" :canVisit="canVisit" :structural-element="selected" :ordered-structural-elements="orderedStructuralElements" @@ -10,12 +12,15 @@ <MountingPortal mountTo="#courseware-action-widget" name="sidebar-actions"> <courseware-action-widget :structural-element="selected" :canVisit="canVisit"></courseware-action-widget> </MountingPortal> - <MountingPortal mountTo="#courseware-export-widget" name="sidebar-actions"> - <courseware-export-widget :structural-element="selected" :canVisit="canVisit"></courseware-export-widget> + <MountingPortal mountTo="#courseware-search-widget" name="sidebar-search"> + <courseware-search-widget></courseware-search-widget> </MountingPortal> <MountingPortal mountTo="#courseware-view-widget" name="sidebar-views"> <courseware-view-widget :structural-element="selected" :canVisit="canVisit"></courseware-view-widget> </MountingPortal> + <MountingPortal mountTo="#courseware-export-widget" name="sidebar-export"> + <courseware-export-widget :structural-element="selected" :canVisit="canVisit"></courseware-export-widget> + </MountingPortal> </div> <studip-progress-indicator v-if="structureLoadingState === 'loading'" @@ -27,26 +32,34 @@ mood="sad" :msgCompanion="loadingErrorMessage" /> + <courseware-companion-overlay /> </div> </template> <script> import CoursewareStructuralElement from './CoursewareStructuralElement.vue'; +import CoursewareSearchResults from './CoursewareSearchResults.vue'; import CoursewareViewWidget from './CoursewareViewWidget.vue'; import CoursewareActionWidget from './CoursewareActionWidget.vue'; import CoursewareExportWidget from './CoursewareExportWidget.vue'; import CoursewareCompanionBox from './CoursewareCompanionBox.vue'; +import CoursewareSearchWidget from './CoursewareSearchWidget.vue'; +import CoursewareCompanionOverlay from './CoursewareCompanionOverlay.vue'; import StudipProgressIndicator from '../StudipProgressIndicator.vue'; + import { mapActions, mapGetters } from 'vuex'; export default { components: { CoursewareStructuralElement, + CoursewareSearchResults, CoursewareViewWidget, CoursewareActionWidget, CoursewareCompanionBox, StudipProgressIndicator, - CoursewareExportWidget + CoursewareExportWidget, + CoursewareSearchWidget, + CoursewareCompanionOverlay, }, data: () => ({ canVisit: null, @@ -61,6 +74,7 @@ export default { courseware: 'courseware', orderedStructuralElements: 'courseware-structure/ordered', relatedStructuralElement: 'courseware-structural-elements/related', + showSearchResults: 'showSearchResults', structuralElementLastMeta: 'courseware-structural-elements/lastMeta', structuralElements: 'courseware-structural-elements/all', structuralElementById: 'courseware-structural-elements/byId', diff --git a/resources/vue/store/courseware/courseware.module.js b/resources/vue/store/courseware/courseware.module.js index d8ae6d77e03c83f1df2ef95e7e73860faf553d12..dd4cfac60d932b16f0ced8853cf8a825d057676f 100755 --- a/resources/vue/store/courseware/courseware.module.js +++ b/resources/vue/store/courseware/courseware.module.js @@ -54,6 +54,9 @@ const getDefaultState = () => { showOverviewElementAddDialog: false, bookmarkFilter: 'all', + + showSearchResults: false, + searchResults: [], }; }; @@ -204,6 +207,12 @@ const getters = { bookmarkFilter(state) { return state.bookmarkFilter; }, + showSearchResults(state) { + return state.showSearchResults; + }, + searchResults(state) { + return state.searchResults; + }, }; export const state = { ...initialState }; @@ -844,6 +853,14 @@ export const actions = { commit('setExportProgress', percent); }, + setShowSearchResults({ commit }, state) { + commit('setShowSearchResults', state); + }, + + setSearchResults({ commit }, state) { + commit('setSearchResults', state); + }, + addBookmark({ dispatch, rootGetters }, structuralElement) { const cw = rootGetters['courseware']; @@ -1342,6 +1359,12 @@ export const mutations = { setBookmarkFilter(state, course) { state.bookmarkFilter = course; }, + setShowSearchResults(state, searchState) { + state.showSearchResults = searchState; + }, + setSearchResults(state, results) { + state.searchResults = results; + }, }; export default {