From 1460ee5307ad6b1dc4cd2d09a82e8bdc542f2515 Mon Sep 17 00:00:00 2001
From: Jan-Hendrik Willms <tleilax+studip@gmail.com>
Date: Tue, 10 Oct 2023 07:50:41 +0000
Subject: [PATCH] introduce interface AdminCourseWidgetPlugin and adjust core
 admin courses to it, fixes #3084

Closes #3084

Merge request studip/studip!2071
---
 app/controllers/admin/courses.php             | 137 +++++++++-------
 ...g_configurations_used_in_admin_courses.php |  73 +++++++++
 lib/classes/Widget.php                        |  18 ++-
 lib/classes/helpbar/HelpbarWidget.php         |  27 +---
 .../sidebar/AdminCourseOptionsWidget.php      | 153 ++++++++++++++++++
 .../sidebar/AttributesArrayAccessTrait.php    |  44 +++++
 lib/classes/sidebar/ButtonElement.php         |  26 +++
 lib/classes/sidebar/LinkElement.php           |  44 +----
 lib/classes/sidebar/OptionsWidget.php         | 117 +++++++-------
 lib/classes/sidebar/SelectListElement.php     |  55 +++++++
 .../core/AdminCourseWidgetPlugin.class.php    |  42 +++++
 lib/plugins/engine/PluginEngine.class.php     |   5 +-
 .../javascripts/bootstrap/admin-courses.js    |  16 ++
 resources/vue/store/AdminCoursesStore.js      |   2 +-
 templates/sidebar/list-widget.php             |   9 +-
 15 files changed, 571 insertions(+), 197 deletions(-)
 create mode 100644 db/migrations/5.4.15_add_missing_configurations_used_in_admin_courses.php
 create mode 100644 lib/classes/sidebar/AdminCourseOptionsWidget.php
 create mode 100644 lib/classes/sidebar/AttributesArrayAccessTrait.php
 create mode 100644 lib/classes/sidebar/ButtonElement.php
 create mode 100644 lib/classes/sidebar/SelectListElement.php
 create mode 100644 lib/plugins/core/AdminCourseWidgetPlugin.class.php

diff --git a/app/controllers/admin/courses.php b/app/controllers/admin/courses.php
index b3c7ec82a63..d9eced342b0 100644
--- a/app/controllers/admin/courses.php
+++ b/app/controllers/admin/courses.php
@@ -235,6 +235,17 @@ class Admin_CoursesController extends AuthenticatedController
             )->asDialog('size=auto');
             $sidebar->addWidget($export);
         }
+
+        foreach (PluginEngine::getPlugins(AdminCourseWidgetPlugin::class) as $plugin) {
+            foreach ($plugin->getWidgets() as $name => $widget) {
+                $position = $widget->getPositionInSidebar();
+                if ($position) {
+                    $sidebar->insertWidget($widget, $position, $name);
+                } else {
+                    $sidebar->addWidget($widget, $name);
+                }
+            }
+        }
     }
 
 
@@ -315,21 +326,23 @@ class Admin_CoursesController extends AuthenticatedController
                      ? $configuration->MY_INSTITUTES_DEFAULT
                      : null;
 
+        $filters = array_merge(
+            array_merge(...PluginEngine::sendMessage(AdminCourseWidgetPlugin::class, 'getFilters')),
+            $this->getDatafieldFilters(),
+            [
+                'institut_id'    => $institut_id,
+                'search'         => $configuration->ADMIN_COURSES_SEARCHTEXT,
+                'semester_id'    => $configuration->MY_COURSES_SELECTED_CYCLE,
+                'course_type'    => $configuration->MY_COURSES_TYPE_FILTER,
+                'stgteil'        => $configuration->MY_COURSES_SELECTED_STGTEIL,
+                'teacher_filter' => $configuration->ADMIN_COURSES_TEACHERFILTER,
+            ]
+        );
 
         return [
             'setActivatedFields' => $this->getFilterConfig(),
             'setActionArea' => $configuration->MY_COURSES_ACTION_AREA ?? '1',
-            'setFilter' => array_filter(array_merge(
-                $this->getDatafieldFilters(),
-                [
-                    'institut_id'    => $institut_id,
-                    'search'         => $configuration->ADMIN_COURSES_SEARCHTEXT,
-                    'semester_id'    => $configuration->MY_COURSES_SELECTED_CYCLE,
-                    'course_type'    => $configuration->MY_COURSES_TYPE_FILTER,
-                    'stgteil'        => $configuration->MY_COURSES_SELECTED_STGTEIL,
-                    'teacher_filter' => $configuration->ADMIN_COURSES_TEACHERFILTER,
-                ]
-            )),
+            'setFilter' => array_filter($filters),
         ];
     }
 
