From a9c40e363b3e13223bb771105c190be9475021a5 Mon Sep 17 00:00:00 2001 From: Jan-Hendrik Willms <tleilax+studip@gmail.com> Date: Thu, 4 Jul 2024 13:31:50 +0000 Subject: [PATCH] introduce [data-vue-app], fixes #4294 Closes #4294, #4295, #4297, #4298, #4301, #4304, and #4306 Merge request studip/studip!3109 --- app/controllers/admin/cache.php | 28 +- app/controllers/admin/courses.php | 8 +- app/controllers/admin/tree.php | 42 ++- app/controllers/course/contentmodules.php | 22 +- app/controllers/my_courses.php | 20 +- app/controllers/search/courses.php | 15 ++ app/views/admin/cache/settings.php | 14 - app/views/admin/courses/index.php | 34 +-- app/views/admin/tree/batch_assign_semtree.php | 10 +- app/views/admin/tree/rangetree.php | 9 - app/views/admin/tree/semtree.php | 10 - app/views/course/contentmodules/index.php | 1 - app/views/course/contentmodules/info.php | 4 +- app/views/my_courses/index.php | 11 +- app/views/search/courses/index.php | 16 -- lib/classes/Debug/VueCollector.php | 97 +++++++ lib/classes/StudipController.php | 11 + lib/classes/VueApp.php | 194 +++++++++++++ .../javascripts/bootstrap/admin-courses.js | 26 -- .../javascripts/bootstrap/cache-admin.js | 21 -- .../javascripts/bootstrap/contentmodules.js | 45 ---- .../javascripts/bootstrap/my-courses.js | 22 -- .../bootstrap/responsive-navigation.js | 10 - .../assets/javascripts/bootstrap/treeview.js | 13 - resources/assets/javascripts/bootstrap/vue.js | 80 +++--- resources/assets/javascripts/entry-base.js | 8 - .../assets/javascripts/lib/admin-courses.js | 15 +- .../assets/javascripts/lib/studip-vue.js | 35 ++- resources/vue/components/AdminCourses.vue | 254 +++++++++++------- .../vue/components/CacheAdministration.vue | 11 +- templates/header.php | 12 +- templates/vue-app.php | 14 + 32 files changed, 690 insertions(+), 422 deletions(-) delete mode 100644 app/views/admin/cache/settings.php delete mode 100644 app/views/admin/tree/rangetree.php delete mode 100644 app/views/admin/tree/semtree.php delete mode 100644 app/views/course/contentmodules/index.php delete mode 100644 app/views/search/courses/index.php create mode 100644 lib/classes/Debug/VueCollector.php create mode 100644 lib/classes/VueApp.php delete mode 100644 resources/assets/javascripts/bootstrap/cache-admin.js delete mode 100644 resources/assets/javascripts/bootstrap/contentmodules.js delete mode 100644 resources/assets/javascripts/bootstrap/my-courses.js delete mode 100644 resources/assets/javascripts/bootstrap/responsive-navigation.js delete mode 100644 resources/assets/javascripts/bootstrap/treeview.js create mode 100644 templates/vue-app.php diff --git a/app/controllers/admin/cache.php b/app/controllers/admin/cache.php index c016af5efd6..7bd70c64627 100644 --- a/app/controllers/admin/cache.php +++ b/app/controllers/admin/cache.php @@ -64,19 +64,21 @@ class Admin_CacheController extends AuthenticatedController */ public function settings_action() { - if ($this->enabled) { - $this->types = CacheType::findAndMapBySQL(function (CacheType $type) { - return $type->toArray(); - }, "1 ORDER BY `cache_id`"); - - $currentCache = Config::get()->SYSTEMCACHE; - $currentCacheClass = CacheType::findOneByClass_name($currentCache['type']); - $this->cache = $currentCacheClass->class_name; - $this->config = $currentCacheClass->class_name::getConfig(); - } else { - PageLayout::postWarning( - _('Caching ist systemweit ausgeschaltet, daher kann hier nichts konfiguriert werden.')); - } + $currentCache = Config::get()->SYSTEMCACHE; + $currentCacheClass = CacheType::findOneByClass_name($currentCache['type']); + + $this->render_vue_app( + Studip\VueApp::create('CacheAdministration') + ->withProps([ + 'enabled' => (bool) $this->enabled, + 'currentCache' => $currentCacheClass->class_name, + 'currentConfig' => $currentCacheClass->class_name::getConfig(), + 'cacheTypes' => CacheType::findAndMapBySQL( + fn(CacheType $type) => $type->toArray(), + "1 ORDER BY `cache_id`" + ), + ]) + ); } /** diff --git a/app/controllers/admin/courses.php b/app/controllers/admin/courses.php index 5f60442bb40..a10aa5cfe19 100644 --- a/app/controllers/admin/courses.php +++ b/app/controllers/admin/courses.php @@ -305,15 +305,9 @@ class Admin_CoursesController extends AuthenticatedController $this->fields = $this->getViewFilters(); $this->sortby = $GLOBALS['user']->cfg->MEINE_SEMINARE_SORT ?? (Config::get()->IMPORTANT_SEMNUMBER ? 'number' : 'name'); $this->sortflag = $GLOBALS['user']->cfg->MEINE_SEMINARE_SORT_FLAG ?? 'ASC'; + $this->store_data = $this->getStoreData(); $this->buildSidebar(); - - PageLayout::addHeadElement('script', [ - 'type' => 'text/javascript', - ], sprintf( - 'window.AdminCoursesStoreData = %s;', - json_encode($this->getStoreData()) - )); } private function getStoreData(): array diff --git a/app/controllers/admin/tree.php b/app/controllers/admin/tree.php index c8f2a8f1c4a..ec35368c262 100644 --- a/app/controllers/admin/tree.php +++ b/app/controllers/admin/tree.php @@ -7,10 +7,28 @@ class Admin_TreeController extends AuthenticatedController $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(); + + $this->render_vue_app( + Studip\VueApp::create('tree/StudipTree') + ->withProps([ + 'breadcrumb-icon' => 'institute', + 'create-url' => $this->createURL(), + 'delete-url' => $this->deleteURL(), + 'edit-url' => $this->editURL(), + 'editable' => true, + 'semester' => $this->semester, + 'show-structure-as-navigation' => true, + 'start-id' => Request::get('node_id', 'RangeTreeNode_root'), + 'title' => _('Einrichtungshierarchie bearbeiten'), + 'view-type' => 'table', + 'visible-children-only' => false, + 'with-courses' => true, + ]) + ); } public function semtree_action() @@ -18,10 +36,30 @@ class Admin_TreeController extends AuthenticatedController $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(); + + $this->render_vue_app( + Studip\VueApp::create('tree/StudipTree') + ->withProps([ + 'breadcrumb-icon' => 'literature', + 'create-url' => $this->createURL(), + 'delete-url' => $this->deleteURL(), + 'edit-url' => $this->editURL(), + 'editable' => true, + 'semester' => $this->semester, + 'show-structure-as-navigation' => true, + 'start-id' => Request::get('node_id', 'StudipStudyArea_root'), + 'title' => _('Veranstaltungshierarchie bearbeiten'), + 'view-type' => 'table', + 'visible-children-only' => false, + 'with-course-assign' => true, + 'with-courses' => true, + ]) + ); } /** diff --git a/app/controllers/course/contentmodules.php b/app/controllers/course/contentmodules.php index d37d1bb050a..d1ac7b48b37 100644 --- a/app/controllers/course/contentmodules.php +++ b/app/controllers/course/contentmodules.php @@ -83,18 +83,16 @@ class Course_ContentmodulesController extends AuthenticatedController Sidebar::Get()->addWidget($widget); } - PageLayout::addHeadElement('script', [ - 'type' => 'text/javascript', - ], sprintf( - 'window.ContentModulesStoreData = %s;', - json_encode([ - 'setCategories' => $this->categories, - 'setHighlighted' => $this->highlighted_modules, - 'setModules' => array_values($this->modules), - 'setUserId' => User::findCurrent()->id, - 'setView' => $GLOBALS['user']->cfg->CONTENTMODULES_TILED_DISPLAY ? 'tiles' : 'table', - ]) - )); + $this->render_vue_app( + Studip\VueApp::create('ContentModules') + ->withStore('ContentModulesStore', 'contentmodules', [ + 'setCategories' => $this->categories, + 'setHighlighted' => $this->highlighted_modules, + 'setModules' => array_values($this->modules), + 'setUserId' => User::findCurrent()->id, + 'setView' => $GLOBALS['user']->cfg->CONTENTMODULES_TILED_DISPLAY ? 'tiles' : 'table', + ]) + ); } public function trigger_action() diff --git a/app/controllers/my_courses.php b/app/controllers/my_courses.php index 8205c12c743..e17fc91b38b 100644 --- a/app/controllers/my_courses.php +++ b/app/controllers/my_courses.php @@ -113,13 +113,13 @@ class MyCoursesController extends AuthenticatedController } $this->setupSidebar($sem_key, $group_field, $this->check_for_new($sem_courses, $group_field)); - $data = $this->getMyCoursesData($sem_courses, $group_field); - PageLayout::addHeadElement( - 'script', - ['type' => 'text/javascript'], - 'window.STUDIP.MyCoursesData = ' . json_encode($data) . ';' - ); + $this->vueApp = Studip\VueApp::create('MyCourses') + ->withStore( + 'MyCoursesStore', + 'mycourses', + $this->getMyCoursesData($sem_courses, $group_field) + ); } /** @@ -797,10 +797,10 @@ class MyCoursesController extends AuthenticatedController } return [ - 'courses' => $this->sanitizeNavigations(array_map([$this, 'convertCourse'], $temp_courses)), - 'groups' => $groups, - 'user_id' => $GLOBALS['user']->id, - 'config' => [ + 'setCourses' => $this->sanitizeNavigations(array_map([$this, 'convertCourse'], $temp_courses)), + 'setGroups' => $groups, + 'setUserId' => $GLOBALS['user']->id, + 'setConfig' => [ 'allow_dozent_visibility' => Config::get()->ALLOW_DOZENT_VISIBILITY, 'open_groups' => array_values($GLOBALS['user']->cfg->MY_COURSES_OPEN_GROUPS), 'sem_number' => Config::get()->IMPORTANT_SEMNUMBER, diff --git a/app/controllers/search/courses.php b/app/controllers/search/courses.php index a6a4d272d11..76e3320e2bd 100644 --- a/app/controllers/search/courses.php +++ b/app/controllers/search/courses.php @@ -59,6 +59,21 @@ class Search_CoursesController extends AuthenticatedController $this->setupSidebar(); PageLayout::setTitle($title); + + $this->render_vue_app( + Studip\VueApp::create('tree/StudipTree') + ->withProps([ + 'breadcrumb-icon' => $this->breadcrumbIcon, + 'sem-class' => $this->semClass, + 'semester' => $this->semester, + 'start-id' => $this->startId, + 'title' => $this->treeTitle, + 'view-type' => $this->show_as, + 'with-courses' => true, + 'with-export' => true, + 'with-search' => true, + ]) + ); } private function setupSidebar() diff --git a/app/views/admin/cache/settings.php b/app/views/admin/cache/settings.php deleted file mode 100644 index cf022d4d930..00000000000 --- a/app/views/admin/cache/settings.php +++ /dev/null @@ -1,14 +0,0 @@ -<?php -/** - * @var boolean $enabled - * @var array $types - * @var array $config - * @var string $cache - */ -?> -<? if ($enabled) : ?> - <div id="cache-admin-container"> - <cache-administration :cache-types='<?= htmlReady(json_encode($types)) ?>' current-cache="<?= htmlReady($cache) ?>" - :current-config='<?= htmlReady(json_encode($config)) ?>'></cache-administration> - </div> -<? endif; diff --git a/app/views/admin/courses/index.php b/app/views/admin/courses/index.php index 812790c6151..690366c0cae 100644 --- a/app/views/admin/courses/index.php +++ b/app/views/admin/courses/index.php @@ -9,6 +9,7 @@ * @var string $sortflag * @var array $activeSidebarElements * @var int $max_show_courses + * @var array $store_data */ $unsortable_fields = [ @@ -17,29 +18,18 @@ $unsortable_fields = [ 'contents' ]; ?> - <? if (empty($insts)): ?> <?= MessageBox::info(sprintf(_('Sie wurden noch keinen Einrichtungen zugeordnet. Bitte wenden Sie sich an einen der zuständigen %sAdministratoren%s.'), '<a href="' . URLHelper::getLink('dispatch.php/siteinfo/show') . '">', '</a>')) ?> -<? else : - - $attributes = [ - ':show-complete' => json_encode((bool) Config::get()->ADMIN_COURSES_SHOW_COMPLETE), - ':fields' => json_encode($fields), - ':unsortable-fields' => json_encode($unsortable_fields), - ':max-courses' => (int) $max_show_courses, - 'sort-by' => $sortby, - 'sort-flag' => $sortflag, - ]; -?> - <form method="post"> - <?= CSRFProtection::tokenTag() ?> - - <div class="admin-courses-vue-app course-admin" - is="AdminCourses" - v-cloak - ref="app" - <?= arrayToHtmlAttributes($attributes) ?> - ></div> - </form> +<? else: ?> + <?= Studip\VueApp::create('AdminCourses') + ->withProps([ + 'show-complete' => (bool) Config::get()->ADMIN_COURSES_SHOW_COMPLETE, + 'fields' => $fields, + 'unsortable-fields' => $unsortable_fields, + 'max-courses' => (int) $max_show_courses, + 'sort-by' => $sortby, + 'sort-flag' => $sortflag, + ]) + ->withStore('AdminCoursesStore', 'admincourses', $store_data) ?> <? endif; ?> diff --git a/app/views/admin/tree/batch_assign_semtree.php b/app/views/admin/tree/batch_assign_semtree.php index 4b1a4ff0649..6b7df0e2bbd 100644 --- a/app/views/admin/tree/batch_assign_semtree.php +++ b/app/views/admin/tree/batch_assign_semtree.php @@ -2,10 +2,12 @@ <?= CSRFProtection::tokenTag() ?> <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> + <?= Studip\VueApp::create('tree/StudipTree')->withProps([ + 'assignable' => true, + 'open-levels' => 1, + 'start-id' => 'StudipStudyArea_root', + 'with-info' => false, + ]) ?> </fieldset> <fieldset> <legend><?= _('Diese Veranstaltungen werden zugewiesen') ?></legend> diff --git a/app/views/admin/tree/rangetree.php b/app/views/admin/tree/rangetree.php deleted file mode 100644 index 1e3e9453f53..00000000000 --- a/app/views/admin/tree/rangetree.php +++ /dev/null @@ -1,9 +0,0 @@ -<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 deleted file mode 100644 index 0c48245a6ed..00000000000 --- a/app/views/admin/tree/semtree.php +++ /dev/null @@ -1,10 +0,0 @@ -<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/course/contentmodules/index.php b/app/views/course/contentmodules/index.php deleted file mode 100644 index af8f3e1ebaf..00000000000 --- a/app/views/course/contentmodules/index.php +++ /dev/null @@ -1 +0,0 @@ -<div class="content-modules-vue-app" is="ContentModules"></div> diff --git a/app/views/course/contentmodules/info.php b/app/views/course/contentmodules/info.php index 93d3ad9cb68..0585ebecef9 100644 --- a/app/views/course/contentmodules/info.php +++ b/app/views/course/contentmodules/info.php @@ -18,7 +18,9 @@ <? endif; ?> </div> </div> - <div class="content-modules-controls-vue-app" is="ContentModulesControl" module_id="<?= htmlReady($plugin->getPluginId()) ?>"></div> + <?= Studip\VueApp::create('ContentModulesControl')->withProps([ + 'module_id' => (string) $plugin->getPluginId(), + ]) ?> <? $keywords = preg_split( "/;/", $metadata['keywords'] ?? '', -1, PREG_SPLIT_NO_EMPTY) ?> <? if (count($keywords) > 0) : ?> <ul class="keywords"> diff --git a/app/views/my_courses/index.php b/app/views/my_courses/index.php index 1bbfd7388e8..8ec7703a80b 100644 --- a/app/views/my_courses/index.php +++ b/app/views/my_courses/index.php @@ -1,10 +1,15 @@ +<?php +/** + * @var Studip\VueApp $vueApp + * @var array $my_bosses + * @var array $waiting_list + */ +?> <? if ($waiting_list) : ?> <?= $this->render_partial('my_courses/waiting_list.php', compact('waiting_list')) ?> <? endif ?> -<div class="my-courses-vue-app"> - <my-courses /> -</div> +<?= $vueApp->render() ?> <? if (count($my_bosses) > 0) : ?> <?= $this->render_partial('my_courses/_deputy_bosses'); ?> diff --git a/app/views/search/courses/index.php b/app/views/search/courses/index.php deleted file mode 100644 index 500c31e7f69..00000000000 --- a/app/views/search/courses/index.php +++ /dev/null @@ -1,16 +0,0 @@ -<?php -/** - * @var String $startId - * @var String $show_as - * @var String $treeTitle - * @var String $breadcrumIcon - * @var String $semester - * @var String $semClass - */ -?> -<div data-studip-tree> - <studip-tree start-id="<?= htmlReady($startId) ?>" view-type="<?= htmlReady($show_as) ?>" :visible-children-only="true" - title="<?= htmlReady($treeTitle) ?>" breadcrumb-icon="<?= htmlReady($breadcrumbIcon) ?>" - :with-search="true" :with-export="true" :with-courses="true" semester="<?= htmlReady($semester) ?>" - :sem-class="<?= htmlReady($semClass) ?>" :with-export="true"></studip-tree> -</div> diff --git a/lib/classes/Debug/VueCollector.php b/lib/classes/Debug/VueCollector.php new file mode 100644 index 00000000000..a2d90f3ab54 --- /dev/null +++ b/lib/classes/Debug/VueCollector.php @@ -0,0 +1,97 @@ +<?php +namespace Studip\Debug; + +use DebugBar\DataCollector\DataCollector; +use DebugBar\DataCollector\Renderable; +use Studip\VueApp; + +final class VueCollector extends DataCollector implements Renderable +{ + public function __construct( + private readonly VueApp $app + ) { + $this->useHtmlVarDumper(false); + } + + public function collect() + { + $data = []; + + $props = $this->app->getProps(); + if (count($props) > 0) { + ksort($props); + + $data['== DATA =='] = count($props) . ' items'; + foreach ($props as $key => $value) { + $data[$key] = $this->dumpVar($value); + } + } + + $stores = $this->app->getStores(); + if (count($stores) > 0) { + ksort($stores); + $storeData = $this->app->getStoreData(); + + $data['== STORES =='] = ''; + + foreach ($stores as $index => $store) { + $data[$index] = $store === $index ? '' : "({$store})"; + + $tmp = $storeData[$index] ?? []; + ksort($tmp); + foreach ($tmp as $key => $value) { + $data["- {$key}"] = $this->dumpVar($value); + } + } + } + + return $data; + } + + public function getName() + { + return '[Vue]' . basename($this->app->getBaseComponent()); + } + + /** + * @return array + */ + public function getAssets() + { + return $this->isHtmlVarDumperUsed() ? $this->getVarDumper()->getAssets() : []; + } + + /** + * @return array[] + */ + public function getWidgets() + { + $name = $this->getName(); + $widget = $this->isHtmlVarDumperUsed() + ? 'PhpDebugBar.Widgets.HtmlVariableListWidget' + : 'PhpDebugBar.Widgets.VariableListWidget'; + + + return [ + $name => [ + 'icon' => 'code', + 'widget' => $widget, + 'map' => $name, + 'default' => '{}' + ], + ]; + } + + private function dumpVar(mixed $variable): string + { + if ($this->isHtmlVarDumperUsed()) { + return $this->getVarDumper()->renderVar($variable); + } + + if (!is_string($variable)) { + return $this->getDataFormatter()->formatVar($variable); + } + + return $variable; + } +} diff --git a/lib/classes/StudipController.php b/lib/classes/StudipController.php index a908a477c37..f99511c543d 100644 --- a/lib/classes/StudipController.php +++ b/lib/classes/StudipController.php @@ -588,6 +588,17 @@ abstract class StudipController extends Trails\Controller $this->render_text($form->render()); } + /** + * Renders a vue app + * + * Use this if the vue app is the only content located on the page so + * you don't have to create a template file. + */ + public function render_vue_app(\Studip\VueApp $app): void + { + $this->render_template($app->getTemplate(), $this->layout); + } + /** * relays current request to another controller and returns the response * the other controller is given all assigned properties, additional parameters are passed diff --git a/lib/classes/VueApp.php b/lib/classes/VueApp.php new file mode 100644 index 00000000000..0e209615f17 --- /dev/null +++ b/lib/classes/VueApp.php @@ -0,0 +1,194 @@ +<?php +namespace Studip; + +use Flexi\Template; +use Stringable; + +/** + * PHP abstraction of vue app + * + * The VueApp is used to create a Vue app in a general way. Just create it + * using the name of the case component and pass in any required props or + * stores including initial data. + * + * The store data is passed as an associative array where the key is the name + * of the mutation to call with the given value as data. + * + * All methods are written in fluid manner so that you can create the app like this: + * + * <code> + * <?= Studip\VueApp::create('ExampleComponent') + * ->withProps(['foo' => 'bar']) + * ->withStore('exampleStore', data: ['setBar' => 'baz']) ?> + * </code> + * + * All with* methods will always create a new cloned instance so the original + * instance is immutable. + * + * @author Jan-Hendrik Willms <tleilax+studip@gmail.com> + * @since Stud.IP 6.0 + */ +final class VueApp implements Stringable +{ + /** + * Creates a vue app with the given base component. + */ + public static function create(string $base_component): VueApp + { + return new self($base_component); + } + + private array $props = []; + private array $stores = []; + private array $storeData = []; + + /** + * Private constructor since we want to enforce the use of VueApp::create(). + */ + private function __construct( + private readonly string $base_component + ) { + } + + /** + * Returns the base component + */ + public function getBaseComponent(): string + { + return $this->base_component; + } + + /** + * Add props + * + * You may choose to overwrite the defined props + */ + public function withProps(array $props, bool $overwrite = false): VueApp + { + $clone = clone $this; + $clone->props = [...$overwrite ? [] : $clone->props, ...$props]; + return $clone; + } + + /** + * Returns all props + */ + public function getProps(): array + { + return $this->props; + } + + /** + * Add a slot with the given name + * + * If you pass a flexi template as the content, it will be rendered. + */ + public function withSlot(string $name, string|Template $content): VueApp + { + $this->slots[$name] = $content instanceof Template ? $content->render() : $content; + return $this; + } + + /** + * Returns all slots + */ + public function getSlots(): array + { + return $this->slots; + } + + /** + * Adds a store + */ + public function withStore(string $store, ?string $index = null, ?array $data = null): VueApp + { + $clone = clone $this; + + $clone->stores[$index ?? $store] = $store; + + if ($data !== null) { + $clone->storeData[$index ?? $store] = $data; + } + + return $clone; + } + + /** + * Returns all stores + */ + public function getStores(): array + { + return $this->stores; + } + + /** + * Returns all store data + */ + public function getStoreData(): array + { + return $this->storeData; + } + + /** + * Returns the template to render the vue app + */ + public function getTemplate(): Template + { + $data = [ + 'components' => [$this->base_component], + ]; + + if (count($this->stores) > 0) { + $data['stores'] = $this->stores; + } + + $template = $GLOBALS['template_factory']->open('vue-app.php'); + $template->baseComponent = basename($this->base_component); + $template->attributes = ['data-vue-app' => json_encode($data)]; + $template->props = $this->getPreparedProps(); + $template->storeData = $this->storeData; + return $template; + } + + /** + * Returns the props as required to include them in the html + */ + private function getPreparedProps(): array + { + $result = []; + foreach ($this->props as $name => $value) { + $name = ltrim($name, ':'); + $name = strtokebabcase($name); + $result[":{$name}"] = json_encode($value); + } + return $result; + } + + /** + * Renders the vue app + */ + public function render(): string + { + if (Debug\DebugBar::isActivated()) { + $debugbar = app()->get(\DebugBar\DebugBar::class); + $collector = new Debug\VueCollector($this); + $debugbar->addCollector($collector); + } + + \NotificationCenter::postNotification('VueAppWillRender', $this); + + $content = $this->getTemplate()->render(); + + \NotificationCenter::postNotification('VueAppDidRender', $this); + + return $content; + } + + /** + * Returns a string representation of the vue app by rendering it. + */ + public function __toString(): string + { + return $this->render(); + } +} diff --git a/resources/assets/javascripts/bootstrap/admin-courses.js b/resources/assets/javascripts/bootstrap/admin-courses.js index 74802b6ae4d..273b97d91a3 100644 --- a/resources/assets/javascripts/bootstrap/admin-courses.js +++ b/resources/assets/javascripts/bootstrap/admin-courses.js @@ -1,30 +1,4 @@ STUDIP.domReady(() => { - const node = document.querySelector('.admin-courses-vue-app'); - if (!node) { - return; - } - - Promise.all([ - STUDIP.Vue.load(), - import('../../../vue/store/AdminCoursesStore.js').then((config) => config.default), - import('../../../vue/components/AdminCourses.vue').then((component) => component.default), - ]).then(([{ createApp, store }, storeConfig, AdminCourses]) => { - store.registerModule('admincourses', storeConfig); - - Object.entries(window.AdminCoursesStoreData ?? {}).forEach(([key, value]) => { - store.commit(`admincourses/${key}`, value); - }) - - const vm = createApp({ - components: { AdminCourses }, - }); - vm.$mount(node); - - STUDIP.AdminCourses.App = vm.$refs.app; - }); - - - $('.admin-courses-options').find('.options-radio, .options-checkbox').on('click', function () { $(this).toggleClass(['options-checked', 'options-unchecked']); $(this).attr('aria-checked', $(this).is('.options-checked') ? 'true' : 'false'); diff --git a/resources/assets/javascripts/bootstrap/cache-admin.js b/resources/assets/javascripts/bootstrap/cache-admin.js deleted file mode 100644 index 97b41073d22..00000000000 --- a/resources/assets/javascripts/bootstrap/cache-admin.js +++ /dev/null @@ -1,21 +0,0 @@ -/** - * Stud.IP: Administration of available cache types, like database, Memcached, Redis etc. - * - * @author Thomas Hackl <studip@thomas-hackl.name> - * @license GPL2 or any later version - * @copyright Stud.IP core group - * @since Stud.IP 5.0 - */ - -import CacheAdministration from '../../../vue/components/CacheAdministration.vue' - -STUDIP.domReady(() => { - if (document.getElementById('cache-admin-container')) { - STUDIP.Vue.load().then(({ createApp }) => { - createApp({ - el: '#cache-admin-container', - components: { CacheAdministration } - }) - }) - } -}); diff --git a/resources/assets/javascripts/bootstrap/contentmodules.js b/resources/assets/javascripts/bootstrap/contentmodules.js deleted file mode 100644 index 18963fd4e9c..00000000000 --- a/resources/assets/javascripts/bootstrap/contentmodules.js +++ /dev/null @@ -1,45 +0,0 @@ -STUDIP.domReady(() => { - const node = document.querySelector('.content-modules-vue-app'); - if (!node) { - return; - } - - Promise.all([ - STUDIP.Vue.load(), - import('../../../vue/store/ContentModulesStore.js').then((config) => config.default), - import('../../../vue/components/ContentModules.vue').then((component) => component.default), - ]).then(([{ createApp, store }, storeConfig, ContentModules]) => { - store.registerModule('contentmodules', storeConfig); - - Object.entries(window.ContentModulesStoreData ?? {}).forEach(([key, value]) => { - store.commit(`contentmodules/${key}`, value); - }); - - const vm = createApp({ - components: { ContentModules } - }); - vm.$mount(node); - }); -}); - -STUDIP.dialogReady((event) => { - let target = event.target ?? document; - if (target instanceof jQuery) { - target = target.get(0); - } - - const node = target.querySelector('.content-modules-controls-vue-app'); - if (!node) { - return; - } - - Promise.all([ - STUDIP.Vue.load(), - import('../../../vue/components/ContentModulesControl.vue').then((component) => component.default), - ]).then(([{ createApp }, ContentModulesControl]) => { - const vm = createApp({ - components: { ContentModulesControl } - }); - vm.$mount(node); - }); -}); diff --git a/resources/assets/javascripts/bootstrap/my-courses.js b/resources/assets/javascripts/bootstrap/my-courses.js deleted file mode 100644 index 40e0c24dc8d..00000000000 --- a/resources/assets/javascripts/bootstrap/my-courses.js +++ /dev/null @@ -1,22 +0,0 @@ -import MyCourses from '../../../vue/components/MyCourses.vue'; -import storeConfig from '../../../vue/store/MyCoursesStore.js'; - -STUDIP.domReady(async () => { - if ($('.my-courses-vue-app').length === 0) { - return; - } - - const { createApp, store } = await STUDIP.Vue.load(); - - store.registerModule('mycourses', storeConfig); - - store.commit('mycourses/setCourses', window.STUDIP.MyCoursesData['courses']); - store.commit('mycourses/setGroups', window.STUDIP.MyCoursesData['groups']); - store.commit('mycourses/setUserId', window.STUDIP.MyCoursesData['user_id']); - store.commit('mycourses/setConfig', window.STUDIP.MyCoursesData['config']); - - const vm = createApp({ - components: { MyCourses } - }); - vm.$mount('.my-courses-vue-app'); -}); diff --git a/resources/assets/javascripts/bootstrap/responsive-navigation.js b/resources/assets/javascripts/bootstrap/responsive-navigation.js deleted file mode 100644 index ad39d2be092..00000000000 --- a/resources/assets/javascripts/bootstrap/responsive-navigation.js +++ /dev/null @@ -1,10 +0,0 @@ -import ResponsiveNavigation from '../../../vue/components/responsive/ResponsiveNavigation.vue'; - -STUDIP.domReady(() => { - STUDIP.Vue.load().then(({ createApp }) => { - createApp({ - el: '#responsive-menu', - components: { ResponsiveNavigation } - }); - }); -}); diff --git a/resources/assets/javascripts/bootstrap/treeview.js b/resources/assets/javascripts/bootstrap/treeview.js deleted file mode 100644 index d132775a335..00000000000 --- a/resources/assets/javascripts/bootstrap/treeview.js +++ /dev/null @@ -1,13 +0,0 @@ -import StudipTree from '../../../vue/components/tree/StudipTree.vue' - -STUDIP.ready(() => { - document.querySelectorAll('[data-studip-tree]:not(.vueified)').forEach(element => { - element.classList.add('vueified'); - STUDIP.Vue.load().then(({ createApp }) => { - createApp({ - el: element, - components: { StudipTree } - }) - }) - }); -}); diff --git a/resources/assets/javascripts/bootstrap/vue.js b/resources/assets/javascripts/bootstrap/vue.js index c6816a21a38..64d2492acdf 100644 --- a/resources/assets/javascripts/bootstrap/vue.js +++ b/resources/assets/javascripts/bootstrap/vue.js @@ -1,46 +1,66 @@ -/** - * The following block of code is used to automatically register your - * Vue components. It will recursively scan this directory for the Vue - * components and automatically register them with their "basename". - * - * Eg. ./components/ExampleComponent.vue -> <example-component></example-component> - */ STUDIP.ready(() => { - $('[data-vue-app]').each(function () { - if ($(this).is('[data-vue-app-created]')) { - return; - } - - const config = Object.assign({}, { - id: false, - components: [], - store: false - }, $(this).data().vueApp); - - let data = {}; - if (config.id && window.STUDIP.AppData && window.STUDIP.AppData[config.id] !== undefined) { - data = window.STUDIP.AppData[config.id]; - } + document.querySelectorAll('[data-vue-app]:not([data-vue-app-created])').forEach((node) => { + const config = Object.assign( + { + components: [], + stores: {} + }, + JSON.parse(node.dataset.vueApp) + ); let components = {}; config.components.forEach(component => { - components[component] = () => import(`../../../vue/components/${component}.vue`); + const name = component.split('/').reverse()[0]; + components[name] = () => import(`../../../vue/components/${component}.vue`); }); STUDIP.Vue.load().then(async ({createApp, store}) => { - if (config.store) { - const storeConfig = await import(`../../../vue/store/${config.store}.js`); + for (const [index, name] of Object.entries(config.stores)) { + import(`../../../vue/store/${name}.js`).then(storeConfig => { + store.registerModule(index, storeConfig.default); - store.registerModule(config.id, storeConfig.default, {root: true}); + const dataElement = document.getElementById(`vue-store-data-${index}`); + if (dataElement) { + const data = JSON.parse(dataElement.innerText); + Object.keys(data).forEach(command => { + store.commit(`${index}/${command}`, data[command]); + }); - Object.keys(data).forEach(command => { - store.commit(`${config.id}/${command}`, data[command]); + dataElement.remove(); + } }); } + createApp({ + components, + store, - createApp({components, data}).$mount(this); + beforeCreate() { + STUDIP.Vue.emit('VueAppWillCreate', this); + }, + created() { + STUDIP.Vue.emit('VueAppDidCreate', this); + }, + beforeMount() { + STUDIP.Vue.emit('VueAppWillMount', this); + }, + mounted() { + STUDIP.Vue.emit('VueAppDidMount', this); + }, + beforeUpdate() { + STUDIP.Vue.emit('VueAppWillUpdate', this); + }, + updated() { + STUDIP.Vue.emit('VueAppDidUpdate', this); + }, + beforeDestroy() { + STUDIP.Vue.emit('VueAppWillDestroy', this); + }, + destroyed() { + STUDIP.Vue.emit('VueAppDidDestroy', this); + }, + }).$mount(node); }); - $(this).attr('data-vue-app-created', ''); + node.dataset.vueAppCreated = 'true'; }); }); diff --git a/resources/assets/javascripts/entry-base.js b/resources/assets/javascripts/entry-base.js index f551c6dadbb..3c2223e0255 100644 --- a/resources/assets/javascripts/entry-base.js +++ b/resources/assets/javascripts/entry-base.js @@ -16,10 +16,6 @@ import "./init.js" import "./bootstrap/responsive.js" import "./bootstrap/vue.js" -import "./bootstrap/system-notifications.js" - -import "./bootstrap/my-courses.js"; - import "./studip-ui.js" import "./bootstrap/fullscreen.js" import "./bootstrap/tfa.js" @@ -79,12 +75,8 @@ import "./bootstrap/blubber.js" import "./bootstrap/consultations.js" import "./bootstrap/scroll_to_top.js" import "./bootstrap/admin-courses.js" -import "./bootstrap/cache-admin.js" import "./bootstrap/oer.js" import "./bootstrap/courseware.js" -import "./bootstrap/contentmodules.js" -import "./bootstrap/responsive-navigation.js" -import "./bootstrap/treeview.js" import "./bootstrap/stock-images.js" import "./bootstrap/external_pages.js" diff --git a/resources/assets/javascripts/lib/admin-courses.js b/resources/assets/javascripts/lib/admin-courses.js index 23cf6dd3f33..a7bd389f881 100644 --- a/resources/assets/javascripts/lib/admin-courses.js +++ b/resources/assets/javascripts/lib/admin-courses.js @@ -1,7 +1,18 @@ const AdminCourses = { - App: null, + App: { + loadCourse(courseId) { + STUDIP.Vue.emit('AdminCourses/loadCourse', courseId); + }, + changeFilter(filters) { + STUDIP.Vue.emit('AdminCourses/changeFilter', filters); + }, + changeActionArea(area) { + STUDIP.Vue.emit('AdminCourses/changeActionArea', area); + } + }, changeFiltersDependendOnInstitute(institut_id) { - STUDIP.AdminCourses.App.changeFilter({ institut_id }); + AdminCourses.App.changeFilter({ institut_id }); + //change Studiengangteil filter $.get( STUDIP.URLHelper.getURL('dispatch.php/admin/courses/get_stdgangteil_selector/' + institut_id) diff --git a/resources/assets/javascripts/lib/studip-vue.js b/resources/assets/javascripts/lib/studip-vue.js index c7cf89a2924..6ebd59f4393 100644 --- a/resources/assets/javascripts/lib/studip-vue.js +++ b/resources/assets/javascripts/lib/studip-vue.js @@ -1,15 +1,26 @@ -const load = async function () { - return STUDIP.loadChunk('vue'); -}; +class Vue +{ + static async load() + { + return STUDIP.loadChunk('vue'); + } -const on = async function (...args) { - const { eventBus } = await load(); - eventBus.on(...args); -}; + static async on(...args) + { + const { eventBus } = await this.load(); + eventBus.on(...args); + } -const emit = async function (...args) { - const { eventBus } = await load(); - eventBus.emit(...args); -}; + static async off(...args) { + const { eventBus } = await this.load(); + eventBus.off(...args); +} -export default { load, on, emit }; + static async emit(...args) + { + const { eventBus } = await this.load(); + eventBus.emit(...args); + } +} + +export default Vue; diff --git a/resources/vue/components/AdminCourses.vue b/resources/vue/components/AdminCourses.vue index d24a9004281..2571396fde8 100644 --- a/resources/vue/components/AdminCourses.vue +++ b/resources/vue/components/AdminCourses.vue @@ -1,106 +1,109 @@ <template> - <table class="default"> - <caption> - {{ $gettext('Veranstaltungen') }} - <span class="actions" v-if="isLoading"> - <img :src="loadingIndicator" width="20" height="20" :title="$gettext('Daten werden geladen')"> - </span> - <span class="actions" v-else-if="coursesCount > 0"> - {{ coursesCount + ' ' + $gettext('Veranstaltungen') }} - </span> - </caption> - <thead> - <tr class="sortable"> - <th v-if="showComplete" :class="sort.by === 'completion' ? 'sort' + sort.direction.toLowerCase() : ''"> - <a - @click.prevent="changeSort('completion')" - class="course-completion" - :title="$gettext('Bearbeitungsstatus')" - > - {{ $gettext('Bearbeitungsstatus') }} - </a> - </th> - <th v-for="activeField in sortedActivatedFields" :key="`field-${activeField}`" :class="sort.by === activeField ? 'sort' + sort.direction.toLowerCase() : ''"> - <a href="#" - @click.prevent="changeSort(activeField)" - :title="sort.by === activeField && sort.direction === 'ASC' ? $gettextInterpolate('Sortiert aufsteigend nach %{field}', {field: fields[activeField]}, true) : (sort.by === activeField && sort.direction === 'DESC' ? $gettextInterpolate('Sortiert absteigend nach %{ field } ', { field: fields[activeField]}, true) : $gettextInterpolate('Sortieren nach %{ field }', { field: fields[activeField]}, true))" - v-if="!unsortableFields.includes(activeField)" - > - {{ fields[activeField] }} - </a> - <template v-else> - {{ fields[activeField] }} - </template> - </th> - <th class="actions"> - {{ $gettext('Aktion') }} - <studip-action-menu class="filter" :title="$gettext('Darstellungsfilter')" :items="availableFields" @toggleActiveField="toggleActiveField"></studip-action-menu> - </th> - </tr> - <tr v-if="buttons.top"> - <th v-html="buttons.top" style="text-align: right" :colspan="colspan"></th> - </tr> - </thead> - <tbody :class="{ loading: isLoading }"> - <tr v-for="course in sortedCourses" - :key="course.id" - :class="course.id === currentLine ? 'selected' : ''" - @click="currentLine = course.id"> - <td v-if="showComplete"> - <button :href="getURL('dispatch.php/admin/courses/toggle_complete/' + course.id)" - class="course-completion undecorated" - :data-course-completion="course.completion" - :title="(course.completion > 0 ? (course.completion == 1 ? $gettext('Veranstaltung in Bearbeitung.') : $gettext('Veranstaltung komplett.')) : $gettext('Veranstaltung neu.')) + ' ' + $gettext('Klicken zum Ändern des Status.')" - @click.prevent="toggleCompletionState(course.id)"> - {{ $gettext('Bearbeitungsstatus ändern') }} - </button> - </td> - <td v-for="active_field in sortedActivatedFields" :key="active_field"> - <div v-html="course[active_field]"></div> - <a v-if="active_field === 'name' && getChildren(course).length > 0" - @click.prevent="toggleOpenChildren(course.id)" - href=""> - <studip-icon :shape="open_children.indexOf(course.id) === -1 ? 'add' : 'remove'" class="text-bottom"></studip-icon> - {{ $gettextInterpolate( - $gettext('%{ n } Unterveranstaltungen'), - { n: getChildren(course).length } - ) }} - </a> - </td> - <td class="actions" v-html="course.action"> - </td> - </tr> - <tr v-if="coursesCount === 0 && coursesLoaded"> - <td :colspan="colspan"> - {{ $gettext('Keine Ergebnisse') }} - </td> - </tr> - <tr v-if="coursesCount > 0 && sortedCourses.length === 0"> - <td :colspan="colspan"> - {{ - $gettextInterpolate( - $gettext(`%{ n } Veranstaltungen entsprechen Ihrem Filter. Schränken Sie nach Möglichkeit die Filter weiter ein.`), - { n: coursesCount } - ) - }} - <a href="" @click.prevent="loadCourses({withoutLimit: true});"> - {{ $gettext('Alle anzeigen') }} - </a> - </td> - </tr> - <tr v-if="!coursesLoaded"> - <td :colspan="colspan"> - {{ $gettext('Daten werden geladen ...') }} - </td> - </tr> - </tbody> - <tfoot v-if="buttons.bottom"> - <tr> - <td v-html="buttons.bottom" style="text-align: right" :colspan="colspan"></td> - </tr> - </tfoot> - </table> + <form method="post"> + <input type="hidden" :name="csrf.name" :value="csrf.value"> + <table class="default course-admin"> + <caption> + {{ $gettext('Veranstaltungen') }} + <span class="actions" v-if="isLoading"> + <img :src="loadingIndicator" width="20" height="20" :title="$gettext('Daten werden geladen')"> + </span> + <span class="actions" v-else-if="coursesCount > 0"> + {{ coursesCount + ' ' + $gettext('Veranstaltungen') }} + </span> + </caption> + <thead> + <tr class="sortable"> + <th v-if="showComplete" :class="sort.by === 'completion' ? 'sort' + sort.direction.toLowerCase() : ''"> + <a + @click.prevent="changeSort('completion')" + class="course-completion" + :title="$gettext('Bearbeitungsstatus')" + > + {{ $gettext('Bearbeitungsstatus') }} + </a> + </th> + <th v-for="activeField in sortedActivatedFields" :key="`field-${activeField}`" :class="sort.by === activeField ? 'sort' + sort.direction.toLowerCase() : ''"> + <a href="#" + @click.prevent="changeSort(activeField)" + :title="sort.by === activeField && sort.direction === 'ASC' ? $gettextInterpolate('Sortiert aufsteigend nach %{field}', {field: fields[activeField]}, true) : (sort.by === activeField && sort.direction === 'DESC' ? $gettextInterpolate('Sortiert absteigend nach %{ field } ', { field: fields[activeField]}, true) : $gettextInterpolate('Sortieren nach %{ field }', { field: fields[activeField]}, true))" + v-if="!unsortableFields.includes(activeField)" + > + {{ fields[activeField] }} + </a> + <template v-else> + {{ fields[activeField] }} + </template> + </th> + <th class="actions"> + {{ $gettext('Aktion') }} + <studip-action-menu class="filter" :title="$gettext('Darstellungsfilter')" :items="availableFields" @toggleActiveField="toggleActiveField"></studip-action-menu> + </th> + </tr> + <tr v-if="buttons.top"> + <th v-html="buttons.top" style="text-align: right" :colspan="colspan"></th> + </tr> + </thead> + <tbody :class="{ loading: isLoading }"> + <tr v-for="course in sortedCourses" + :key="course.id" + :class="course.id === currentLine ? 'selected' : ''" + @click="currentLine = course.id"> + <td v-if="showComplete"> + <button :href="getURL('dispatch.php/admin/courses/toggle_complete/' + course.id)" + class="course-completion undecorated" + :data-course-completion="course.completion" + :title="(course.completion > 0 ? (course.completion == 1 ? $gettext('Veranstaltung in Bearbeitung.') : $gettext('Veranstaltung komplett.')) : $gettext('Veranstaltung neu.')) + ' ' + $gettext('Klicken zum Ändern des Status.')" + @click.prevent="toggleCompletionState(course.id)"> + {{ $gettext('Bearbeitungsstatus ändern') }} + </button> + </td> + <td v-for="active_field in sortedActivatedFields" :key="active_field"> + <div v-html="course[active_field]"></div> + <a v-if="active_field === 'name' && getChildren(course).length > 0" + @click.prevent="toggleOpenChildren(course.id)" + href=""> + <studip-icon :shape="open_children.indexOf(course.id) === -1 ? 'add' : 'remove'" class="text-bottom"></studip-icon> + {{ $gettextInterpolate( + $gettext('%{ n } Unterveranstaltungen'), + { n: getChildren(course).length } + ) }} + </a> + </td> + <td class="actions" v-html="course.action"> + </td> + </tr> + <tr v-if="coursesCount === 0 && coursesLoaded"> + <td :colspan="colspan"> + {{ $gettext('Keine Ergebnisse') }} + </td> + </tr> + <tr v-if="coursesCount > 0 && sortedCourses.length === 0"> + <td :colspan="colspan"> + {{ + $gettextInterpolate( + $gettext(`%{ n } Veranstaltungen entsprechen Ihrem Filter. Schränken Sie nach Möglichkeit die Filter weiter ein.`), + { n: coursesCount } + ) + }} + <a href="" @click.prevent="loadCourses({withoutLimit: true});"> + {{ $gettext('Alle anzeigen') }} + </a> + </td> + </tr> + <tr v-if="!coursesLoaded"> + <td :colspan="colspan"> + {{ $gettext('Daten werden geladen ...') }} + </td> + </tr> + </tbody> + <tfoot v-if="buttons.bottom"> + <tr> + <td v-html="buttons.bottom" style="text-align: right" :colspan="colspan"></td> + </tr> + </tfoot> + </table> + </form> </template> <script> import { mapActions, mapGetters, mapState } from 'vuex'; @@ -130,6 +133,15 @@ export default { }, created() { this.loadCourses(); + + this.globalOn('AdminCourses/changeActionArea', this.changeActionArea.bind(this)); + this.globalOn('AdminCourses/changeFilter', this.changeFilter.bind(this)); + this.globalOn('AdminCourses/loadCourse', this.loadCourse.bind(this)); + }, + destroyed() { + this.globalOff('AdminCourses/changeActionArea', this.changeActionArea.bind(this)); + this.globalOff('AdminCourses/changeFilter', this.changeFilter.bind(this)); + this.globalOff('AdminCourses/loadCourse', this.loadCourse.bind(this)); }, computed: { ...mapState('admincourses', [ @@ -143,6 +155,9 @@ export default { ...mapGetters('admincourses', [ 'isLoading', ]), + csrf() { + return STUDIP.CSRF_TOKEN; + }, colspan () { let colspan = this.activatedFields.length + 1; if (this.showComplete) { @@ -284,6 +299,41 @@ export default { getURL(url, params = {}) { return STUDIP.URLHelper.getURL(url, params); }, - } + }, }; </script> +<style lang="scss"> +@import '../../assets/stylesheets/mixins.scss'; + +.course-admin { + .course-completion { + @include hide-text(); + @include square(16px); + background-repeat: no-repeat; + display: block; + } + + th .course-completion { + @include background-icon(radiobutton-checked, clickable); + } + + td .course-completion { + @include background-icon(span-empty, status-red); + + &[data-course-completion="1"] { + @include background-icon(span-2quarter, status-yellow); + } + &[data-course-completion="2"] { + @include background-icon(span-full, status-green); + } + + &.ajaxing { + background-image: url("#{$image-path}/loading-indicator.svg"); + } + } + > tbody.loading > tr > td { + opacity: 0.5; + } +} + +</style> diff --git a/resources/vue/components/CacheAdministration.vue b/resources/vue/components/CacheAdministration.vue index 5da9568d415..a7a1792357d 100644 --- a/resources/vue/components/CacheAdministration.vue +++ b/resources/vue/components/CacheAdministration.vue @@ -1,5 +1,8 @@ <template> - <form class="default" :action="actionUrl" method="post" ref="configForm"> + <StudipMessageBox v-if="!enabled" type="warning" :hide-close="true"> + {{ $gettext('Caching ist systemweit ausgeschaltet, daher kann hier nichts konfiguriert werden.') }} + </StudipMessageBox> + <form v-else class="default" :action="actionUrl" method="post" ref="configForm"> <fieldset> <legend> <translate>Cachetyp</translate> @@ -36,10 +39,12 @@ import FileCacheConfig from './FileCacheConfig.vue' import MemcachedCacheConfig from './MemcachedCacheConfig.vue' import RedisCacheConfig from './RedisCacheConfig.vue' +import StudipMessageBox from './StudipMessageBox.vue'; export default { name: 'CacheAdministration', components: { + StudipMessageBox, FileCacheConfig, MemcachedCacheConfig, RedisCacheConfig @@ -61,6 +66,10 @@ export default { props: [] }; } + }, + enabled: { + type: Boolean, + required: true, } }, data () { diff --git a/templates/header.php b/templates/header.php index 711d51785d2..1be47abd629 100644 --- a/templates/header.php +++ b/templates/header.php @@ -66,14 +66,14 @@ if ($navigation) { 'username' => $user->username, 'perm' => $GLOBALS['perm']->get_perm() ]; - ?> - <? } else { + } else { $me = ['username' => 'nobody']; } ?> - <responsive-navigation :me="<?= htmlReady(json_encode($me)) ?>" - context="<?= htmlReady(Context::get() ? Context::get()->getFullName() : '') ?>" - :navigation="<?= htmlReady(json_encode(ResponsiveHelper::getNavigationObject($_COOKIE['responsive-navigation-hash'] ?? null))) ?>" - ></responsive-navigation> + <?= Studip\VueApp::create('responsive/ResponsiveNavigation')->withProps([ + 'context' => Context::get()?->getFullName() ?? '', + 'me' => $me, + 'navigation' => ResponsiveHelper::getNavigationObject($_COOKIE['responsive-navigation-hash'] ?? null), + ]) ?> </div> <div id="site-title"> <?= htmlReady(Config::get()->UNI_NAME_CLEAN) ?> diff --git a/templates/vue-app.php b/templates/vue-app.php new file mode 100644 index 00000000000..2c34929f9bd --- /dev/null +++ b/templates/vue-app.php @@ -0,0 +1,14 @@ +<?php +/** + * @var array $attributes + * @var string $baseComponent + * @var array $props + * @var array $storeData + */ +?> +<? foreach ($storeData as $store => $data): ?> +<script type="application/json" id="vue-store-data-<?= htmlReady($store) ?>"><?= json_encode($data) ?></script> +<? endforeach; ?> +<div <?= arrayToHtmlAttributes($attributes) ?>> + <<?= strtokebabcase($baseComponent) ?> <?= arrayToHtmlAttributes($props) ?>/> +</div> -- GitLab