diff --git a/TracToGitlabPlugin.php b/TracToGitlabPlugin.php
index 9be2f837fab0f0d972f37e136eca0a00c86eced8..20e3e773dd0f194bcaea0f1fceaffb92b93e5cc0 100644
--- a/TracToGitlabPlugin.php
+++ b/TracToGitlabPlugin.php
@@ -78,6 +78,10 @@ final class TracToGitlabPlugin extends TracToGitlab\Plugin implements StandardPl
             'dashboard',
             new Navigation(_('Dashboard'), PluginEngine::getURL($this, [], 'dashboard'))
         );
+        $navigation->addSubnavigation(
+            'testing',
+            new Navigation(_('Testsysteme'), PluginEngine::getURL($this, [], 'testing'))
+        );
         $navigation->addSubnavigation(
             'translations',
             new Navigation(_('Übersetzungen'), PluginEngine::getURL($this, [], 'translations'))
diff --git a/assets/apps/dashboard.js b/assets/apps/dashboard.js
new file mode 100644
index 0000000000000000000000000000000000000000..2f739a582758ed9fe156ddb5a8abaaba53bf0c2c
--- /dev/null
+++ b/assets/apps/dashboard.js
@@ -0,0 +1,85 @@
+export default {
+    props: {
+        issues: Array,
+        qmLabels: Object,
+        filters: Object,
+        filterStoreUrl: String,
+    },
+    data () {
+        let currentFilters = {};
+
+        Object.values(this.qmLabels).forEach(abbr => {
+            if (currentFilters[abbr] === undefined) {
+                currentFilters[abbr] = null;
+            }
+        });
+
+        return {
+            needle: '',
+            currentFilters: { ...this.filters }
+        };
+    },
+    methods: {
+        getStateForIssueAndQmLabel(issue, qm) {
+            return issue.qm_states[qm];
+        },
+        valueMatchesNeedle(what) {
+            if (this.needle.length === 0) {
+                return false;
+            }
+
+            return what.toLowerCase().includes(this.needle.toLowerCase());
+        }
+    },
+    computed: {
+        colspan() {
+            return 8 + Object.values(this.qmLabels).length;
+        },
+        filteredIssues() {
+            let filtered = this.issues.filter(issue => {
+                for (const [key, value] of Object.entries(this.currentFilters)) {
+                    if (value === null) {
+                        continue;
+                    }
+                    if (issue.qm_states[key] !== value) {
+                        return false;
+                    }
+                }
+                return true;
+            });
+
+            if (this.needle.length > 0) {
+                filtered = filtered.filter(issue => {
+                    const ciNeedle = this.needle.toLowerCase();
+                    return issue.iid.toString().includes(this.needle)
+                        || issue.title.toLowerCase().includes(ciNeedle)
+                        || (issue.assignee ?? '').toLowerCase().includes(ciNeedle)
+                        || (issue.author ?? '').toLowerCase().includes(ciNeedle)
+                        || issue.reviewers.some(reviewer => reviewer.toLowerCase().includes(ciNeedle));
+                });
+            }
+
+            return filtered;
+        }
+    },
+    watch: {
+        currentFilters: {
+            handler(current) {
+                console.log('changed filters');
+                const data = new URLSearchParams();
+
+                for (const [label, value] of Object.entries(current)) {
+                    if (value !== null) {
+                        data.append(`filters[${label}]`, value);
+                    }
+                }
+
+                fetch(this.filterStoreUrl, {
+                    method: 'POST',
+                    body: data
+                });
+            },
+            deep: true
+        }
+    },
+};
diff --git a/assets/apps/testing.js b/assets/apps/testing.js
new file mode 100644
index 0000000000000000000000000000000000000000..e66e7f02eceb9810057bd31b5e5bd209e1457f53
--- /dev/null
+++ b/assets/apps/testing.js
@@ -0,0 +1,8 @@
+export default {
+    props: {
+        issues: Array
+    },
+    created () {
+        console.log('created', this.$el);
+    }
+};
diff --git a/assets/apps/trac-migrate.js b/assets/apps/trac-migrate.js
new file mode 100644
index 0000000000000000000000000000000000000000..51a0fb5b841a72531824f9a7cf07fe947853c20d
--- /dev/null
+++ b/assets/apps/trac-migrate.js
@@ -0,0 +1,53 @@
+let searchTimeout = null;
+let lastRequestController = null;
+
+export default {
+   props: {
+        source: String
+    },
+    data () {
+        return {
+            needle: '',
+            results: false,
+            selectedTicket: false,
+        };
+    },
+    mounted () {
+        source = this.$el.dataset.source;
+    },
+    methods: {
+        searchTickets (event) {
+            const needle = this.needle.trim();
+            if (needle.trim().length < 3) {
+                return;
+            }
+
+            if (lastRequestController) {
+                lastRequestController.abort();
+            }
+
+            lastRequestController = new AbortController();
+            const { signal } = lastRequestController;
+
+            clearTimeout(searchTimeout);
+            searchTimeout = setTimeout(async () => {
+                const url = STUDIP.URLHelper.getURL(source, {term: needle});
+                this.results = await fetch(url, { signal }).then(response => response.json());
+            }, 300);
+        }
+    },
+    computed: {
+        orderedResults () {
+            const needle = this.needle.trim();
+            return Object.values(this.results).sort(function (a, b) {
+                if (a[0] === needle) {
+                    return 1;
+                }
+                if (b[0] === needle) {
+                    return -1;
+                }
+                return b[0] - a[0];
+            });
+        }
+    }
+};
diff --git a/assets/script.js b/assets/script.js
index f4ee60d221ad1ff71ba916615c99dd58718b39e4..3402898a77995a96fc6febe5c33d037f821cf0df 100644
--- a/assets/script.js
+++ b/assets/script.js
@@ -1,148 +1,23 @@
 (function ($, STUDIP) {
     'use strict';
 
-    var searchTimeout = null;
-    var lastRequestController = null;
-
     $(document).ready(function () {
-        if (document.getElementById('trac-migrate') !== null) {
-            let source = null;
-            STUDIP.loadChunk('vue').then(function ({createApp}) {
-                createApp({
-                    data: function () {
-                        return {
-                            needle: '',
-                            results: false,
-                            selectedTicket: false,
-                        };
-                    },
-                    mounted () {
-                        source = this.$el.dataset.source;
-                    },
-                    methods: {
-                        searchTickets (event) {
-                            const needle = this.needle.trim();
-                            if (needle.trim().length < 3) {
-                                return;
-                            }
-
-                            if (lastRequestController) {
-                                lastRequestController.abort();
-                            }
-
-                            lastRequestController = new AbortController();
-                            const { signal } = lastRequestController;
-
-                            clearTimeout(searchTimeout);
-                            searchTimeout = setTimeout(async () => {
-                                const url = STUDIP.URLHelper.getURL(source, {term: needle});
-                                this.results = await fetch(url, { signal }).then(response => response.json());
-                            }, 300);
-                        }
-                    },
-                    computed: {
-                        orderedResults () {
-                            const needle = this.needle.trim();
-                            return Object.values(this.results).sort(function (a, b) {
-                                if (a[0] === needle) {
-                                    return 1;
-                                }
-                                if (b[0] === needle) {
-                                    return -1;
-                                }
-                                return b[0] - a[0];
-                            });
+        document.querySelectorAll('[data-plugin-vue-app]').forEach(node => {
+            const app = node.dataset.pluginVueApp;
+
+            STUDIP.loadChunk('vue').then(({Vue, createApp}) => {
+                import(`./apps/${app}.js`).then(({default: config}) => {
+                    config.propsData = {};
+                    for (const [key, value] of Object.entries(node.dataset)) {
+                        if (['pluginVueApp', 'vCloak'].includes(key)) {
+                            continue;
                         }
+                        config.propsData[key] = JSON.parse(value);
                     }
-                }).$mount('#trac-migrate');
-            });
-        } else if (document.getElementById('dashboard') !== null) {
-            const dashboard = document.getElementById('dashboard');
-            const issues = JSON.parse(dashboard.dataset.issues);
-            const qmLabels = JSON.parse(dashboard.dataset.qmLabels);
-            const filters = JSON.parse(dashboard.dataset.filters) || {};
-            const filterStoreUrl = dashboard.dataset.filterStoreUrl;
-
-            Object.values(qmLabels).forEach(abbr => {
-                if (filters[abbr] === undefined) {
-                    filters[abbr] = null;
-                }
-            });
-
-            STUDIP.loadChunk('vue').then(({createApp}) => {
-                createApp({
-                    data () {
-                        return {
-                            needle: '',
-                            issues,
-                            qmLabels,
-                            filters
-                        };
-                    },
-                    methods: {
-                        getStateForIssueAndQmLabel(issue, qm) {
-                            return issue.qm_states[qm];
-                        },
-                        valueMatchesNeedle(what) {
-                            if (this.needle.length === 0) {
-                                return false;
-                            }
 
-                            return what.toLowerCase().includes(this.needle.toLowerCase());
-                        }
-                    },
-                    computed: {
-                        colspan() {
-                            return 8 + Object.values(qmLabels).length;
-                        },
-                        filteredIssues() {
-                            let filtered = this.issues.filter(issue => {
-                                for (const [key, value] of Object.entries(this.filters)) {
-                                    if (value === null) {
-                                        continue;
-                                    }
-                                    if (issue.qm_states[key] !== value) {
-                                        return false;
-                                    }
-                                }
-                                return true;
-                            });
-
-                            if (this.needle.length > 0) {
-                                filtered = filtered.filter(issue => {
-                                    const ciNeedle = this.needle.toLowerCase();
-                                    return issue.iid.toString().includes(this.needle)
-                                        || issue.title.toLowerCase().includes(ciNeedle)
-                                        || (issue.assignee ?? '').toLowerCase().includes(ciNeedle)
-                                        || (issue.author ?? '').toLowerCase().includes(ciNeedle)
-                                        || issue.reviewers.some(reviewer => reviewer.toLowerCase().includes(ciNeedle));
-                                });
-                            }
-
-                            return filtered;
-                        }
-                    },
-                    watch: {
-                        filters: {
-                            handler(current) {
-                                const data = new URLSearchParams();
-
-                                for (const [label, value] of Object.entries(current)) {
-                                    if (value !== null) {
-                                        data.append(`filters[${label}]`, value);
-                                    }
-                                }
-
-                                fetch(filterStoreUrl, {
-                                    method: 'POST',
-                                    body: data
-                                });
-                            },
-                            deep: true
-                        }
-                    }
-                }).$mount('#dashboard');
+                    createApp(config).$mount(node);
+                });
             });
-        }
+        });
     });
 }(jQuery, STUDIP));
diff --git a/controllers/convert.php b/controllers/convert.php
index d6c9278ab220f3f172c8f360b4a1b05c1fb4a844..a96af14e8f4ad3f51034d66369f02f397277bb35 100644
--- a/controllers/convert.php
+++ b/controllers/convert.php
@@ -26,6 +26,9 @@ final class ConvertController extends TracToGitlab\Controller
 
     public function index_action()
     {
+        $this->setVueAppLayout('trac-migrate', [
+            'source' => $this->quicksearchURL(),
+        ]);
     }
 
     public function convert_action()
diff --git a/controllers/dashboard.php b/controllers/dashboard.php
index 775e77c569f1747a19d5c199ed2f3de7a981fbcd..49c74ba49daf6291c9802841b5a7783fc53f8f63 100644
--- a/controllers/dashboard.php
+++ b/controllers/dashboard.php
@@ -5,6 +5,8 @@
  */
 final class DashboardController extends TracToGitlab\Controller
 {
+    use \TracToGitlab\SelectableIssuesTrait;
+
     public function before_filter(&$action, &$args)
     {
         parent::before_filter($action, $args);
@@ -18,63 +20,20 @@ final class DashboardController extends TracToGitlab\Controller
         $this->setupSidebar();
     }
 
-    private function getSelected(string $what)
-    {
-        $source = $this->user
-                ? $this->user->getConfiguration()->TRAC2GITLAB_USER_FILTERS
-                : ($_SESSION['GITLAB_DASHBOARD_SELECTION'] ?? []);
-
-        return $source[$what] ?? $this->getDefaultSelection($what);
-    }
-
-    private function setSelected(string $what, $value): void
-    {
-        if ($value === $this->getSelected($what)) {
-            return;
-        }
-
-        if ($this->user) {
-            $config = $this->user->getConfiguration();
-            $config_value = $config->TRAC2GITLAB_USER_FILTERS;
-            $config_value[$what] = $value;
-            $config->store('TRAC2GITLAB_USER_FILTERS', $config_value);
-        } else {
-            if (!isset($_SESSION['GITLAB_DASHBOARD_SELECTION'])) {
-                $_SESSION['GITLAB_DASHBOARD_SELECTION'] = [];
-            }
-            $_SESSION['GITLAB_DASHBOARD_SELECTION'][$what] = $value;
-        }
-    }
-
-    private function getDefaultSelection(string $what)
-    {
-        if ($what === 'milestone') {
-            return array_reverse(array_keys($this->getMilestonesAsSelection()))[0];
-        }
-
-        if ($what === 'types') {
-            return 'TIC,StEP';
-        }
-
-        if ($what === 'filters') {
-            return [];
-        }
-
-        return null;
-    }
-
     public function index_action()
     {
-        $this->issues  = $this->getIssues();
-        $this->mapping = $this->getQMLabelMapping();
-        $this->filters = $this->getSelected('filters');
-    }
+        $data = [
+            'issues'           => $this->getIssues(),
+            'qm-labels'        => $this->getQMLabelMapping(),
+            'filter-store-url' => $this->store_filtersURL(),
+        ];
 
-    public function select_action(string $what, string $value = null)
-    {
-        $this->setSelected($what, Request::get($what, $value));
+        $filters = $this->getSelected('filters');
+        if ($filters) {
+            $data['filters'] = $filters;
+        }
 
-        $this->redirect($this->indexURL());
+        $this->setVueAppLayout('dashboard', $data);
     }
 
     public function store_filters_action()
@@ -88,101 +47,4 @@ final class DashboardController extends TracToGitlab\Controller
         }
     }
 
-    private function setupSidebar()
-    {
-        $options = Sidebar::get()->addWidget(new OptionsWidget());
-        $options->addSelect(
-            _('Meilenstein'),
-            $this->selectURL('milestone'),
-            'milestone',
-            $this->getMilestonesAsSelection(),
-            $this->getSelected('milestone')
-        );
-
-        $options->addSelect(
-            _('Typ'),
-            $this->selectURL('types'),
-            'types',
-            [
-                'TIC,StEP' => 'StEPs und TICs',
-                'StEP'     => 'StEPs',
-                'TIC'      => 'TICs',
-            ],
-            $this->getSelected('types')
-        );
-    }
-
-    private function getIssues(): array
-    {
-        $issues = [];
-        foreach (explode(',', $this->getSelected('types')) as $type) {
-            $issues = array_merge(
-                $issues,
-                $this->gitlabPager->fetchAll(
-                    $this->gitlab->issues(),
-                    'all',
-                    [
-                        $this->gitlabProjectId,
-                        [
-                            'sort'      => 'asc',
-                            'scope'     => 'all',
-                            'milestone' => $this->getSelected('milestone'),
-                            'labels'    => $type,
-                        ]
-                    ]
-                )
-            );
-        }
-        usort($issues, function ($a, $b) {
-            return $a['id'] - $b['id'];
-        });
-
-        return array_map(function ($issue) {
-            $mrs = $this->gitlab->issues()->relatedMergeRequests($this->gitlabProjectId, $issue['iid']);
-            return new TracToGitlab\GitlabIssue($issue, $mrs);
-        }, $issues);
-    }
-
-    private function getMilestones()
-    {
-        $milestones = $this->gitlab->milestones()->all(
-            $this->gitlabProjectId
-        );
-        $milestones = array_filter($milestones, function ($milestone) {
-            return preg_match('/^Stud\.IP \d+\.\d+$/', $milestone['title']);
-        });
-        return $milestones;
-    }
-
-    private function getMilestonesAsSelection()
-    {
-        $result = [];
-        foreach ($this->getMilestones() as $milestone) {
-            $result[$milestone['title']] = $milestone['title'];
-        }
-        return $result;
-    }
-
-    private function getQMLabels()
-    {
-        $labels = $this->gitlabPager->fetchAll(
-            $this->gitlab->projects(),
-            'labels',
-            [$this->gitlabProjectId]
-        );
-        $labels = array_filter($labels, function ($label) {
-            return strpos($label['name'], 'QM::') === 0;
-        });
-        $labels = array_map(function ($label) {
-            return substr($label['name'], 4, -3);
-        }, $labels);
-        $labels = array_unique($labels);
-        $labels = array_values($labels);
-        return $labels;
-    }
-
-    private function getQMLabelMapping()
-    {
-        return TracToGitlab\GitlabIssue::QM_LABEL_MAPPING;
-    }
 }
diff --git a/controllers/testing.php b/controllers/testing.php
new file mode 100644
index 0000000000000000000000000000000000000000..7d6e4190b20a9183e01dbd9042871f943b0c136a
--- /dev/null
+++ b/controllers/testing.php
@@ -0,0 +1,25 @@
+<?php
+final class TestingController extends TracToGitlab\GitlabController
+{
+    use TracToGitlab\SelectableIssuesTrait;
+
+    public function before_filter(&$action, &$args)
+    {
+        parent::before_filter($action, $args);
+
+        $this->user = User::findCurrent();
+
+        Navigation::activateItem('/gitlab-dashboard/testing');
+        PageLayout::setTitle(_('Übersicht der Testsysteme'));
+        $this->activateNavigation('testing');
+
+        $this->setupSidebar();
+    }
+
+    public function index_action(): void
+    {
+        $this->setVueAppLayout('testing', [
+            'issues' => $this->getIssues(),
+        ]);
+    }
+}
diff --git a/lib/Controller.php b/lib/Controller.php
index 09a987d4c10cdd0bfa42c92d5c3381bf882cf05f..76e1efa64d2b9ece31af3edd293f0327e95f4915 100644
--- a/lib/Controller.php
+++ b/lib/Controller.php
@@ -28,4 +28,27 @@ abstract class Controller extends \PluginController
     {
         \Navigation::activateItem('/gitlab-dashboard/' . implode('/', $path));
     }
+
+    public function setVueAppLayout(string $name, array $data = [])
+    {
+        /** @var \Flexi_Template $layout */
+        $layout = $this->get_template_factory()->open('vue-app.php');
+        $layout->set_layout($this->layout);
+        
+        $layout->vueApp = $name;
+        $layout->data = array_combine(
+            array_map(
+                function ($key) {
+                    return 'data-' . $key;
+                },
+                array_keys($data)
+            ),
+            array_map(
+                'json_encode',
+                array_values($data)
+            )
+        );
+
+        $this->layout = $layout;
+    }
 }
diff --git a/lib/GitlabController.php b/lib/GitlabController.php
index e4ac7647a486b7f5a93a090159c9bd70a9bae6b8..e1c42248ab4d17446feeaaba2dbceeeb830f705a 100644
--- a/lib/GitlabController.php
+++ b/lib/GitlabController.php
@@ -1,8 +1,6 @@
 <?php
 namespace TracToGitlab;
 
-use Gitlab\Api\AbstractApi;
-
 abstract class GitlabController extends Controller
 {
     private $gitlabCache;
diff --git a/lib/SelectableIssuesTrait.php b/lib/SelectableIssuesTrait.php
new file mode 100644
index 0000000000000000000000000000000000000000..cef00bbea1db04869e55f97820c0de6ff4aaa027
--- /dev/null
+++ b/lib/SelectableIssuesTrait.php
@@ -0,0 +1,159 @@
+<?php
+namespace TracToGitlab;
+
+use OptionsWidget;
+use Request;
+use Sidebar;
+
+trait SelectableIssuesTrait
+{
+    private function setupSidebar()
+    {
+        $options = Sidebar::get()->addWidget(new OptionsWidget());
+        $options->addSelect(
+            _('Meilenstein'),
+            $this->selectURL('milestone'),
+            'milestone',
+            $this->getMilestonesAsSelection(),
+            $this->getSelected('milestone')
+        );
+
+        $options->addSelect(
+            _('Typ'),
+            $this->selectURL('types'),
+            'types',
+            [
+                'TIC,StEP' => 'StEPs und TICs',
+                'StEP'     => 'StEPs',
+                'TIC'      => 'TICs',
+            ],
+            $this->getSelected('types')
+        );
+    }
+
+    protected function getSelected(string $what)
+    {
+        $source = $this->user
+            ? $this->user->getConfiguration()->TRAC2GITLAB_USER_FILTERS
+            : ($_SESSION['GITLAB_DASHBOARD_SELECTION'] ?? []);
+
+        return $source[$what] ?? $this->getDefaultSelection($what);
+    }
+
+    protected function setSelected(string $what, $value): void
+    {
+        if ($value === $this->getSelected($what)) {
+            return;
+        }
+
+        if ($this->user) {
+            $config = $this->user->getConfiguration();
+            $config_value = $config->TRAC2GITLAB_USER_FILTERS;
+            $config_value[$what] = $value;
+            $config->store('TRAC2GITLAB_USER_FILTERS', $config_value);
+        } else {
+            if (!isset($_SESSION['GITLAB_DASHBOARD_SELECTION'])) {
+                $_SESSION['GITLAB_DASHBOARD_SELECTION'] = [];
+            }
+            $_SESSION['GITLAB_DASHBOARD_SELECTION'][$what] = $value;
+        }
+    }
+
+    protected function getDefaultSelection(string $what)
+    {
+        if ($what === 'milestone') {
+            return array_reverse(array_keys($this->getMilestonesAsSelection()))[0];
+        }
+
+        if ($what === 'types') {
+            return 'TIC,StEP';
+        }
+
+        if ($what === 'filters') {
+            return [];
+        }
+
+        return null;
+    }
+
+    public function select_action(string $what, string $value = null)
+    {
+        $this->setSelected($what, Request::get($what, $value));
+
+        $this->redirect($this->indexURL());
+    }
+
+    private function getIssues(): array
+    {
+        $issues = [];
+        foreach (explode(',', $this->getSelected('types')) as $type) {
+            $issues = array_merge(
+                $issues,
+                $this->gitlabPager->fetchAll(
+                    $this->gitlab->issues(),
+                    'all',
+                    [
+                        $this->gitlabProjectId,
+                        [
+                            'sort'      => 'asc',
+                            'scope'     => 'all',
+                            'milestone' => $this->getSelected('milestone'),
+                            'labels'    => $type,
+                        ]
+                    ]
+                )
+            );
+        }
+        usort($issues, function ($a, $b) {
+            return $a['id'] - $b['id'];
+        });
+
+        return array_map(function ($issue) {
+            $mrs = $this->gitlab->issues()->relatedMergeRequests($this->gitlabProjectId, $issue['iid']);
+            return new GitlabIssue($issue, $mrs);
+        }, $issues);
+    }
+
+    private function getMilestones()
+    {
+        $milestones = $this->gitlab->milestones()->all(
+            $this->gitlabProjectId
+        );
+        $milestones = array_filter($milestones, function ($milestone) {
+            return preg_match('/^Stud\.IP \d+\.\d+$/', $milestone['title']);
+        });
+        return $milestones;
+    }
+
+    private function getMilestonesAsSelection()
+    {
+        $result = [];
+        foreach ($this->getMilestones() as $milestone) {
+            $result[$milestone['title']] = $milestone['title'];
+        }
+        return $result;
+    }
+
+    private function getQMLabels()
+    {
+        $labels = $this->gitlabPager->fetchAll(
+            $this->gitlab->projects(),
+            'labels',
+            [$this->gitlabProjectId]
+        );
+        $labels = array_filter($labels, function ($label) {
+            return strpos($label['name'], 'QM::') === 0;
+        });
+        $labels = array_map(function ($label) {
+            return substr($label['name'], 4, -3);
+        }, $labels);
+        $labels = array_unique($labels);
+        $labels = array_values($labels);
+        return $labels;
+    }
+
+    private function getQMLabelMapping()
+    {
+        return GitlabIssue::QM_LABEL_MAPPING;
+    }
+}
diff --git a/views/convert/index.php b/views/convert/index.php
index c1c7d654bea6317a050cc4b85c3f4ee9fc8a000f..0ed055129c03b7c5810697767ce7810a3df3f761 100644
--- a/views/convert/index.php
+++ b/views/convert/index.php
@@ -1,4 +1,4 @@
-<form action="<?= $controller->convert() ?>" method="post" class="default" id="trac-migrate" data-source="<?= $controller->quicksearch() ?>">
+<form action="<?= $controller->convert() ?>" method="post" class="default" id="trac-migrate">
     <fieldset>
         <legend><?= _('Ticket von trac zu GitLab migrieren') ?></legend>
 
diff --git a/views/dashboard/index.php b/views/dashboard/index.php
index 478373d7230de93b64500f6e19d9dbe7bad108a4..aa5f02dee1b4e9886914133e2b24fe5abaa3f18a 100644
--- a/views/dashboard/index.php
+++ b/views/dashboard/index.php
@@ -5,128 +5,119 @@
  * @var array<int, TracToGitlab\GitlabIssue> $issues
  * @var array<string, string> $filters
  */
-
-$attributes = [
-    'data-issues'    => json_encode($issues),
-    'data-qm-labels' => json_encode($mapping),
-    'data-filters'   => json_encode($filters ?: null),
-    'data-filter-store-url' => $controller->store_filtersURL(),
-];
 ?>
-<div id="dashboard" v-cloak <?= arrayToHtmlAttributes($attributes) ?>>
-    <table class="default">
-        <caption>
-            <span v-if="filteredIssues.length !== issues.length">
-                {{ filteredIssues.length }} <?= _('von') ?>
-            </span>
-            {{ issues.length }} Issues
-        </caption>
-        <thead>
-            <tr>
-                <th><?= _('Issue') ?></th>
-                <th><?= _('Typ') ?></th>
-                <th><?= _('Status') ?></th>
-                <th><?= _('MR') ?></th>
-                <th><?= _('Titel') ?></th>
-                <th><?= _('Autor') ?></th>
-                <th><?= _('Bearbeiter') ?></th>
-                <th><?= _('Reviewer') ?></th>
-                <th v-for="(abbr, label) in qmLabels">
-                    <abbr :title="label">{{ abbr }}</abbr>
-                </th>
-            </tr>
-        </thead>
-        <tbody>
-            <tr v-if="issues.length === 0">
-                <td :colspan="colspan" style="text-align: center">
-                    <?= _('Keine Issues für diesen Meilenstein und Typ') ?>
-                </td>
-            </tr>
-            <tr v-else-if="filteredIssues.length === 0">
-                <td :colspan="colspan" style="text-align: center">
-                    <?= _('Keine Issues passen auf Ihren gewählten Filter') ?>
-                </td>
-            </tr>
-            <tr v-for="issue in filteredIssues">
-                <td :class="{'filter-match': valueMatchesNeedle(issue.iid.toString())}">
-                    <a :href="issue.web_url" target="_blank">
-                        #{{ issue.iid }}
-                    </a>
-                </td>
-                <td>{{ issue.type }}</td>
-                <td>{{ issue.closed ? 'closed' : 'open' }}</td>
-                <td v-if="!issue.merge_requests">
-                    <abbr title="<?= _('Kein MR') ?>">
-                        <?= Icon::create('decline', Icon::ROLE_STATUS_RED) ?>
-                    </abbr>
-                </td>
-                <td v-else-if="issue.merged">
-                    <abbr title="<?= _('MR bereits gemerget') ?>">
-                        <?= Icon::create('accept', Icon::ROLE_STATUS_GREEN) ?>
-                    </abbr>
-                </td>
-                <td v-else>
-                    <abbr title="<?= _('MR noch nicht gemerget') ?>">
-                        <?= Icon::create('date', Icon::ROLE_STATUS_YELLOW) ?>
-                    </abbr>
-                </td>
-                <td :class="{'filter-match': valueMatchesNeedle(issue.title)}">
-                    <a :href="issue.web_url" target="_blank">
-                        {{ issue.title }}
-                    </a>
-                </td>
-                <td :class="{'filter-match': valueMatchesNeedle(issue.author)}">
-                    {{ issue.author }}
-                </td>
-                <td :class="{'filter-match': valueMatchesNeedle(issue.assignee)}">
-                    {{ issue.assignee }}
-                </td>
-                <td :class="{'filter-match': valueMatchesNeedle(issue.reviewers.join('||'))}">
-                    <ul v-if="issue.reviewers.length > 0" class="list-csv">
-                        <li v-for="username in issue.reviewers" :key="`reviewer-${issue.iid}-${username}`">{{ username }}</li>
-                    </ul>
-                </td>
-                <td v-for="(abbr, label) in qmLabels">
-                    <studip-icon shape="accept" role="status-green" v-if="getStateForIssueAndQmLabel(issue, abbr) === '+'"></studip-icon>
-                    <studip-icon shape="question" role="status-yellow" v-else-if="getStateForIssueAndQmLabel(issue, abbr) === '?'"></studip-icon>
-                    <studip-icon shape="decline" role="status-red" v-else-if="getStateForIssueAndQmLabel(issue, abbr) === '-'"></studip-icon>
-                </td>
-            </tr>
-        </tbody>
-    </table>
+<table class="default">
+    <caption>
+        <span v-if="filteredIssues.length !== issues.length">
+            {{ filteredIssues.length }} <?= _('von') ?>
+        </span>
+        {{ issues.length }} Issues
+    </caption>
+    <thead>
+        <tr>
+            <th><?= _('Issue') ?></th>
+            <th><?= _('Typ') ?></th>
+            <th><?= _('Status') ?></th>
+            <th><?= _('MR') ?></th>
+            <th><?= _('Titel') ?></th>
+            <th><?= _('Autor') ?></th>
+            <th><?= _('Bearbeiter') ?></th>
+            <th><?= _('Reviewer') ?></th>
+            <th v-for="(abbr, label) in qmLabels">
+                <abbr :title="label">{{ abbr }}</abbr>
+            </th>
+        </tr>
+    </thead>
+    <tbody>
+        <tr v-if="issues.length === 0">
+            <td :colspan="colspan" style="text-align: center">
+                <?= _('Keine Issues für diesen Meilenstein und Typ') ?>
+            </td>
+        </tr>
+        <tr v-else-if="filteredIssues.length === 0">
+            <td :colspan="colspan" style="text-align: center">
+                <?= _('Keine Issues passen auf Ihren gewählten Filter') ?>
+            </td>
+        </tr>
+        <tr v-for="issue in filteredIssues">
+            <td :class="{'filter-match': valueMatchesNeedle(issue.iid.toString())}">
+                <a :href="issue.web_url" target="_blank">
+                    #{{ issue.iid }}
+                </a>
+            </td>
+            <td>{{ issue.type }}</td>
+            <td>{{ issue.closed ? 'closed' : 'open' }}</td>
+            <td v-if="!issue.merge_requests">
+                <abbr title="<?= _('Kein MR') ?>">
+                    <?= Icon::create('decline', Icon::ROLE_STATUS_RED) ?>
+                </abbr>
+            </td>
+            <td v-else-if="issue.merged">
+                <abbr title="<?= _('MR bereits gemerget') ?>">
+                    <?= Icon::create('accept', Icon::ROLE_STATUS_GREEN) ?>
+                </abbr>
+            </td>
+            <td v-else>
+                <abbr title="<?= _('MR noch nicht gemerget') ?>">
+                    <?= Icon::create('date', Icon::ROLE_STATUS_YELLOW) ?>
+                </abbr>
+            </td>
+            <td :class="{'filter-match': valueMatchesNeedle(issue.title)}">
+                <a :href="issue.web_url" target="_blank">
+                    {{ issue.title }}
+                </a>
+            </td>
+            <td :class="{'filter-match': valueMatchesNeedle(issue.author)}">
+                {{ issue.author }}
+            </td>
+            <td :class="{'filter-match': valueMatchesNeedle(issue.assignee)}">
+                {{ issue.assignee }}
+            </td>
+            <td :class="{'filter-match': valueMatchesNeedle(issue.reviewers.join('||'))}">
+                <ul v-if="issue.reviewers.length > 0" class="list-csv">
+                    <li v-for="username in issue.reviewers" :key="`reviewer-${issue.iid}-${username}`">{{ username }}</li>
+                </ul>
+            </td>
+            <td v-for="(abbr, label) in qmLabels">
+                <studip-icon shape="accept" role="status-green" v-if="getStateForIssueAndQmLabel(issue, abbr) === '+'"></studip-icon>
+                <studip-icon shape="question" role="status-yellow" v-else-if="getStateForIssueAndQmLabel(issue, abbr) === '?'"></studip-icon>
+                <studip-icon shape="decline" role="status-red" v-else-if="getStateForIssueAndQmLabel(issue, abbr) === '-'"></studip-icon>
+            </td>
+        </tr>
+    </tbody>
+</table>
 
-    <mounting-portal mount-to="#sidebar" name="sidebar-filter" append>
-        <div class="sidebar-widget sidebar-search">
-            <div class="sidebar-widget-header"><?= _('Suche') ?></div>
-            <div class="sidebar-widget-content">
-                <form action="#" @submit="event => event.preventDefault()">
-                    <ul class="needles">
-                        <li>
-                            <div class="input-group files-search">
-                                <input type="text" v-model.trim="needle" placeholder="<?= _('Suchwort') ?>">
-                                <button class="submit-search" type="submit" title="<?= _('Suche auführen') ?>">
-                                    <?= Icon::create('search')->asImg(20) ?>
-                                </button>
-                            </div>
-                        </li>
-                    </ul>
-                </form>
-            </div>
+<mounting-portal mount-to="#sidebar" name="sidebar-filter" append>
+    <div class="sidebar-widget sidebar-search">
+        <div class="sidebar-widget-header"><?= _('Suche') ?></div>
+        <div class="sidebar-widget-content">
+            <form action="#" @submit="event => event.preventDefault()">
+                <ul class="needles">
+                    <li>
+                        <div class="input-group files-search">
+                            <input type="text" v-model.trim="needle" placeholder="<?= _('Suchwort') ?>">
+                            <button class="submit-search" type="submit" title="<?= _('Suche auführen') ?>">
+                                <?= Icon::create('search')->asImg(20) ?>
+                            </button>
+                        </div>
+                    </li>
+                </ul>
+            </form>
         </div>
+    </div>
 
-        <div class="sidebar-widget">
-            <div class="sidebar-widget-header"><?= _('Filter') ?></div>
-            <div class="sidebar-widget-content">
-                <label v-for="(abbr, label) in qmLabels" style="display: block;">
-                    {{ label }}
-                    <select v-model="filters[abbr]" style="display: block;" class="sidebar-selectlist">
-                        <option :value="null"></option>
-                        <option>+</option>
-                        <option>?</option>
-                        <option>-</option>
-                    </select>
-                </label>
-            </div>
+    <div class="sidebar-widget">
+        <div class="sidebar-widget-header"><?= _('Filter') ?></div>
+        <div class="sidebar-widget-content">
+            <label v-for="(abbr, label) in qmLabels" style="display: block;">
+                {{ label }}
+                <select v-model="currentFilters[abbr]" style="display: block;" class="sidebar-selectlist">
+                    <option :value="null"></option>
+                    <option>+</option>
+                    <option>?</option>
+                    <option>-</option>
+                </select>
+            </label>
         </div>
-    </mounting-portal>
-</div>
+    </div>
+</mounting-portal>
diff --git a/views/testing/index.php b/views/testing/index.php
new file mode 100644
index 0000000000000000000000000000000000000000..72a303aa43b427baa24da5498c2b430277b3d111
--- /dev/null
+++ b/views/testing/index.php
@@ -0,0 +1,64 @@
+<table class="default">
+    <caption>
+        {{ issues.length }} Issues
+    </caption>
+    <thead>
+        <tr>
+            <th><?= _('Issue') ?></th>
+            <th><?= _('Typ') ?></th>
+            <th><?= _('Status') ?></th>
+            <th><?= _('MR') ?></th>
+            <th><?= _('Titel') ?></th>
+            <th><?= _('Autor') ?></th>
+            <th><?= _('Bearbeiter') ?></th>
+            <th><?= _('Reviewer') ?></th>
+        </tr>
+    </thead>
+    <tbody>
+        <tr v-if="issues.length === 0">
+            <td :colspan="colspan" style="text-align: center">
+                <?= _('Keine Issues für diesen Meilenstein und Typ') ?>
+            </td>
+        </tr>
+        <tr v-for="issue in issues">
+            <td>
+                <a :href="issue.web_url" target="_blank">
+                    #{{ issue.iid }}
+                </a>
+            </td>
+            <td>{{ issue.type }}</td>
+            <td>{{ issue.closed ? 'closed' : 'open' }}</td>
+            <td v-if="!issue.merge_requests">
+                <abbr title="<?= _('Kein MR') ?>">
+                    <?= Icon::create('decline', Icon::ROLE_STATUS_RED) ?>
+                </abbr>
+            </td>
+            <td v-else-if="issue.merged">
+                <abbr title="<?= _('MR bereits gemerget') ?>">
+                    <?= Icon::create('accept', Icon::ROLE_STATUS_GREEN) ?>
+                </abbr>
+            </td>
+            <td v-else>
+                <abbr title="<?= _('MR noch nicht gemerget') ?>">
+                    <?= Icon::create('date', Icon::ROLE_STATUS_YELLOW) ?>
+                </abbr>
+            </td>
+            <td>
+                <a :href="issue.web_url" target="_blank">
+                    {{ issue.title }}
+                </a>
+            </td>
+            <td>
+                {{ issue.author }}
+            </td>
+            <td>
+                {{ issue.assignee }}
+            </td>
+            <td>
+                <ul v-if="issue.reviewers.length > 0" class="list-csv">
+                    <li v-for="username in issue.reviewers" :key="`reviewer-${issue.iid}-${username}`">{{ username }}</li>
+                </ul>
+            </td>
+        </tr>
+    </tbody>
+</table>
diff --git a/views/vue-app.php b/views/vue-app.php
new file mode 100644
index 0000000000000000000000000000000000000000..be06be901d54446de92659171dedd06ae75949b9
--- /dev/null
+++ b/views/vue-app.php
@@ -0,0 +1,10 @@
+<?php
+/**
+ * @var string $vueApp
+ * @var array $data
+ * @var string $content_for_layout
+ */
+?>
+<div data-plugin-vue-app="<?= htmlReady($vueApp) ?>" <?= arrayToHtmlAttributes($data) ?> v-cloak>
+    <?= $content_for_layout ?>
+</div>