@@ -357,59 +370,14 @@ class Admin_CoursesController extends AuthenticatedController
 
     public function search_action()
     {
-        $activeSidebarElements = $this->getActiveElements();
-        if (Request::get('search')) {
-            $GLOBALS['user']->cfg->store('ADMIN_COURSES_SEARCHTEXT', Request::get('search'));
-        } else {
-            $GLOBALS['user']->cfg->delete('ADMIN_COURSES_SEARCHTEXT');
-        }
-        if (Request::option('institut_id') && Request::option('institut_id') !== 'all') {
-            $GLOBALS['user']->cfg->store('MY_INSTITUTES_DEFAULT', Request::option('institut_id'));
-        } else {
-            $GLOBALS['user']->cfg->delete('MY_INSTITUTES_DEFAULT');
-        }
-
-        if (Request::option('semester_id')) {
-            $GLOBALS['user']->cfg->store('MY_COURSES_SELECTED_CYCLE', Request::option('semester_id'));
-        } else {
-            $GLOBALS['user']->cfg->delete('MY_COURSES_SELECTED_CYCLE');
-        }
-
-        if (Request::option('course_type') && Request::option('course_type') !== 'all') {
-            $GLOBALS['user']->cfg->store('MY_COURSES_TYPE_FILTER', Request::option('course_type'));
-        } else {
-            $GLOBALS['user']->cfg->delete('MY_COURSES_TYPE_FILTER');
-        }
-
-        if (Request::option('stgteil')) {
-            $GLOBALS['user']->cfg->store('MY_COURSES_SELECTED_STGTEIL', Request::option('stgteil'));
-        } else {
-            $GLOBALS['user']->cfg->delete('MY_COURSES_SELECTED_STGTEIL');
-        }
-
-        if (Request::option('teacher_filter')) {
-            $GLOBALS['user']->cfg->store('ADMIN_COURSES_TEACHERFILTER', Request::option('teacher_filter'));
-        } else {
-            $GLOBALS['user']->cfg->delete('ADMIN_COURSES_TEACHERFILTER');
-        }
-
-        $datafields_filters = $GLOBALS['user']->cfg->ADMIN_COURSES_DATAFIELDS_FILTERS;
-        foreach (DataField::getDataFields('sem') as $datafield) {
-            if (
-                Request::get('df_'.$datafield->getId())
-                && in_array($datafield->getId(), $activeSidebarElements['datafields'])
-            ) {
-                $datafields_filters[$datafield->getId()] = Request::get('df_'.$datafield->getId());
-            } else {
-                unset($datafields_filters[$datafield->getId()]);
-            }
-        }
-        $GLOBALS['user']->cfg->store('ADMIN_COURSES_DATAFIELDS_FILTERS', $datafields_filters);
+        $this->processFilters();
 
         $filter = AdminCourseFilter::get();
         if (Request::option('course_id')) { //we have only one course and want to see if that course is part of the result set
             $filter->query->where('course_id', 'seminare.Seminar_id = :course_id', ['course_id' => Request::option('course_id')]);
         }
+        PluginEngine::sendMessage(AdminCourseWidgetPlugin::class, 'applyFilters', $filter);
+
         $count = $filter->countCourses();
         if ($count > $this->max_show_courses && !Request::submitted('without_limit')) {
             $this->render_json([
@@ -526,6 +494,57 @@ class Admin_CoursesController extends AuthenticatedController
          $this->render_json($data);
     }
 
+    private function processFilters(): void
+    {
+        $filters = Request::getArray('filters');
+        $config = User::findCurrent()->getConfiguration();
+
+        // Simple filters
+        $mapping = [
+            'search'         => 'ADMIN_COURSES_SEARCHTEXT',
+            'semester_id'    => 'MY_COURSES_SELECTED_CYCLE',
+            'stgteil'        => 'MY_COURSES_SELECTED_STGTEIL',
+            'teacher_filter' => 'ADMIN_COURSES_TEACHERFILTER',
+            'course_type'    => 'MY_COURSES_TYPE_FILTER',
+            'institut_id'    => 'MY_INSTITUTES_DEFAULT',
+        ];
+
+        foreach ($mapping as $key => $field) {
+            if (isset($filters[$key])) {
+                $config->store($field, $filters[$key]);
+            }
+
+            unset($filters[$key]);
+        }
+
+        // Datafield filters
+        $activeSidebarElements = $this->getActiveElements();
+
+        $datafields_filters = $GLOBALS['user']->cfg->ADMIN_COURSES_DATAFIELDS_FILTERS;
+        foreach (DataField::getDataFields('sem') as $datafield) {
+            $key = "df_{$datafield->id}";
+
+            if (
+                !empty($filters[$key])
+                && in_array($datafield->id, $activeSidebarElements['datafields'])
+            ) {
+                $datafields_filters[$datafield->id] = $filters[$key];
+            } else {
+                unset($datafields_filters[$datafield->id]);
+            }
+        }
+        $config->store('ADMIN_COURSES_DATAFIELDS_FILTERS', $datafields_filters);
+
+        // Plugin filters
+        foreach (PluginEngine::getPlugins(AdminCourseWidgetPlugin::class) as $plugin) {
+            $plugin_filters = array_intersect_key(
+                $filters,
+                $plugin->getFilters()
+            );
+            $plugin->setFilters($plugin_filters);
+        }
+    }
+
     protected function getCourseData(Course $course, $activated_fields)
     {
         $d = [
diff --git a/db/migrations/5.4.15_add_missing_configurations_used_in_admin_courses.php b/db/migrations/5.4.15_add_missing_configurations_used_in_admin_courses.php
new file mode 100644
index 00000000000..64d26d55e6a
--- /dev/null
+++ b/db/migrations/5.4.15_add_missing_configurations_used_in_admin_courses.php
@@ -0,0 +1,73 @@
+<?php
+
+/**
+ * @see https://gitlab.studip.de/studip/studip/-/merge_requests/2071/diffs#note_84752
+ */
+final class AddMissingConfigurationsUsedInAdminCourses extends Migration
+{
+    public function description()
+    {
+        return 'Adds the missing configurations for ADMIN_COURSES_SEARCHTEXT, '
+             . 'MY_COURSES_SELECTED_CYCLE, MY_COURSES_SELECTED_STGTEIL, '
+             . 'ADMIN_COURSES_TEACHERFILTER and MY_COURSES_TYPE_FILTER.';
+    }
+
+    protected function up()
+    {
+        $query = "INSERT IGNORE INTO `config` (
+                    `field`, `value`, `type`, `range`, `section`,
+                    `mkdate`, `chdate`, `description`
+                  ) VALUES (
+                   :field, :value, :type, 'user', '',
+                   UNIX_TIMESTAMP(), UNIX_TIMESTAMP(), :description
+                  )";
+        $statement = DBManager::get()->prepare($query);
+
+        $statement->execute([
+            ':field' => 'ADMIN_COURSES_SEARCHTEXT',
+            ':value' => '',
+            ':type'  => 'string',
+            ':description' => 'Speichert den auf der Veranstaltungsübersicht für Admins eingegebenen Suchtext',
+        ]);
+
+        $statement->execute([
+            ':field' => 'MY_COURSES_SELECTED_CYCLE',
+            ':value' => '',
+            ':type'  => 'string',
+            ':description' => 'Das auf der Veranstaltungsübersicht für Admins gewählte Semester',
+        ]);
+
+        $statement->execute([
+            ':field' => 'MY_COURSES_SELECTED_STGTEIL',
+            ':value' => '',
+            ':type'  => 'string',
+            ':description' => 'Der auf der Veranstaltungsübersicht für Admins gewählte Studiengangsteil',
+        ]);
+
+        $statement->execute([
+            ':field' => 'ADMIN_COURSES_TEACHERFILTER',
+            ':value' => '',
+            ':type'  => 'string',
+            ':description' => 'Der auf der Veranstaltungsübersicht für Admins gewählte Filter auf Lehrende',
+        ]);
+
+        $statement->execute([
+            ':field' => 'MY_COURSES_TYPE_FILTER',
+            ':value' => '',
+            ':type'  => 'string',
+            ':description' => 'Der auf der Veranstaltungsübersicht für Admins gewählte Filter auf Veranstaltungstypen',
+        ]);
+    }
+
+    protected function down()
+    {
+        $query = "DELETE FROM `config` WHERE `field` = :field";
+        $statement = DBManager::get()->prepare($query);
+
+        $statement->execute([':field' => 'ADMIN_COURSES_SEARCHTEXT']);
+        $statement->execute([':field' => 'MY_COURSES_SELECTED_CYCLE']);
+        $statement->execute([':field' => 'MY_COURSES_SELECTED_STGTEIL']);
+        $statement->execute([':field' => 'ADMIN_COURSES_TEACHERFILTER']);
+        $statement->execute([':field' => 'MY_COURSES_TYPE_FILTER']);
+    }
+}
diff --git a/lib/classes/Widget.php b/lib/classes/Widget.php
index f2dd3f06029..3ccdf56b671 100644
--- a/lib/classes/Widget.php
+++ b/lib/classes/Widget.php
@@ -41,25 +41,31 @@ class Widget
     /**
      * Add an element to the widget.
      *
-     * @param WidgetElement $element The actual element
-     * @param String        $index   Index/name of the element
+     * @template E of WidgetElement
+     * @param E $element The actual element
+     * @param String $index   Index/name of the element
+     * @return E
      */
-    public function addElement(WidgetElement $element, $index = null)
+    public function addElement(WidgetElement $element, $index = null): WidgetElement
     {
         $index = $index ?: $this->guessIndex($element);
 
         $this->elements[$index] = $element;
+
+        return $element;
     }
 
     /**
      * Insert an element before a specific other element or at the end of the
      * list if the specified position is invalid.
      *
-     * @param WidgetElement $element      The actual element
+     * @template E of WidgetElement
+     * @param E $element The actual element
      * @param String        $before_index Insert element before this element.
      * @param String        $index        Index/name of the element
+     * @return E
      */
-    public function insertElement(WidgetElement $element, $before_index, $index = null)
+    public function insertElement(WidgetElement $element, $before_index, $index = null): WidgetElement
     {
         $index = $index ?: $this->guessIndex($element);
 
@@ -79,6 +85,8 @@ class Widget
         }
 
         $this->elements = $elements;
+
+        return $element;
     }
 
     /**
diff --git a/lib/classes/helpbar/HelpbarWidget.php b/lib/classes/helpbar/HelpbarWidget.php
index 12b3850e916..4e02b947ee3 100644
--- a/lib/classes/helpbar/HelpbarWidget.php
+++ b/lib/classes/helpbar/HelpbarWidget.php
@@ -3,33 +3,8 @@ class HelpbarWidget extends Widget
 {
     public $icon = false;
     
-    public function addElement(WidgetElement $element, $index = null)
-    {
-        parent::addElement($element, $index);
-    }
-
     public function setIcon($icon)
     {
         $this->icon = $icon;
     }
-
-    /**
-     * Renders the widget.
-     * The widget will only be rendered if it contains at least one element.
-     *
-     * @return String The THML code of the rendered sidebar widget
-     */
-    public function render($variables = [])
-    {
-        $content = '';
-
-        if ($this->hasElements()) {
-            $template = $GLOBALS['template_factory']->open($this->template);
-            $template->set_attributes($variables + $this->template_variables);
-            $template->elements = $this->elements;
-            $content = $template->render();
-        }
-        
-        return $content;
-    }
-}
\ No newline at end of file
+}
diff --git a/lib/classes/sidebar/AdminCourseOptionsWidget.php b/lib/classes/sidebar/AdminCourseOptionsWidget.php
new file mode 100644
index 00000000000..5565e2f75c7
--- /dev/null
+++ b/lib/classes/sidebar/AdminCourseOptionsWidget.php
@@ -0,0 +1,153 @@
+<?php
+/**
+ * This is a special widget class for use with the admin course page.
+ * It will connect to the Vue app and use it's method to change the filters.
+ *
+ * @author Jan-Hendrik Willms <tleilax+studip@gmail.com>
+ * @since Stud.IP 5.4
+ */
+class AdminCourseOptionsWidget extends ListWidget
+{
+    /**
+     * @var string|null
+     */
+    protected $position_in_sidebar = null;
+
+    public function __construct(string $title)
+    {
+        parent::__construct();
+
+        $this->addCSSClass('widget-options admin-courses-options');
+        $this->title = $title;
+    }
+
+    /**
+     * Adds a checkbox to the widget.
+     *
+     * @param string $label
+     * @param string $filter_name
+     * @param bool   $checked
+     * @param        $true_value
+     * @param        $false_value
+     * @param array  $attributes
+     *
+     * @return ButtonElement
+     */
+    public function addCheckbox(
+        string $label,
+        string $filter_name,
+        bool $checked = false,
+        $true_value = 1,
+        $false_value = null,
+        array $attributes = []
+    ): ButtonElement {
+        if (!isset($attributes['onclick'])) {
+            $attributes['onclick'] = implode('', [
+                sprintf(
+                    'STUDIP.AdminCourses.App.changeFilter({%s: $(this).is(".options-checked") ? %s : %s});',
+                    json_encode($filter_name),
+                    json_encode($true_value),
+                    json_encode($false_value)
+                ),
+                "return false;",
+            ]);
+        }
+
+        $attributes['class'] = trim(($attributes['class'] ?? '') . ' options-checkbox options-' . ($checked ? 'checked' : 'unchecked'));
+
+        return $this->addElement(
+            new ButtonElement($label, null, $attributes + [
+                'aria-checked' => $checked ? 'true' : 'false',
+                'role'         => 'checkbox',
+            ])
+        );
+    }
+
+    /**
+     * Adds a radio button to the widget.
+     *
+     * @param string $label
+     * @param string $filter_name
+     * @param        $value
+     * @param bool   $checked
+     * @param array  $attributes
+     *
+     * @return ButtonElement
+     */
+    public function addRadioButton(
+        string $label,
+        string $filter_name,
+        $value,
+        bool $checked = false,
+        array $attributes = []
+    ): ButtonElement {
+        if (!isset($attributes['onclick'])) {
+            $attributes['onclick'] = implode('', [
+                sprintf(
+                    'STUDIP.AdminCourses.App.changeFilter({%s: %s});',
+                    json_encode($filter_name),
+                    json_encode($value)
+                ),
+                "return false;",
+            ]);
+        }
+
+        $attributes['class'] = trim(($attributes['class'] ?? '') . ' options-radio options-' . ($checked ? 'checked' : 'unchecked'));
+
+        return $this->addElement(
+            new ButtonElement($label, null, $attributes + [
+                'aria-checked'     => $checked ? 'true' : 'false',
+                'data-filter-name' => $filter_name,
+                'role'             => 'radio',
+            ])
+        );
+    }
+
+    /**
+     * Adds a select list to the widget.
+     *
+     * @param string $label
+     * @param string $filter_name
+     * @param array  $options
+     * @param        $selected_value
+     * @param array  $attributes
+     */
+    public function addSelect(
+        string $label,
+        string $filter_name,
+        array $options,
+        $selected_value = null,
+        array $attributes = []
+    ): SelectListElement {
+        if (!isset($attributes['onchange'])) {
+            $attributes['onfocus'] = 'this.classList.remove("submit-upon-select");';
+
+            $attributes['onchange'] = sprintf(
+                'STUDIP.AdminCourses.App.changeFilter({%s: this.value});',
+                json_encode($filter_name)
+            );
+        }
+
+        return $this->addElement(
+            new SelectListElement($label, $filter_name, $options, $selected_value, $attributes)
+        );
+    }
+
+    /**
+     * Sets the position where the widget should be inserted in the sidebar.
+     */
+    public function setPositionInSidebar(?string $position): void
+    {
+        $this->position_in_sidebar = $position;
+    }
+
+    /**
+     * Returns the position where the widget should be inserted in the sidebar.
+     *
+     * @return string|null
+     */
+    public function getPositionInSidebar(): ?string
+    {
+        return $this->position_in_sidebar;
+    }
+}
diff --git a/lib/classes/sidebar/AttributesArrayAccessTrait.php b/lib/classes/sidebar/AttributesArrayAccessTrait.php
new file mode 100644
index 00000000000..7bea8348f17
--- /dev/null
+++ b/lib/classes/sidebar/AttributesArrayAccessTrait.php
@@ -0,0 +1,44 @@
+<?php
+trait AttributesArrayAccessTrait
+{
+    public $attributes = [];
+
+    /**
+     * @todo Add bool return type when Stud.IP requires PHP8 minimal
+     */
+    #[ReturnTypeWillChange]
+    public function offsetExists($offset)
+    {
+        return isset($this->attributes[$offset]);
+    }
+
+    /**
+     * @param $offset
+     * @return mixed
+     *
+     * @todo Add mixed return type when Stud.IP requires PHP8 minimal
+     */
+    #[ReturnTypeWillChange]
+    public function offsetGet($offset)
+    {
+        return $this->attributes[$offset];
+    }
+
+    /**
+     * @todo Add void return type when Stud.IP requires PHP8 minimal
+     */
+    #[ReturnTypeWillChange]
+    public function offsetSet($offset, $value)
+    {
+        $this->attributes[$offset] = $value;
+    }
+
+    /**
+     * @todo Add void return type when Stud.IP requires PHP8 minimal
+     */
+    #[ReturnTypeWillChange]
+    public function offsetUnset($offset)
+    {
+        unset($this->attributes[$offset]);
+    }
+}
diff --git a/lib/classes/sidebar/ButtonElement.php b/lib/classes/sidebar/ButtonElement.php
new file mode 100644
index 00000000000..f60fccfffbd
--- /dev/null
+++ b/lib/classes/sidebar/ButtonElement.php
@@ -0,0 +1,26 @@
+<?php
+class ButtonElement extends WidgetElement implements ArrayAccess
+{
+    use AttributesArrayAccessTrait;
+
+    public $label;
+    public $icon = null;
+
+    public function __construct(string $label, \Icon $icon = null, array $attributes = [])
+    {
+        parent::__construct();
+
+        $this->label = $label;
+        $this->icon = $icon;
+        $this->attributes = $attributes;
+    }
+
+    public function render()
+    {
+        return sprintf(
+            '<button %s>%s</button>',
+            arrayToHtmlAttributes($this->attributes),
+            htmlReady($this->label)
+        );
+    }
+}
diff --git a/lib/classes/sidebar/LinkElement.php b/lib/classes/sidebar/LinkElement.php
index fccb9fae1a0..8476c3ec448 100644
--- a/lib/classes/sidebar/LinkElement.php
+++ b/lib/classes/sidebar/LinkElement.php
@@ -5,6 +5,8 @@
  */
 class LinkElement extends WidgetElement implements ArrayAccess
 {
+    use AttributesArrayAccessTrait;
+
     /**
      * Create link by parsing a html chunk.
      *
@@ -60,7 +62,6 @@ class LinkElement extends WidgetElement implements ArrayAccess
     public $label;
     public $icon;
     public $active = false;
-    public $attributes = [];
     public $as_button = false;
 
     /**
@@ -243,45 +244,4 @@ class LinkElement extends WidgetElement implements ArrayAccess
     {
         return filter_var($url, FILTER_VALIDATE_URL) !== false;
     }
-
-    // Array access for attributes
-
-    /**
-     * @todo Add bool return type when Stud.IP requires PHP8 minimal
-     */
-    #[ReturnTypeWillChange]
-    public function offsetExists($offset)
-    {
-        return isset($this->attributes[$offset]);
-    }
-
-    /**
-     * @param $offset
-     * @return mixed
-     *
-     * @todo Add mixed return type when Stud.IP requires PHP8 minimal
-     */
-    #[ReturnTypeWillChange]
-    public function offsetGet($offset)
-    {
-        return $this->attributes[$offset];
-    }
-
-    /**
-     * @todo Add void return type when Stud.IP requires PHP8 minimal
-     */
-    #[ReturnTypeWillChange]
-    public function offsetSet($offset, $value)
-    {
-        $this->attributes[$offset] = $value;
-    }
-
-    /**
-     * @todo Add void return type when Stud.IP requires PHP8 minimal
-     */
-    #[ReturnTypeWillChange]
-    public function offsetUnset($offset)
-    {
-        unset($this->attributes[$offset]);
-    }
 }
diff --git a/lib/classes/sidebar/OptionsWidget.php b/lib/classes/sidebar/OptionsWidget.php
index 304a5ac26ca..8ef12358bb6 100644
--- a/lib/classes/sidebar/OptionsWidget.php
+++ b/lib/classes/sidebar/OptionsWidget.php
@@ -8,9 +8,9 @@ class OptionsWidget extends ListWidget
     const INDEX = 'options';
 
     /**
-     * @param String $title Optional alternative title
+     * @param string|null $title Optional alternative title
      */
-    public function __construct($title = null)
+    public function __construct(?string $title = null)
     {
         parent::__construct();
 
@@ -19,85 +19,90 @@ class OptionsWidget extends ListWidget
     }
 
     /**
-     * @param String $label
-     * @param bool   $state
-     * @param String $toggle_url     Url to execute the action
-     * @param String $toggle_url_off Optional alternative url to explicitely
-     *                               turn off the checkbox ($toggle_url will
-     *                               then act as $toggle_url_on)
-     * @param Array  $attributes  Optional additional attributes for the anchor
+     * @param string      $label
+     * @param bool        $state
+     * @param string      $toggle_url     Url to execute the action
+     * @param string|null $toggle_url_off Optional alternative url to explicitely
+     *                                    turn off the checkbox ($toggle_url will
+     *                                    then act as $toggle_url_on)
+     * @param Array       $attributes     Optional additional attributes for the anchor
+     *
+     * @return ButtonElement
      */
-    public function addCheckbox($label, $state, $toggle_url, $toggle_url_off = null, array $attributes = [])
-    {
+    public function addCheckbox(
+        string $label,
+        bool $state,
+        string $toggle_url,
+        ?string $toggle_url_off = null,
+        array $attributes = []
+    ): ButtonElement {
         // TODO: Remove this some versions after 5.0
         $toggle_url = html_entity_decode($toggle_url);
         $toggle_url_off = isset($toggle_url_off) ? html_entity_decode($toggle_url_off) : null;
 
-        $content = sprintf(
-            '<button formaction="%s" role="checkbox" aria-checked="%s" class="options-checkbox options-%s" %s>%s</button>',
-            htmlReady($state && $toggle_url_off !== null ? $toggle_url_off : $toggle_url),
-            $state ? 'true' : 'false',
-            $state ? 'checked' : 'unchecked',
-            arrayToHtmlAttributes($attributes),
-            htmlReady($label)
+        $attributes['class'] = trim(($attributes['class'] ?? '') . ' options-checkbox options-' . ($state ? 'checked' : 'unchecked'));
+
+        return $this->addElement(
+            new ButtonElement($label, null, $attributes + [
+                'aria-checked' => $state ? 'true' : 'false',
+                'formaction'   => $state && $toggle_url_off !== null ? $toggle_url_off : $toggle_url,
+                'role'         => 'checkbox',
+            ])
         );
-        $this->addElement(new WidgetElement($content));
     }
 
     /**
-     * @param String $label
-     * @param String $url
+     * Adds a radio button to the widget.
+     *
+     * @param string $label
+     * @param string $url
      * @param bool   $checked
+     * @param array $attributes
+     *
+     * @return ButtonElement
      */
-    public function addRadioButton($label, $url, $checked = false, array $attributes = [])
-    {
+    public function addRadioButton(
+        string $label,
+        string $url,
+        bool $checked = false,
+        array $attributes = []
+    ): ButtonElement {
         // TODO: Remove this some versions after 5.0
         $url = html_entity_decode($url);
 
-        $content = sprintf(
-            '<button formaction="%s" role="radio" aria-checked="%s" class="options-radio options-%s" %s>%s</button>',
-            htmlReady($url),
-            $checked ? 'true' : 'false',
-            $checked ? 'checked' : 'unchecked',
-            arrayToHtmlAttributes($attributes),
-            htmlReady($label)
+        $attributes['class'] = trim(($attributes['class'] ?? '') . ' options-radio options-' . ($checked ? 'checked' : 'unchecked'));
+
+        return $this->addElement(
+            new ButtonElement($label, null, $attributes + [
+                'aria-checked' => $checked ? 'true' : 'false',
+                'formaction'   => $url,
+                'role'         => 'radio',
+            ])
         );
-        $this->addElement(new WidgetElement($content));
     }
 
     /**
      * Adds a select element to the widget.
      *
-     * @param String $label
-     * @param String $url
-     * @param String $name            Attribute name
+     * @param string $label
+     * @param string $url
+     * @param string $name            Attribute name
      * @param array  $options         Array of associative options (value => label)
      * @param mixed  $selected_option Currently selected option
      * @param array  $attributes      Additional attributes
      */
-    public function addSelect($label, $url, $name, $options, $selected_option = false, $attributes = [])
-    {
-        $option_content = '';
+    public function addSelect(
+        string $label,
+        string $url,
+        string $name,
+        array $options,
+        $selected_option = false,
+        array $attributes = []
+    ): SelectListElement {
+        $attributes['data-formaction'] = $url;
 
-        foreach ($options as $value => $option) {
-            $selected = $value === $selected_option ? 'selected' : '';
-            $option_content .= sprintf(
-                '<option value="%s" %s>%s</option>', 
-                htmlReady($value),
-                $selected, 
-                htmlReady($option)
-            );
-        }
-
-        $content = sprintf(
-            '<select data-formaction="%s" class="sidebar-selectlist submit-upon-select" name="%s" aria-label="%s" %s>%s</select>',
-            htmlReady($url),
-            htmlReady($name),
-            htmlReady($label),
-            arrayToHtmlAttributes($attributes),
-            $option_content
+        return $this->addElement(
+            new SelectListElement($label, $name, $options, $selected_option, $attributes)
         );
-
-        $this->addElement(new WidgetElement($content));
     }
 }
diff --git a/lib/classes/sidebar/SelectListElement.php b/lib/classes/sidebar/SelectListElement.php
new file mode 100644
index 00000000000..9cbb42c4ef2
--- /dev/null
+++ b/lib/classes/sidebar/SelectListElement.php
@@ -0,0 +1,55 @@
+<?php
+class SelectListElement extends WidgetElement implements ArrayAccess
+{
+    use AttributesArrayAccessTrait;
+
+    protected $label;
+    protected $name;
+    protected $options;
+    protected $selected_option;
+
+    public function __construct(
+        string $label,
+        string $name,
+        array $options,
+        $selected_option = null,
+        array $attributes = []
+    ) {
+        $this->label = $label;
+        $this->name = $name;
+        $this->options = $options;
+        $this->selected_option = $selected_option;
+        $this->attributes = $attributes;
+    }
+
+    public function setOptions(array $options): void
+    {
+        $this->options = $options;
+    }
+
+    public function render()
+    {
+        $option_content = '';
+
+        foreach ($this->options as $value => $option) {
+            $selected = $value == $this->selected_option ? 'selected' : '';
+            $option_content .= sprintf(
+                '<option value="%s" %s>%s</option>',
+                htmlReady($value),
+                $selected,
+                htmlReady($option)
+            );
+        }
+
+        $attributes = $this->attributes;
+        $attributes['class'] = trim(($attributes['class'] ?? '') . ' sidebar-selectlist submit-upon-select');
+        $attributes['name'] = $this->name;
+        $attributes['aria-label'] = $this->label;
+
+        return sprintf(
+            '<select %s>%s</select>',
+            arrayToHtmlAttributes($attributes),
+            $option_content
+        );
+    }
+}
diff --git a/lib/plugins/core/AdminCourseWidgetPlugin.class.php b/lib/plugins/core/AdminCourseWidgetPlugin.class.php
new file mode 100644
index 00000000000..c554626b23c
--- /dev/null
+++ b/lib/plugins/core/AdminCourseWidgetPlugin.class.php
@@ -0,0 +1,42 @@
+<?php
+/**
+ * This plugin interface is used to add functionality to the sidebar of the
+ * admin courses page.
+ *
+ * @see    AdminCourseOptionsWidget
+ * @author Jan-Hendrik Willms <tleilax+studip@gmail.com>
+ * @since Stud.IP 5.4
+ */
+interface AdminCourseWidgetPlugin
+{
+    /**
+     * Returns a list of widgets for the admin courses page.
+     *
+     * @return AdminCourseOptionsWidget[]
+     */
+    public function getWidgets(): iterable;
+
+    /**
+     * Return the filter values this widget provides. Return an associative
+     * array with filter names as indices and filter values as values.
+     *
+     * @return array
+     */
+    public function getFilters(): array;
+
+    /**
+     * Apply the set filters to the AdminCourseFilter query.
+     *
+     * @param AdminCourseFilter $filter
+     */
+    public function applyFilters(AdminCourseFilter $filter): void;
+
+
+    /**
+     * Set filters from the admin course page. You will be given an associative
+     * array according to getFilters().
+     *
+     * @param array $filters
+     */
+    public function setFilters(array $filters): void;
+}
diff --git a/lib/plugins/engine/PluginEngine.class.php b/lib/plugins/engine/PluginEngine.class.php
index 1d3e5a9b6b1..ecf00cff790 100644
--- a/lib/plugins/engine/PluginEngine.class.php
+++ b/lib/plugins/engine/PluginEngine.class.php
@@ -70,9 +70,10 @@ class PluginEngine
      * returns all enabled plugins. The optional context parameter can be
      * used to get only plugins that are activated in the given context.
      *
-     * @param string $type plugin type or null (all types)
+     * @template T
+     * @param T $type plugin type or null (all types)
      * @param string $context context range id (optional)
-     * @return array all plugins of the specified type
+     * @return T[] all plugins of the specified type
      */
     public static function getPlugins ($type, $context = null)
     {
diff --git a/resources/assets/javascripts/bootstrap/admin-courses.js b/resources/assets/javascripts/bootstrap/admin-courses.js
index 06c4621ad63..74802b6ae4d 100644
--- a/resources/assets/javascripts/bootstrap/admin-courses.js
+++ b/resources/assets/javascripts/bootstrap/admin-courses.js
@@ -22,4 +22,20 @@ STUDIP.domReady(() => {
 
         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');
+
+        if ($(this).is('.options-radio')) {
+            const filterName = $(this).data('filter-name');
+            $(`button[data-filter-name="${filterName}"]`)
+                .not(this)
+                .removeClass('options-checked')
+                .addClass('options-unchecked')
+                .attr('aria-checked', 'false');
+        }
+    });
 });
diff --git a/resources/vue/store/AdminCoursesStore.js b/resources/vue/store/AdminCoursesStore.js
index 813644f1434..5b20e7013cb 100644
--- a/resources/vue/store/AdminCoursesStore.js
+++ b/resources/vue/store/AdminCoursesStore.js
@@ -81,7 +81,7 @@ export default {
             Screenreader.notify('');
 
             let params = {
-                ...state.filters,
+                filters: state.filters,
                 action: state.actionArea,
                 activated_fields: state.activatedFields,
                 without_limit: withoutLimit ? 1 : null,
diff --git a/templates/sidebar/list-widget.php b/templates/sidebar/list-widget.php
index 5c94a0ed149..4085cf6a130 100644
--- a/templates/sidebar/list-widget.php
+++ b/templates/sidebar/list-widget.php
@@ -2,12 +2,9 @@
     <?= CSRFProtection::tokenTag() ?>
     <ul class="<?= implode(' ', $css_classes) ?>" aria-label="<?= htmlReady($title) ?>">
     <? foreach ($elements as $index => $element): ?>
-        <? $icon = null ?>
-        <? if ($element instanceof LinkElement): ?>
-            <? $icon = $element->icon ?? null ?>
-            <? if ($icon && $element->isDisabled()): ?>
-                <? $icon = $icon->copyWithRole('inactive') ?>
-            <? endif ?>
+        <? $icon = $element->icon ?? null ?>
+        <? if ($icon && $element instanceof LinkElement && $element->isDisabled()): ?>
+            <? $icon = $icon->copyWithRole(Icon::ROLE_INACTIVE) ?>
         <? endif ?>
         <li id="<?= htmlReady($index) ?>"
             <?= isset($icon) ? 'style="' . $icon->asCSS() .'"' : '' ?>
-- 
GitLab