diff --git a/app/controllers/news.php b/app/controllers/news.php
index d9c371709b5426ae21ddc9fecc3f7db0e5316edc..2571177b047ff83eb4bad6eed59cf44ac341803d 100644
--- a/app/controllers/news.php
+++ b/app/controllers/news.php
@@ -220,8 +220,6 @@ class NewsController extends StudipController
                 $this->route .= "/{$template_id}";
         }
 
-        $msg_object = new messaging();
-
         if ($id === 'new') {
             unset($id);
             PageLayout::setTitle(_('Ankündigung erstellen'));
@@ -236,15 +234,14 @@ class NewsController extends StudipController
 
         // load news and comment data and check if user has permission to edit
         $news = new StudipNews($id);
-        if (!$news->isNew()) {
-            $this->comments = StudipComment::GetCommentsForObject($id);
-        }
 
         if (!$news->havePermission('edit') && !$news->isNew()) {
             throw new AccessDeniedException();
         }
 
-        if(!$news->isNew()){
+        if (!$news->isNew()) {
+            $this->comments = StudipComment::GetCommentsForObject($id);
+
             $this->assigned = NewsRoles::getRoles($id);
             if ($this->assigned){
                 $this->news_isvisible['news_visibility'] = true;
@@ -253,38 +250,12 @@ class NewsController extends StudipController
         }
 
         // if form sent, get news data by post vars
-        if (Request::get('news_isvisible')) {
-            // visible categories, selected areas, topic, and body are utf8 encoded when sent via ajax
-            $this->news_isvisible = json_decode(Request::get('news_isvisible'), true);
-            $this->area_options_selected = json_decode(Request::get('news_selected_areas'), true);
-            $this->area_options_selectable = json_decode(Request::get('news_selectable_areas'), true);
-
-            $news->topic          = Request::i18n('news_topic');
-            $news->body           = Request::i18n('news_body', null, function ($string) {
-                                        if (!$string) {
-                                            return $string;
-                                        }
-                                        return transformBeforeSave(Studip\Markup::purifyHtml($string));
-                                    });
-            $news->date           = $this->getTimeStamp(Request::get('news_startdate'), 'start');
-            $news->expire         = $this->getTimeStamp(Request::get('news_enddate'), 'end')
-                                  ? $this->getTimeStamp(Request::get('news_enddate'), 'end') - $news->date
-                                  : '';
-            $news->allow_comments = Request::bool('news_allow_comments', false);
-            $news->prio = Request::int('news_prio', 0);
-            $assignedroles = Request::intArray('assignedroles',false);
-
-            $this->assigned = NewsRoles::load($assignedroles);
-            if ($this->assigned){
-                $this->news_isvisible['news_visibility'] = true;
-            }
-        } elseif ($id) {
+        if ($id) {
             // if news id given check for valid id and load ranges
             if ($news->isNew()) {
                 PageLayout::postError(_('Die Ankündigung existiert nicht!'));
                 return $this->render_nothing();
             }
-            $ranges = $news->news_ranges->toArray();
         } elseif ($template_id) {
             // otherwise, load data from template
             $news_template = new StudipNews($template_id);
@@ -298,6 +269,7 @@ class NewsController extends StudipController
                 return $this->render_nothing();
             }
             $ranges = $news_template->news_ranges->toArray();
+
             // remove those ranges for which user doesn't have permission
             foreach ($ranges as $key => $news_range)
                 if (!$news->haveRangePermission('edit', $news_range['range_id'])) {
@@ -324,197 +296,110 @@ class NewsController extends StudipController
                 $ranges[] = $add_range->toArray();
             }
         }
-        // build news var for template
-        $this->news = $news;
-
-        // treat faculties and institutes as one area group (inst)
-        foreach ($ranges as $range) {
-            switch ($range['type']) {
-                case 'fak' :
-                    $this->area_options_selected['inst'][$range['range_id']] = $range['name'];
-                    break;
-                default:
-                    $this->area_options_selected[$range['type']][$range['range_id']] = (string) $range['name'];
-            }
-        }
-
-        // define search presets
-        $this->search_presets['user'] = _('Meine Profilseite');
-        if ($GLOBALS['perm']->have_perm('autor') && !$GLOBALS['perm']->have_perm('admin')) {
-            $my_sem = $this->search_area('__THIS_SEMESTER__');
-            if (is_array($my_sem['sem']) && count($my_sem['sem']))
-                $this->search_presets['sem'] = _('Meine Veranstaltungen im aktuellen Semester') . ' (' . count($my_sem['sem']) . ')';
-        }
-        if ($GLOBALS['perm']->have_perm('autor') && !$GLOBALS['perm']->have_perm('admin')) {
-            $my_nextsem = $this->search_area('__NEXT_SEMESTER__');
-            if (is_array($my_nextsem['sem']) && count($my_nextsem['sem']))
-                $this->search_presets['nextsem'] = _('Meine Veranstaltungen im nächsten Semester') . ' (' . count($my_nextsem['sem']) . ')';
-        }
-        if ($GLOBALS['perm']->have_perm('dozent') && !$GLOBALS['perm']->have_perm('root')) {
-            $my_inst = $this->search_area('__MY_INSTITUTES__');
-            if (count($my_inst))
-                $this->search_presets['inst'] = _('Meine Einrichtungen') . ' (' . count($my_inst['inst']) . ')';
-        }
-        if ($GLOBALS['perm']->have_perm('root')) {
-            $this->search_presets['global'] = $this->area_structure['global']['title'];
-        }
-
-        // perform search
-        if (Request::submitted('area_search') || Request::submitted('area_search_preset')) {
-            $this->news_isvisible['news_areas'] = true;
 
-            $this->anker = 'news_areas';
-            $this->search_term = Request::get('area_search_term');
-            if (Request::submitted('area_search')) {
-                $this->area_options_selectable = $this->search_area($this->search_term);
-            } else {
-                $this->current_search_preset = Request::option('search_preset');
-                if ($this->current_search_preset === 'inst') {
-                    $this->area_options_selectable = $my_inst;
-                } elseif ($this->current_search_preset === 'sem') {
-                    $this->area_options_selectable = $my_sem;
-                } elseif ($this->current_search_preset === 'nextsem') {
-                    $this->area_options_selectable = $my_nextsem;
-                } elseif ($this->current_search_preset === 'user') {
-                    $this->area_options_selectable = ['user' => [$GLOBALS['user']->id => get_fullname()]];
-                } elseif ($this->current_search_preset === 'global') {
-                    $this->area_options_selectable = ['global' => ['studip' => _('Stud.IP')]];
-                }
-            }
 
-            if (!count($this->area_options_selectable)) {
-                unset($this->search_term);
-            } else {
-                // already assigned areas won't be selectable
-                foreach($this->area_options_selected as $type => $data) {
-                    foreach ($data as $id => $title) {
-                        unset($this->area_options_selectable[$type][$id]);
-                    }
-                }
-            }
-        }
-        // delete comment(s)
-        if (Request::submitted('delete_marked_comments')) {
-            $this->anker = 'news_comments';
-            $this->flash['question_text'] = delete_comments(Request::optionArray('mark_comments'));
-            $this->flash['question_param'] = ['mark_comments' => Request::optionArray('mark_comments'),
-                                                   'delete_marked_comments' => 1];
-            // reload comments
-            if (!$this->flash['question_text']) {
-                $this->comments = StudipComment::GetCommentsForObject($id);
-            }
-        }
-        if ($news->havePermission('delete')) {
-            $this->comments_admin = true;
-        }
-        if (is_array($this->comments)) {
-            foreach ($this->comments as $key => $comment) {
-                if (Request::submitted('news_delete_comment_'.$comment['comment_id'])) {
-                    $this->anker = 'news_comments';
-                    $this->flash['question_text'] = delete_comments($comment['comment_id']);
-                    $this->flash['question_param'] = ['mark_comments' => [$comment['comment_id']],
-                                                           'delete_marked_comments' => 1];
-                }
-            }
-        }
-        // open / close category
-        foreach($this->news_isvisible as $category => $value) {
-            if (Request::get($category . '_js') == 'toggle') {
-                $this->news_isvisible[$category] = !$this->news_isvisible[$category];
-                $this->anker = $category;
-            }
-        }
-        // add / remove areas
-        if (Request::submitted('news_add_areas') && is_array($this->area_options_selectable)) {
-            $this->news_isvisible['news_areas'] = true;
-
-            $this->anker = 'news_areas';
-            foreach (Request::optionArray('area_options_selectable') as $range_id) {
-                foreach ($this->area_options_selectable as $type => $data) {
-                    if (isset($data[$range_id])) {
-                        $this->area_options_selected[$type][$range_id] = $data[$range_id];
-                        unset($this->area_options_selectable[$type][$range_id]);
-                    }
-                }
-            }
-        }
-        if (Request::submitted('news_remove_areas') && is_array($this->area_options_selected)) {
-            $this->news_isvisible['news_areas'] = true;
-
-            $this->anker = 'news_areas';
-            foreach (Request::optionArray('area_options_selected') as $range_id) {
-                foreach ($this->area_options_selected as $type => $data) {
-                    if (isset($data[$range_id])) {
-                        $this->area_options_selectable[$type][$range_id] = $data[$range_id];
-                        unset($this->area_options_selected[$type][$range_id]);
-                    }
-                }
-            }
-        }
-        // prepare to save news
-        if (Request::submitted('save_news') && Request::isPost()) {
-            CSRFProtection::verifySecurityToken();
-            //prepare ranges array for already assigned news_ranges
-            foreach($news->getRanges() as $range_id) {
-                $this->ranges[$range_id] = get_object_type($range_id, ['global', 'fak', 'inst', 'sem', 'user']);
-            }
-
-            // check if new ranges must be added
-            foreach ($this->area_options_selected as $type => $area_group) {
-                foreach ($area_group as $range_id => $area_title) {
-                    if (!isset($this->ranges[$range_id])) {
-                        if ($news->haveRangePermission('edit', $range_id)) {
-                            $news->addRange($range_id);
-                        } else {
-                            PageLayout::postError(sprintf(_('Sie haben keine Berechtigung zum Ändern der Bereichsverknüpfung für "%s".'), htmlReady($area_title)));
-                            $error++;
+        foreach ($ranges as $range_array) {
+            $range = new NewsRange();
+            $range['range_id'] = $range_array['range_id'];
+            $news['news_ranges'][] = $range;
+        }
+
+
+        $this->form = \Studip\Forms\Form::fromSORM(
+            $news,
+            [
+                'legend' => _('Grunddaten'),
+                'fields' => [
+                    'topic' => [
+                        'label' => _('Titel'),
+                        'required' => true
+                    ],
+                    'body' => [
+                        'label' => _('Ankündigungstext'),
+                        'required' => true,
+                        'type' => 'i18n_formatted'
+                    ],
+                    'hgroup1' => new \Studip\Forms\InputRow(
+                        [
+                            'name' => 'date',
+                            'label' => _('Beginn'),
+                            'type' => 'datetimepicker',
+                            'required' => true
+                        ],
+                        [
+                            'name' => 'expire',
+                            'label' => _('Ende'),
+                            'type' => 'datetimepicker',
+                            'value' => $news['date'] + $news['expire'],
+                            'mindate' => 'date',
+                            'mapper' => function ($value, $obj) { //hier müssen wir vom UnixTimestamp noch den Beginn abziehen:
+                                return $value - $obj['date'];
+                            },
+                            'required' => true
+                        ],
+                        [
+                            'name' => 'days',
+                            'label' => _('Laufzeit in Tagen'),
+                            'type' => 'calculator',
+                            'value' => "Math.floor((expire - date) / 86400)"
+                        ]
+                    ),
+                    'allow_comments' => [
+                        'label' => _('Kommentare zulassen'),
+                        'type' => 'checkbox'
+                    ],
+                    'user_id' => [
+                        'type' => 'no',
+                        'mapper' => function () {
+                            return User::findCurrent()->id;
                         }
-                    }
-                }
-            }
-
-            // check if assigned ranges must be removed
-            foreach ($this->ranges as $range_id => $range_type) {
-                if (($range_type === 'fak' && !isset($this->area_options_selected['inst'][$range_id])) ||
-                    ($range_type !== 'fak' && !isset($this->area_options_selected[$range_type][$range_id])))
-                {
-                    if ($news->havePermission('unassign', $range_id)) {
-                        $news->deleteRange($range_id);
-                    } else {
-                        PageLayout::postError(_('Sie haben keine Berechtigung zum Ändern der Bereichsverknüpfung.'));
-                        $error++;
-                    }
-                }
-            }
-
-            // save news
-            if ($news->validate() && !$error) {
-                if ($news->user_id !== $GLOBALS['user']->id) {
-                    $news->chdate_uid = $GLOBALS['user']->id;
-                    setTempLanguage($news->user_id);
-                    $msg = sprintf(_('Ihre Ankündigung "%s" wurde von %s verändert.'), $news->topic, get_fullname() . ' ('.get_username().')'). "\n";
-                    $msg_object->insert_message($msg, get_username($news->user_id) , "____%system%____", FALSE, FALSE, "1", FALSE, _("Systemnachricht:")." "._("Ankündigung geändert"));
-                    restoreLanguage();
-                } else {
-                    $news->chdate_uid = '';
-                }
-
-                $news->store();
-
-                if ($GLOBALS['perm']->have_perm('admin')) {
-                    NewsRoles::update($news->id, $assignedroles);
-                }
+                    ],
+                    'author' => [
+                        'type' => 'no',
+                        'mapper' => function () {
+                            return get_fullname();
+                        }
+                    ]
+                ]
+            ],
+            URLHelper::getURL('?')
+        )->addSORM(
+            $news,
+            [
+                'legend' => _('In weiteren Bereichen anzeigen'),
+                'fields' => [
+                    'news_ranges' => [
+                        'label' => _('Bereich auswählen'),
+                        'type' => 'NewsRanges',
+                        'required' => true
+                    ]
+                ]
+            ]
+        )->addSORM(
+            $news,
+            [
+                'legend' => _('Sichtbarkeitseinstellungen'),
+                'fields' => [
+                    'prio' => [
+                        'label' => _('Priorität'),
+                        'type' => 'range'
+                    ],
+                    'newsroles' => [
+                        'permission' => $GLOBALS['perm']->have_perm('admin'),
+                        'label' => _('Sichtbarkeit'),
+                        'value' => $news->news_roles->pluck('roleid'),
+                        'type' => 'multiselect',
+                        'options' => array_map(function ($r) { return $r->getRolename(); }, RolePersistence::getAllRoles()),
+                        'store' => function ($value, $input) {
+                            $news = $input->getContextObject();
+                            NewsRoles::update($news->id, $value);
+                        }
+                    ]
+                ]
+            ]
+        )->setCollapsable()
+            ->autoStore();
 
-                PageLayout::postSuccess(_('Die Ankündigung wurde gespeichert.'));
-                if (!Request::isXhr() && !$id) {
-                    // in fallback mode redirect to edit page with proper news id
-                    $this->redirect('news/edit_news/' . $news->id);
-                } elseif (Request::isXhr()) {
-                    // if in dialog mode send empty result (STUDIP.News closes dialog and initiates reload)
-                    $this->render_nothing();
-                }
-            }
-        }
         // check if user has full permission on news object
         if ($news->havePermission('delete')) {
             $this->may_delete = true;
@@ -692,8 +577,8 @@ class NewsController extends StudipController
             _('Ankündigung erstellen'),
             $this->url_for('news/edit_news/new'),
             Icon::create('news+add'),
-            ['rel' => 'get_dialog', 'target' => '_blank']
-        );
+            ['target' => '_blank']
+        )->asDialog();
         $this->sidebar->addWidget($widget);
     }
 
diff --git a/app/views/blubber/index.php b/app/views/blubber/index.php
index 0daceb688f614c384638b77279a7942f7f24d48a..7392fa466385cfb4d0b16b214152a4f82b568085 100644
--- a/app/views/blubber/index.php
+++ b/app/views/blubber/index.php
@@ -2,7 +2,7 @@
      data-active_thread="<?= htmlReady($thread->getId()) ?>"
      data-thread_data="<?= htmlReady(json_encode($thread_data ?: ['thread_posting' => []])) ?>"
      data-threads_more_down="<?= htmlReady($threads_more_down) ?>"
-     :class="waiting ? 'waiting' : ''">
+     :class="waiting ? 'waiting' : ''" v-cloak>
 
     <div id="blubber_stream_container">
         <blubber-thread :thread_data="thread_data"></blubber-thread>
diff --git a/app/views/news/_actions.php b/app/views/news/_actions.php
index 15daf3c15fd73671b0de123da37c4ac67a5dbfe5..343cd102857a91df942cbc925e963e1cae36b934 100644
--- a/app/views/news/_actions.php
+++ b/app/views/news/_actions.php
@@ -5,8 +5,8 @@
     </a>
 <? endif; ?>
 
-<span class='news_date' title="<?= ($perm ? _("Ablaufdatum") . ': ' . date('d.m.Y', $new['date'] + $new['expire']) : '') ?>">
-    <?= date('d.m.Y', $new['date']) ?>
+<span class='news_date' title="<?= ($perm ? _('Ablaufdatum') . ': ' . strftime('%x', $new['date'] + $new['expire']) : '') ?>">
+    <?= strftime('%x', $new['date']) ?>
 </span>
 
 <? if (Config::get()->NEWS_DISPLAY >= 2 || $new->havePermission('edit')): ?>
@@ -24,10 +24,10 @@ if ($new['allow_comments']) :
     <? if ($num): ?>
         <? if ($isnew): ?>
             <span class="news_comments_indicator nowrap" title="<?= sprintf(_('%s neue(r) Kommentar(e)'), $isnew) ?>">
-                <?= Icon::create("chat", "new")->asImg() ?>
+                <?= Icon::create('chat', Icon::ROLE_NEW) ?>
         <? else: ?>
             <span class="news_comments_indicator nowrap" title="<?= sprintf(_('%s Kommentare'), $num) ?>">
-                <?= Icon::create("chat", "info")->asImg() ?>
+                <?= Icon::create('chat', Icon::ROLE_INFO) ?>
         <? endif; ?>
                 <?= $num ?>
             </span>
@@ -37,17 +37,17 @@ if ($new['allow_comments']) :
 
 
 <? if ($new->havePermission('edit')): ?>
-    <a href="<?= URLHelper::getLink('dispatch.php/news/edit_news/' . $new->id) ?>" rel="get_dialog">
-        <?= Icon::create('edit', 'clickable')->asImg(); ?>
+    <a href="<?= URLHelper::getLink('dispatch.php/news/edit_news/' . $new->id) ?>" data-dialog>
+        <?= Icon::create('edit') ?>
     </a>
     <? if ($new->havePermission('unassign', $range)): ?>
         <a href=" <?= URLHelper::getLink('', ['remove_news' => $new->id, 'news_range' => $range]) ?>" >
-            <?= Icon::create('remove', 'clickable')->asImg(); ?>
+            <?= Icon::create('remove') ?>
         </a>
     <? endif; ?>
     <? if ($new->havePermission('delete')): ?>
         <a href=" <?= URLHelper::getLink('', ['delete_news' => $new->id]) ?>" >
-            <?= Icon::create('trash', 'clickable')->asImg(); ?>
+            <?= Icon::create('trash') ?>
         </a>
     <? endif; ?>
 <? endif; ?>
diff --git a/app/views/news/admin_news.php b/app/views/news/admin_news.php
index 17e28286154e27125c426429fc27017bb1a9a636..95c819f124c68911ed53d5538e58b283f8744d59 100644
--- a/app/views/news/admin_news.php
+++ b/app/views/news/admin_news.php
@@ -124,26 +124,26 @@
                             $menu->addLink(
                                 $controller->url_for('news/edit_news/' . $news['object']->news_id),
                                 _('Ankündigung bearbeiten'),
-                                Icon::create('edit', 'clickable'),
-                                ['rel' => 'get_dialog', 'target' => '_blank']
+                                Icon::create('edit'),
+                                ['data-dialog' => '', 'target' => '_blank']
                             );
                             $menu->addLink(
                                 $controller->url_for('news/edit_news/new/template/' . $news['object']->news_id),
                                 _('Kopieren, um neue Ankündigung zu erstellen'),
-                                Icon::create('news+export', 'clickable'),
-                                ['rel' => 'get_dialog', 'target' => '_blank']
+                                Icon::create('news+export'),
+                                ['data-dialog' => '1', 'target' => '_blank']
                             );
                             if ($news['object']->havePermission('unassign', $news['range_id'])) {
                                 $menu->addButton(
                                     'news_remove_' . $news['object']->news_id . '_' . $news['range_id'],
                                     _('Ankündigung aus diesem Bereich entfernen'),
-                                    Icon::create('remove', 'clickable')
+                                    Icon::create('remove')
                                 );
                             } else {
                                 $menu->addButton(
                                     'news_remove_' . $news['object']->news_id . '_' . $news['range_id'],
                                     _('Ankündigung löschen'),
-                                    Icon::create('trash', 'clickable')
+                                    Icon::create('trash')
                                 );
                             }
                             echo $menu->render();
diff --git a/app/views/news/display.php b/app/views/news/display.php
index 873a79c1e0047ae1d7033f83c3f0f186eb01bbfc..22434f9b50093f07ef3f103e481e582a2a8e2d28 100644
--- a/app/views/news/display.php
+++ b/app/views/news/display.php
@@ -8,18 +8,18 @@
         </h1>
         <nav>
         <? if ($perm): ?>
-            <a href="<?= $controller->link_for('news/edit_news/new/' . $range); ?>" rel="get_dialog">
-                <?= Icon::create('add', 'clickable')->asImg(); ?>
+            <a href="<?= $controller->link_for('news/edit_news/new/' . $range); ?>" data-dialog>
+                <?= Icon::create('add') ?>
             </a>
         <? endif; ?>
         <? if ($perm && Config::get()->NEWS_RSS_EXPORT_ENABLE): ?>
             <a data-dialog="size=auto;reload-on-close" title="<?=_('RSS-Feed konfigurieren') ?>" href="<?= $controller->link_for('news/rss_config/' . $range); ?>">
-                <?= Icon::create('rss+add')->asImg() ?>
+                <?= Icon::create('rss+add') ?>
             </a>
         <? endif; ?>
         <? if ($rss_id): ?>
             <a href="<?= URLHelper::getLink('rss.php', ['id' => $rss_id]) ?>">
-                <?= Icon::create('rss', 'clickable', ['title' => _('RSS-Feed')])->asImg() ?>
+                <?= Icon::create('rss')->asImg(['title' => _('RSS-Feed')]) ?>
             </a>
         <? endif; ?>
         </nav>
@@ -31,7 +31,7 @@
         <header>
             <h1>
                 <a href="<?= ContentBoxHelper::href($new->id, ['contentbox_type' => 'news']) ?>">
-                    <?= Icon::create('news', 'clickable')->asImg(); ?>
+                    <?= Icon::create('news') ?>
                     <?= htmlReady($new['topic']); ?>
                 </a>
             </h1>
diff --git a/app/views/news/edit_news.php b/app/views/news/edit_news.php
index 2559d065842e0111b591dc09988218424ad0ac0a..45a48d133d2504635b8f3cf84647998abc64bdc5 100644
--- a/app/views/news/edit_news.php
+++ b/app/views/news/edit_news.php
@@ -1,333 +1 @@
-<? use Studip\Button, Studip\LinkButton; ?>
-<? if(!empty($flash['question_text'])) : // TODO: This will certainly break with i18n fields ?>
-    <? $form_content = ['news_isvisible' => htmlReady(json_encode($news_isvisible)),
-              'news_selectable_areas' => htmlReady(json_encode($area_options_selectable)),
-              'news_selected_areas' => htmlReady(json_encode($area_options_selected)),
-              'news_basic_js' => '',
-              'news_comments_js' => '',
-              'news_areas_js' => '',
-              'news_allow_comments' => $news->allow_comments,
-              'news_topic' => $news->topic,
-              'news_body' => $news->body,
-              'news_startdate' => $news->date ? date('d.m.Y H:i', $news->date) : '',
-              'news_enddate' => $news->expire ? date('d.m.Y H:i', $news->date + $news->expire) : ''] ?>
-    <?= (string) QuestionBox::create(
-        htmlReady($flash['question_text']),
-        URLHelper::getURL('dispatch.php/'.$route.'#anker', array_merge($flash['question_param'], $form_content)),
-        URLHelper::getURL('dispatch.php/'.$route.'#anker', $form_content)
-    );?>
-<? endif ?>
-<form action="<?=URLHelper::getURL('dispatch.php/'.$route.'#anker')?>"
-    data-dialog="size=auto" method="POST" class="default collapsable">
-    <?=CSRFProtection::tokenTag(); ?>
-    <input type="hidden" name="news_basic_js" value="">
-    <input type="hidden" name="news_comments_js" value="">
-    <input type="hidden" name="news_areas_js" value="">
-    <input type="hidden" name="news_visibility_js" value="">
-    <input type="hidden" name="news_isvisible" value="<?=htmlReady(json_encode($news_isvisible))?>">
-    <input type="hidden" name="news_selectable_areas" value="<?=htmlReady(json_encode($area_options_selectable))?>">
-    <input type="hidden" name="news_selected_areas" value="<?=htmlReady(json_encode($area_options_selected))?>">
-
-    <? if (Request::isXhr()) : ?>
-        <? foreach (PageLayout::getMessages() as $msg) : ?>
-            <?=$msg?>
-            <? $anker = ''; ?>
-        <? endforeach ?>
-    <? endif ?>
-
-    <fieldset <?= $news_isvisible['news_basic'] ? '' : 'class="collapsed"' ?>>
-        <legend class="news_category_header" id="news_basic">
-            <?=_("Grunddaten")?>
-        </legend>
-
-        <label>
-            <span class="required">
-                <?= _("Titel") ?>
-            </span>
-            <?= I18N::input('news_topic', $news->topic, [
-                'required'   => '',
-                'class'      => 'news_topic news_prevent_submit size-l',
-                'aria-label' => _('Titel der Ankündigung'),
-            ]) ?>
-        </label>
-
-        <label>
-            <span class="required">
-                <?= _("Inhalt") ?>
-            </span>
-
-            <?= I18N::textarea('news_body', $news->body, [
-                'required'    => '',
-                'class'       => 'news_body add_toolbar wysiwyg size-l',
-                'rows'        => 6,
-                'wrap'        => 'virtual',
-                'placeholder' => _('Geben Sie hier den Ankündigungstext ein'),
-                'aria-label'  => _('Inhalt der Ankündigung'),
-            ]) ?>
-        </label>
-
-        <label class="col-2">
-            <span class="required">
-                <?= _('Veröffentlichungsdatum') ?>
-            </span>
-
-            <input type="text" class="news_date news_prevent_submit"
-                   name="news_startdate" id="news_startdate"
-                   data-datetime-picker
-                   value="<? if ($news->date) echo strftime('%d.%m.%Y %H:%M', $news->date); ?>"
-                   aria-label="<?= _('Einstelldatum') ?>" required>
-        </label>
-
-        <label class="col-2">
-            <span class="required">
-                <?= _('Ablaufdatum') ?>
-            </span>
-
-            <input type="text" class="news_date news_prevent_submit"
-                   name="news_enddate" id="news_enddate"
-                   data-datetime-picker='{">=":"#news_startdate","offset":"#news_duration"}'
-                   value="<? if ($news->expire) echo strftime('%d.%m.%Y %H:%M', $news->date + $news->expire) ?>"
-                   aria-label="<?= _('Ablaufdatum') ?>" required>
-        </label>
-
-        <label class="col-2">
-            <?= _('Laufzeit in Tagen') ?>
-
-            <input type="number" class="news_date news_prevent_submit"
-                   name="news_duration" id="news_duration"
-                   value="<?= $news->expire ? round($news->expire / (24 * 60 * 60)) : 8 ?>"
-                   aria-label="<?= _('Laufzeit') ?>"
-                   min="1">
-        </label>
-
-        <? if ($anker == 'news_comments') : ?>
-            <a name='anker'></a>
-        <? endif ?>
-        <label>
-            <input type="checkbox"
-                   id="news_allow_comments" name="news_allow_comments" value="1"
-                   <? if ($news->allow_comments) echo 'checked'; ?>>
-            <?= _('Kommentare zulassen') ?>
-        </label>
-    </fieldset>
-
-    <? if (is_array($comments) && count($comments)) : ?>
-        <fieldset <?= $news_isvisible['news_comments'] ? '' : 'class="collapsed"' ?>>
-            <legend class="news_category_header" id="news_comments">
-                <?=_("Kommentare zu dieser Ankündigung")?>
-            </legend>
-            <table class="default nohover">
-                <tbody>
-                    <? foreach ($comments as $index => $comment): ?>
-                        <?= $this->render_partial('../../templates/news/comment-box', compact('index', 'comment')) ?>
-                    <? endforeach; ?>
-
-                    <? if ($comments_admin): ?>
-                        <tfoot>
-                            <tr>
-                                <td colspan="3">
-                                    <?=Button::create(_('Markierte Kommentare löschen'), 'delete_marked_comments', ['title' => _('Markierte Kommentare löschen')]) ?>
-                                </td>
-                            </tr>
-                        </tfoot>
-                    <? endif ?>
-                </tbody>
-            </table>
-        </fieldset>
-    <? endif ?>
-
-    <fieldset <?= $news_isvisible['news_areas'] ? '' : 'class="collapsed"' ?>>
-        <legend class="news_category_header" id="news_areas">
-            <?=_('In weiteren Bereichen anzeigen')?>
-        </legend>
-
-        <? if ($anker == 'news_areas') : ?>
-            <a name='anker'></a>
-        <? endif ?>
-
-        <label class="with-action">
-            <span>
-                <?= _('Suchvorlage auswählen') ?>
-            </span>
-
-            <select name="search_preset" aria-label="<?= _('Vorauswahl bestimmter Bereiche, alternativ zur Suche') ?>"
-                    onchange="jQuery('input[name=area_search_preset]').click()">
-                <option><?=_('--- Suchvorlagen ---')?></option>
-                <? foreach($search_presets as $value => $title) : ?>
-                    <option value="<?=$value?>"<?=($this->current_search_preset == $value) ? ' selected' : '' ?>>
-                        <?=htmlReady($title)?>
-                    </option>
-                <? endforeach ?>
-            </select>
-
-            <?= Icon::create('accept')->asInput([
-                'name'           => 'area_search_preset',
-                'title'          => _('Vorauswahl anwenden'),
-                'formnovalidate' => '',
-            ]) ?>
-        </label>
-
-        <label class="with-action">
-            <span>
-                <?= _('Freitextsuche') ?>
-            </span>
-
-            <input name="area_search_term" class="news_search_term" type="text" placeholder="<?=_('Suchen')?>"
-                   aria-label="<?= _('Suchbegriff') ?>">
-            <?= Icon::create('search')->asInput([
-                'name'           => 'area_search',
-                'title'          => _('Suche starten'),
-                'formnovalidate' => '',
-            ]) ?>
-        </label>
-
-
-        <div class="news_area_selectable">
-            <label>
-                <?=_('Suchergebnis')?>
-            <select name="area_options_selectable[]" class="news_area_options" size="7" multiple
-                    aria-label="<?= _('Gefundene Bereiche, die der Ankündigung hinzugefügt werden können') ?>"
-                    ondblclick="jQuery('input[name=news_add_areas]').click()">
-            <? foreach ($area_structure as $area_key => $area_data) : ?>
-                <? if (is_array($area_options_selectable[$area_key]) && count($area_options_selectable[$area_key])) : ?>
-                    <optgroup class="news_area_title"
-                            style="background-image: url('<?= Icon::create($area_data['icon'], 'info')->asImagePath() ?>');" label="<?=htmlReady($area_data['title'])?>">
-                    <? foreach ($area_options_selectable[$area_key] as $area_option_key => $area_option_title) : ?>
-                        <option <?= StudipNews::haveRangePermission('edit', $area_option_key) ? 'value="'.$area_option_key.'"' : 'disabled'?>
-                                <?=tooltip($area_option_title);?>>
-                            <?= htmlReady(mila($area_option_title))?>
-                        </option>
-                    <? endforeach ?>
-                    </optgroup>
-                <? endif ?>
-            <? endforeach ?>
-            </select>
-            </label>
-        </div>
-        <div class="news_area_actions">
-            <br>
-            <br>
-            <br>
-            <?= Icon::create('arr_2right')->asInput([
-                'name'           => 'news_add_areas',
-                'title'          => _('In den Suchergebnissen markierte Bereiche der Ankündigung hinzufügen'),
-                'formnovalidate' => '',
-            ]) ?>
-            <br><br>
-            <?= Icon::create('arr_2left')->asInput([
-                'name'           => 'news_remove_areas',
-                'title'          => _('Bei den bereits ausgewählten Bereichen die markierten Bereiche entfernen'),
-                'formnovalidate' => '',
-            ]) ?>
-        </div>
-        <div class="news_area_selected">
-            <? foreach ($area_structure as $area_key => $area_data) : ?>
-                <? if (isset($area_options_selected[$area_key])) : ?>
-                    <? $area_count += count($area_options_selected[$area_key]) ?>
-                <? endif ?>
-            <? endforeach ?>
-            <label>
-            <div id="news_area_text">
-                <? if ($area_count == 0) : ?>
-                    <?=_('Keine Bereiche ausgewählt')?>
-                <? elseif ($area_count == 1) : ?>
-                    <?=_('1 Bereich ausgewählt')?>
-                <? else : ?>
-                    <?=sprintf(_('%s Bereiche ausgewählt'), $area_count)?>
-                <? endif ?>
-            </div>
-            <select name="area_options_selected[]" class="news_area_options" size="7" multiple
-                    aria-label="<?= _('Bereiche, in denen die Ankündigung angezeigt wird') ?>"
-                    ondblclick="jQuery('input[name=news_remove_areas]').click()">
-            <? foreach ($area_structure as $area_key => $area_data) : ?>
-                <? if (isset($area_options_selected[$area_key]) && count($area_options_selected[$area_key])) : ?>
-                    <optgroup class="news_area_title"
-                            style="background-image: url('<?= Icon::create($area_data['icon'], 'info')->asImagePath() ?>');" label="<?=htmlReady($area_data['title'])?>">
-                    <? foreach ($area_options_selected[$area_key] as $area_option_key => $area_option_title) : ?>
-                        <option <?= (StudipNews::haveRangePermission('edit', $area_option_key) OR $may_delete) ? 'value="'.$area_option_key.'"' : 'disabled'?>
-                                <?=tooltip($area_option_title);?>>
-                            <?= htmlReady(mila($area_option_title))?>
-                        </option>
-                    <? endforeach ?>
-                    </optgroup>
-                <? endif ?>
-            <? endforeach ?>
-            </select>
-            </label>
-        </div>
-    </fieldset>
-
-    <fieldset <?= $news_isvisible['news_visibility'] ? '' : 'class="collapsed"' ?>>
-        <legend class="news_visibility_header" id="news_visibility">
-            <?= _('Sichtbarkeitseinstellungen') ?>
-        </legend>
-
-        <? if ($anker == 'news_visibility') : ?>
-            <a name='anker'></a>
-        <? endif ?>
-
-        <label>
-            <?= _('Priorität') ?>
-
-            <select name="news_prio">
-                <? foreach ($priorities as $key => $label) : ?>
-                    <option value ="<?= $key ?>"<?= $news->prio == $key ? ' selected' : '' ?>><?= $label ?></option>
-                <? endforeach ?>
-            </select>
-        </label>
-        <? if ($GLOBALS['perm']->have_perm('admin')) : ?>
-            <label>
-                <?= _('Sichtbarkeit') ?>
-
-                <select id="assignedroles" name="assignedroles[]" multiple>
-                    <? if ($assigned) : ?>
-                        <? foreach ($assigned as $assignedrole) : ?>
-                            <option value="<?= $assignedrole->getRoleid() ?>" selected>
-                                <?= htmlReady($assignedrole->getRolename()) ?>
-                                <? if ($assignedrole->getSystemtype()) : ?>[<?= _('Systemrolle') ?>]<? endif ?>
-                                (<?= $rolesStats[$assignedrole->getRoleid()]['explicit'] + $rolesStats[$assignedrole->getRoleid()]['implicit'] ?>)
-                            </option>
-                        <? endforeach ?>
-                    <? endif ?>
-                    <? foreach ($roles as $role) : ?>
-                        <option value="<?= $role->getRoleid() ?>">
-                            <?= htmlReady($role->getRolename()) ?>
-                            <? if ($role->getSystemtype()) : ?>[<?= _('Systemrolle') ?>]<? endif ?>
-                            (<?= $rolesStats[$role->getRoleid()]['explicit'] + $rolesStats[$role->getRoleid()]['implicit'] ?>)
-                        </option>
-                    <? endforeach ?>
-                </select>
-            </label>
-        <? endif ?>
-    </fieldset>
-
-    <footer data-dialog-button>
-        <? if ($news->isNew()) : ?>
-            <?= Button::createAccept(_('Ankündigung erstellen'), 'save_news') ?>
-        <? else : ?>
-            <?= Button::createAccept(_('Änderungen speichern'), 'save_news') ?>
-        <? endif ?>
-        <? if (Request::isXhr()) : ?>
-            <?= LinkButton::createCancel(_('Schließen'), URLHelper::getURL(''), ['rel' => 'close_dialog']) ?>
-        <? endif ?>
-    </footer>
-</form>
-
-<script>
-    jQuery('.news_prevent_submit').keydown(function(event) {
-        if (event.which === 13) {
-            event.preventDefault();
-        }
-    });
-    jQuery('input[name=area_search_term]').keydown(function(event) {
-        if (event.which === 13) {
-            jQuery('input[name=area_search]').click();
-            event.preventDefault();
-        }
-    });
-    <? if ($GLOBALS['perm']->have_perm('admin')) : ?>
-        $("#assignedroles").select2({
-            width: '100%'
-        });
-    <? endif ?>
-</script>
+<?= $form->render() ?>
diff --git a/lib/bootstrap-autoload.php b/lib/bootstrap-autoload.php
index 05d424a623833058eac53ab45393ca9d47da9471..fa6ed7b7a1a9cc6047382ddd37adf5a2c5832534 100644
--- a/lib/bootstrap-autoload.php
+++ b/lib/bootstrap-autoload.php
@@ -23,6 +23,7 @@ StudipAutoloader::addAutoloadPath('lib/classes/admission/userfilter');
 StudipAutoloader::addAutoloadPath('lib/classes/auth_plugins');
 StudipAutoloader::addAutoloadPath('lib/classes/calendar');
 StudipAutoloader::addAutoloadPath('lib/classes/exportdocument');
+StudipAutoloader::addAutoloadPath('lib/classes/forms');
 StudipAutoloader::addAutoloadPath('lib/classes/globalsearch');
 StudipAutoloader::addAutoloadPath('lib/classes/helpbar');
 StudipAutoloader::addAutoloadPath('lib/classes/librarysearch/resultparsers');
diff --git a/lib/classes/CSRFProtection.php b/lib/classes/CSRFProtection.php
index 4a995927ef6bf9af3ff7dd3c3c23067e9608bba7..440919efe92318f144bf51f0af44dfc8933a8863 100644
--- a/lib/classes/CSRFProtection.php
+++ b/lib/classes/CSRFProtection.php
@@ -49,7 +49,7 @@ class CSRFProtection
      * This checks the request and throws an InvalidSecurityTokenException if
      * fails to verify its authenticity.
      *
-     * @throws MethodNotAllowed               The request has to be unsafe
+     * @throws MethodNotAllowedException      The request has to be unsafe
      *                                        in terms of RFC 2616.
      * @throws InvalidSecurityTokenException  The request is invalid as the
      *                                        security token does not match.
@@ -139,14 +139,19 @@ class CSRFProtection
      * <input type="hidden" name="security_token" value="012345678901234567890123456789==">
      * \endcode
      *
+     * @param array $attributes Additional attributes to be added to the input
      * @return string  the HTML snippet containing the input element
      */
-    public static function tokenTag()
+    public static function tokenTag(array $attributes = [])
     {
+        $attributes = array_merge($attributes, [
+            'name'  => self::TOKEN,
+            'value' => self::token(),
+        ]);
+
         return sprintf(
-            '<input type="hidden" name="%s" value="%s">',
-            self::TOKEN,
-            self::token()
+            '<input type="hidden" %s>',
+            arrayToHtmlAttributes($attributes)
         );
     }
 }
diff --git a/lib/classes/forms/CalculatorInput.php b/lib/classes/forms/CalculatorInput.php
new file mode 100644
index 0000000000000000000000000000000000000000..40c08c18b9798d08b374a8f57f6ad834271f589b
--- /dev/null
+++ b/lib/classes/forms/CalculatorInput.php
@@ -0,0 +1,20 @@
+<?php
+
+namespace Studip\Forms;
+
+class CalculatorInput extends Input
+{
+    public function render()
+    {
+        $template = $GLOBALS['template_factory']->open('forms/calculator_input');
+        $template->title = $this->title;
+        $template->value = $this->value;
+        $template->attributes = arrayToHtmlAttributes($this->attributes);
+        return $template->render();
+    }
+
+    public function getAllInputNames()
+    {
+        return [];
+    }
+}
diff --git a/lib/classes/forms/CheckboxInput.php b/lib/classes/forms/CheckboxInput.php
new file mode 100644
index 0000000000000000000000000000000000000000..5616056f4c68e9781cccffd33a01791f4fda65ff
--- /dev/null
+++ b/lib/classes/forms/CheckboxInput.php
@@ -0,0 +1,23 @@
+<?php
+
+namespace Studip\Forms;
+
+class CheckboxInput extends Input
+{
+    public function getValue()
+    {
+        return $this->value ? true : false;
+    }
+
+    public function render()
+    {
+        $template = $GLOBALS['template_factory']->open('forms/checkbox_input');
+        $template->title = $this->title;
+        $template->name = $this->name;
+        $template->value = $this->value;
+        $template->id = md5(uniqid());
+        $template->required = $this->required;
+        $template->attributes = arrayToHtmlAttributes($this->attributes);
+        return $template->render();
+    }
+}
diff --git a/lib/classes/forms/DatetimepickerInput.php b/lib/classes/forms/DatetimepickerInput.php
new file mode 100644
index 0000000000000000000000000000000000000000..9bee82bb344cfe53bb769c84170c9ab764468339
--- /dev/null
+++ b/lib/classes/forms/DatetimepickerInput.php
@@ -0,0 +1,25 @@
+<?php
+
+namespace Studip\Forms;
+
+class DatetimepickerInput extends Input
+{
+    public function render()
+    {
+        $attributes = "";
+        foreach ((array) $this->attributes as $key => $value) {
+            if (in_array($key, ['mindate', 'maxdate'])) {
+                $key = ":".$key;
+            }
+            $attributes .= " ".$key.'="'.htmlReady($value).'"';
+        }
+        $template = $GLOBALS['template_factory']->open('forms/datetimepicker_input');
+        $template->title = $this->title;
+        $template->name = $this->name;
+        $template->value = $this->value;
+        $template->id = md5(uniqid());
+        $template->required = $this->required;
+        $template->attributes = $attributes;
+        return $template->render();
+    }
+}
diff --git a/lib/classes/forms/Fieldset.php b/lib/classes/forms/Fieldset.php
new file mode 100644
index 0000000000000000000000000000000000000000..e7bced0a1b8d5478a414e42e820e835ad78cbd75
--- /dev/null
+++ b/lib/classes/forms/Fieldset.php
@@ -0,0 +1,26 @@
+<?php
+
+namespace Studip\Forms;
+
+class Fieldset extends Part
+{
+    protected $legend = null;
+
+    public function __construct($legend = null)
+    {
+        $this->legend = $legend;
+    }
+
+    public function setLegend($legend)
+    {
+        $this->legend = $legend;
+    }
+
+    public function render()
+    {
+        $template = $GLOBALS['template_factory']->open('forms/fieldset');
+        $template->legend = $this->legend;
+        $template->parts = $this->parts;
+        return $template->render();
+    }
+}
diff --git a/lib/classes/forms/Form.php b/lib/classes/forms/Form.php
new file mode 100644
index 0000000000000000000000000000000000000000..9aba36ec428d86b95e6b088b6a7811e4e32ed6c3
--- /dev/null
+++ b/lib/classes/forms/Form.php
@@ -0,0 +1,353 @@
+<?php
+
+namespace Studip\Forms;
+
+class Form extends Part
+{
+
+    //models:
+    protected $afterStore = [];
+
+    //internals
+    protected $inputs = [];
+    protected $parts = [];
+
+    //appearance in html-form
+    protected $url = null;
+    protected $autoStore = false;
+    protected $collapsable = false;
+
+    //to identify a form element
+    protected $id = null;
+
+    /**
+     * Creates a new Form object from a SORM object so that each field of the db-table becomes
+     * an input-field of the form. You can modify the form by the params.
+     * @param \SimpleORMap $object
+     * @param array $params
+     * @param string|null $url
+     * @return Form
+     */
+    public static function fromSORM(\SimpleORMap $object, $params = [], $url = null)
+    {
+        $form = static::create();
+        $form->addSORM($object, $params);
+        if ($url) {
+            $form->setURL($url);
+        }
+        return $form;
+    }
+
+
+    /**
+     * A static constructor for an empty Form object.
+     * @return Form
+     */
+    public static function create() : Form
+    {
+        $form = new static();
+        return $form;
+    }
+
+
+    /**
+     * Adds a new Fieldset to the Form object with the SORM object's fields as
+     * input fields. These fields can be modified or specified by the $params array.
+     * @param \SimpleORMap $object
+     * @param array $params
+     * @return Form $this
+     */
+    public function addSORM(\SimpleORMap $object, array $params = [])
+    {
+        $metadata = $object->getTableMetadata();
+
+        if ($params['fields']) {
+            //Setting the label
+            foreach ($params['fields'] as $fieldname => $fielddata) {
+                if (is_string($fielddata)) {
+                    $params['fields'][$fieldname] = [
+                        'label' => $fielddata
+                    ];
+                }
+            }
+            //Setting the type and name
+            foreach ($params['fields'] as $fieldname => $fielddata) {
+                if (is_array($fielddata)) {
+                    $meta = $metadata['fields'][$fieldname];
+                    if (!isset($fielddata['type'])) {
+                        if ($meta) {
+                            $fielddata = array_merge(Input::getFielddataFromMeta($meta, $object), $fielddata);
+                        } else {
+                            $fielddata['type'] = 'text';
+                        }
+
+                        $params['fields'][$fieldname] = $fielddata;
+                    }
+                    $params['fields'][$fieldname]['name'] = $fieldname;
+                }
+            }
+        } else {
+            foreach ($metadata['fields'] as $attribute => $meta) {
+                if (!in_array($attribute, (array) $params['without'])) {
+                    $fielddata = [
+                        'label' => $attribute
+                    ];
+                    $fielddata = array_merge(Input::getFielddataFromMeta($meta, $object), $fielddata);
+
+                    $params['fields'][$attribute] = $fielddata;
+                }
+            }
+        }
+        foreach ($params['fields'] as $fieldname => $fielddata) {
+            if (is_array($fielddata) && !array_key_exists('value', $fielddata)) {
+                if ($object->isField($fieldname)) {
+                    $params['fields'][$fieldname]['value'] = $object[$fieldname];
+                }
+            }
+        }
+        foreach ((array) $params['types'] as $fieldname => $type) {
+            $params['fields'][$fieldname]['type'] = $type;
+        }
+        //respect the without param:
+        foreach ((array) $params['without'] as $fieldname) {
+            unset($params['fields'][$fieldname]);
+        }
+        $fields = $params['fields'];
+
+        //Now initializing the fieldset:
+        $fieldset = new Fieldset($params['legend'] ?: _("Daten"));
+        $fieldset->setContextObject($object);
+        $this->addPart($fieldset);
+
+        foreach ($fields as $fieldname => $fielddata) {
+            if (is_array($fielddata)) {
+                $fieldset->addInput($fieldset->getInputFromArray($fielddata));
+            } elseif(is_subclass_of($fielddata, Part::class)) {
+                $fieldset->addPart($fielddata);
+            } elseif(is_subclass_of($fielddata, Input::class)) {
+                $fieldset->addInput($fielddata);
+            }
+        }
+        return $this;
+    }
+
+    /**
+     * Sets the URL where the Form should be leading after submitting.
+     * @param $url
+     * @return Form $this
+     */
+    public function setURL($url)
+    {
+        $this->url = $url;
+        return $this;
+    }
+
+    /**
+     * Returns the URL where the Form is leading to after the submit.
+     * @return string|null
+     */
+    public function getURL()
+    {
+        return $this->url;
+    }
+
+    public function setCollapsable($collapsing = true)
+    {
+        $this->collapsable = $collapsing;
+        return $this;
+    }
+
+    public function isCollapsable()
+    {
+        return $this->collapsable;
+    }
+
+    /**
+     * Stores the Form object if this is a POST-request. This also erases the URL so that the auto-save URL
+     * will be set automatically to the current $_SERVER['REQUEST_URI'].
+     * @return $this
+     * @throws \AccessDeniedException
+     */
+    public function autoStore()
+    {
+        $this->autoStore = true;
+        if (\Request::isPost() && \Request::isAjax() && !\Request::isDialog()) {
+            $this->store();
+            \PageLayout::postSuccess(_('Daten wurden gespeichert.'));
+            die();
+        }
+        return $this;
+    }
+
+    public function isAutoStoring()
+    {
+        return $this->autoStore;
+    }
+
+    /**
+     * Adds a callback function that is executed right after the store-method. That callback receives this
+     * Form object as the only parameter.
+     * @param callable $c
+     * @return Form $this
+     */
+    public function addAfterStoreCallback(Callable $c)
+    {
+        $this->afterStore[] = $c;
+        return $this;
+    }
+
+    /**
+     * Sets the ID if this form. This ID is only relevant for plugins to identify this Form object.
+     * @param string|null $id
+     * @return Form $this
+     */
+    public function setId($id)
+    {
+        $this->id = $id;
+        return $this;
+    }
+
+    /**
+     * Returns the ID if this form. This ID is only relevant for plugins to identify this Form object.
+     * @return string|null
+     */
+    public function getId()
+    {
+        return $this->id;
+    }
+
+    /**
+     * Returns the number of storing processes
+     * @return: a number of storing processes. 0 if nothing was stored.
+     */
+    public function store()
+    {
+        if (!\CSRFProtection::verifyRequest()) {
+            throw new \AccessDeniedException();
+        }
+        \NotificationCenter::postNotification('FormWillStore', $this);
+
+        $stored = 0;
+
+        //store by each input
+        foreach ($this->getAllInputs() as $input) {
+            $value = $this->getStorableValueFromRequest($input);
+            if ($value !== null) {
+                $callback = $this->getStoringCallback($input);
+                $stored += $callback($value, $input);
+            }
+        }
+
+        foreach ($this->parts as $part) {
+            $context = $part->getContextObject();
+            if ($context && method_exists($context, 'store')) {
+                $stored += $context->store();
+            }
+        }
+
+        foreach ($this->afterStore as $callback) {
+            if (is_callable($callback)) {
+                $stored += call_user_func($callback, $this);
+            } else {
+                //throw warning if callback is not available:
+                if ($callback === null) {
+                    $callback = 'NULL';
+                }
+                trigger_error(sprintf('Could not execute callback %s in Form object.', $callback), E_USER_WARNING);
+            }
+        }
+        return $stored;
+    }
+
+    /**
+     * Adds a Part object to this form like a fieldset
+     * @param Part $part
+     * @return Form|void
+     */
+    public function addPart(Part $part)
+    {
+        $part->setParent($this);
+        $this->parts[] = $part;
+    }
+
+    /**
+     * Returns all the Part objects like Fieldsets as an array.
+     * @return array
+     */
+    public function getParts() : array
+    {
+        return $this->parts;
+    }
+
+    /**
+     * Returns the last part of the form. If there is none yet, it will create a fieldset and return that.
+     * @return Part
+     */
+    public function getLastPart() : Part
+    {
+        if (count($this->parts) === 0) {
+            $this->parts[] = new Fieldset();
+        }
+        return $this->parts[count($this->parts) - 1];
+    }
+
+    /**
+     * Renders the whole form as a string.
+     * @return string
+     * @throws \Flexi_TemplateNotFoundException
+     */
+    public function render()
+    {
+        \NotificationCenter::postNotification('FormWillRender', $this);
+        $template = $GLOBALS['template_factory']->open('forms/form');
+        $template->form = $this;
+        return $template->render();
+    }
+
+    /**
+     * Returns the function to be used to store the value into the input. If the given Input has no storing
+     * function it will generate a Closuer to set the value to the SimpleORMap context object.
+     * @param $input
+     * @return \Closure|void
+     */
+    protected function getStoringCallback(Input $input)
+    {
+        if ($input->store) {
+            return $input->store;
+        }
+        $context = $input->getParent()->getContextObject();
+        if ($context && is_subclass_of($context, \SimpleORMap::class)) {
+            return function ($value) use ($context, $input) {
+                $context[$input->getName()] = $value;
+            };
+        }
+    }
+
+    /**
+     * Returns the value for the Input object from the $_REQUEST. This value will also be mapped by
+     * the Input's dataMapper function and after that by a special mapper-callback the Input
+     * probably has.
+     * @param Input $input
+     * @return mixed
+     */
+    protected function getStorableValueFromRequest(Input $input)
+    {
+        $requestparam = $input->getName();
+        $bracket_pos = strpos($requestparam, "[");
+        if ($bracket_pos !== false) {
+            $requestparam = substr($requestparam, 0, $bracket_pos);
+            $value = Request::getArray($requestparam);
+            foreach ($value as $i => $v) {
+                $value[$i] = $input->dataMapper($v);
+            }
+        } else {
+            $value = $input->getRequestValue();
+            $value = $input->dataMapper($value);
+        }
+        if ($input->mapper && is_callable($input->mapper)) {
+            $mapper = $input->mapper;
+            $value = $mapper($value, $input->getContextObject());
+        }
+        return $value;
+    }
+}
diff --git a/lib/classes/forms/HiddenInput.php b/lib/classes/forms/HiddenInput.php
new file mode 100644
index 0000000000000000000000000000000000000000..24c8c740ebc77cbe336bb6855d12773de9ea38b5
--- /dev/null
+++ b/lib/classes/forms/HiddenInput.php
@@ -0,0 +1,16 @@
+<?php
+
+namespace Studip\Forms;
+
+class HiddenInput extends Input
+{
+    public function render()
+    {
+        $template = $GLOBALS['template_factory']->open('forms/hidden_input');
+        $template->name = $this->name;
+        $template->value = $this->value;
+        $template->id = md5(uniqid());
+        $template->attributes = arrayToHtmlAttributes($this->attributes);
+        return $template->render();
+    }
+}
diff --git a/lib/classes/forms/I18n_formattedInput.php b/lib/classes/forms/I18n_formattedInput.php
new file mode 100644
index 0000000000000000000000000000000000000000..842a76750a8f567ad92074b70aff69104fa7eee4
--- /dev/null
+++ b/lib/classes/forms/I18n_formattedInput.php
@@ -0,0 +1,49 @@
+<?php
+
+namespace Studip\Forms;
+
+class I18n_formattedInput extends Input
+{
+    public function render()
+    {
+        if (!isset($this->attributes['id'])) {
+            $id = md5(uniqid());
+            $this->attributes['id'] = $id;
+        } else {
+            $id = $this->attributes['id'];
+        }
+        if (!is_object($this->value)) {
+            $value = $this->value;
+        } else {
+            $value = [\I18NString::getDefaultLanguage() => $this->value->original()];
+            $value = json_encode(array_merge($value, $this->value->toArray()));
+        }
+
+        $template = $GLOBALS['template_factory']->open('forms/i18n_formatted_input');
+        $template->title = $this->title;
+        $template->name = $this->name;
+        $template->value = $value;
+        $template->id = $id;
+        $template->required = $this->required;
+        $template->attributes = $this->attributes;
+        return $template->render();
+    }
+
+    public function getAllInputNames()
+    {
+        $all_names = [$this->getName()];
+        if (is_object($this->value)) {
+            foreach (\Config::get()->CONTENT_LANGUAGES as $lang_id => $language) {
+                if (\I18NString::getDefaultLanguage() !== $lang_id) {
+                    $all_names[] = $this->getName() . '_i18n[' . $lang_id . ']';
+                }
+            }
+        }
+        return $all_names;
+    }
+
+    public function getRequestValue()
+    {
+        return \Request::i18n($this->name);
+    }
+}
diff --git a/lib/classes/forms/I18n_textInput.php b/lib/classes/forms/I18n_textInput.php
new file mode 100644
index 0000000000000000000000000000000000000000..ae563b094276776cdc1e0b8a868dd3c376959d94
--- /dev/null
+++ b/lib/classes/forms/I18n_textInput.php
@@ -0,0 +1,48 @@
+<?php
+
+namespace Studip\Forms;
+
+class I18n_textInput extends Input
+{
+    public function render()
+    {
+        if (!isset($this->attributes['id'])) {
+            $id = md5(uniqid());
+            $this->attributes['id'] = $id;
+        } else {
+            $id = $this->attributes['id'];
+        }
+        if (!is_object($this->value)) {
+            $value = $this->value;
+        } else {
+            $value = [\I18NString::getDefaultLanguage() => $this->value->original()];
+            $value = json_encode(array_merge($value, $this->value->toArray()));
+        }
+        $template = $GLOBALS['template_factory']->open('forms/i18n_text_input');
+        $template->title = $this->title;
+        $template->name = $this->name;
+        $template->value = $value;
+        $template->id = $id;
+        $template->required = $this->required;
+        $template->attributes = $this->attributes;
+        return $template->render();
+    }
+
+    public function getAllInputNames()
+    {
+        $all_names = [$this->getName()];
+        if (is_object($this->value)) {
+            foreach (\Config::get()->CONTENT_LANGUAGES as $lang_id => $language) {
+                if (\I18NString::getDefaultLanguage() !== $lang_id) {
+                    $all_names[] = $this->getName() . '_i18n[' . $lang_id . ']';
+                }
+            }
+        }
+        return $all_names;
+    }
+
+    public function getRequestValue()
+    {
+        return \Request::i18n($this->name);
+    }
+}
diff --git a/lib/classes/forms/I18n_textareaInput.php b/lib/classes/forms/I18n_textareaInput.php
new file mode 100644
index 0000000000000000000000000000000000000000..44ba1f2d5e07e52e4ac32e56c81a86c3c7b8b2a2
--- /dev/null
+++ b/lib/classes/forms/I18n_textareaInput.php
@@ -0,0 +1,48 @@
+<?php
+
+namespace Studip\Forms;
+
+class I18n_textareaInput extends Input
+{
+    public function render()
+    {
+        if (!isset($this->attributes['id'])) {
+            $id = md5(uniqid());
+            $this->attributes['id'] = $id;
+        } else {
+            $id = $this->attributes['id'];
+        }
+        if (!is_object($this->value)) {
+            $value = $this->value;
+        } else {
+            $value = [\I18NString::getDefaultLanguage() => $this->value->original()];
+            $value = json_encode(array_merge($value, $this->value->toArray()));
+        }
+        $template = $GLOBALS['template_factory']->open('forms/i18n_textarea_input');
+        $template->title = $this->title;
+        $template->name = $this->name;
+        $template->value = $value;
+        $template->id = $id;
+        $template->required = $this->required;
+        $template->attributes = $this->attributes;
+        return $template->render();
+    }
+
+    public function getAllInputNames()
+    {
+        $all_names = [$this->getName()];
+        if (is_object($this->value)) {
+            foreach (\Config::get()->CONTENT_LANGUAGES as $lang_id => $language) {
+                if (\I18NString::getDefaultLanguage() !== $lang_id) {
+                    $all_names[] = $this->getName() . '_i18n[' . $lang_id . ']';
+                }
+            }
+        }
+        return $all_names;
+    }
+
+    public function getRequestValue()
+    {
+        return \Request::i18n($this->name);
+    }
+}
diff --git a/lib/classes/forms/Input.php b/lib/classes/forms/Input.php
new file mode 100644
index 0000000000000000000000000000000000000000..3018a5baf391c8d18aed8aa0dc64408f01779259
--- /dev/null
+++ b/lib/classes/forms/Input.php
@@ -0,0 +1,271 @@
+<?php
+
+namespace Studip\Forms;
+
+abstract class Input
+{
+    protected $title = null;
+    protected $value = null;
+    protected $attributes = [];
+    protected $name = null;
+    protected $parent = null;
+    public $mapper = null;
+    public $store = null;
+    public $if = null;
+    public $permission = true;
+    public $required = false;
+
+    /**
+     * A static constructor. Returns a new Input-object.
+     * @param string $name
+     * @param string $title
+     * @param mixed $value
+     * @param array $attributes
+     * @return static
+     */
+    public static function create($name, $title, $value, array $attributes = [])
+    {
+        return new static($name, $title, $value, $attributes);
+    }
+
+    /**
+     * This static method returns fielddata as an array from metadata of a database-field.
+     * This array will be used internally to create the best fitting Input object to the
+     * database field.
+     * @param array $meta
+     * @param $object: most likely a SimpleORMap object.
+     * @return array
+     */
+    public static function getFielddataFromMeta($meta, $object) : array
+    {
+        $bracket_pos = strpos($meta['type'], '(');
+        if ($bracket_pos !== false) {
+            $type = substr($meta['type'], 0, $bracket_pos);
+        } else {
+            $type = $meta['type'];
+        }
+        $fielddata = [];
+        switch ($type) {
+            case 'enum':
+                $fielddata['type'] = 'select';
+                preg_match("/\((.*)\)/", $meta['type'], $matches);
+                $matches = explode(',', $matches[1]);
+                foreach ($matches as $key => $status) {
+                    $matches[$key] = substr($status, 1, strlen($status) - 2);
+                }
+                $fielddata['attributes']['options'] = $matches;
+                break;
+            case 'tinyint':
+                preg_match("/\((.*)\)/", $meta['type'], $matches);
+                if ($matches[1] == 1) {
+                    $fielddata['type'] = 'checkbox';
+                    break;
+                }
+            case 'integer':
+                $fielddata['type'] = 'number';
+                break;
+            case 'text':
+                if ($object->isI18nField($meta['name'])) {
+                    $fielddata['type'] = 'i18n_textarea';
+                } else {
+                    $fielddata['type'] = 'textarea';
+                }
+                break;
+            default:
+                if ($object->isI18nField($meta['name'])) {
+                    $fielddata['type'] = 'i18n_text';
+                } else {
+                    $fielddata['type'] = 'text';
+                }
+        }
+        return $fielddata;
+    }
+
+    /**
+     * Constructor of the Input class.
+     * @param $name
+     * @param $title
+     * @param $value
+     * @param $attributes
+     */
+    public function __construct($name, $title, $value, array $attributes = [])
+    {
+        $this->name = $name;
+        $this->title = $title;
+        $this->value = $value;
+        $this->attributes = $attributes;
+    }
+
+    /**
+     * Sets the parent of this Input object. Usually this is done automatically by the framework in the moment that
+     * the input is initialized in the Form object. So you usually don't need to call this method on your own.
+     * @param Part $parent
+     * @return $this
+     */
+    public function setParent(Part $parent)
+    {
+        $this->parent = $parent;
+        return $this;
+    }
+
+    /**
+     * Returns the parent of this Input if there is already one.
+     * @return null|Part
+     */
+    public function getParent()
+    {
+        return $this->parent;
+    }
+
+    public function dataMapper($value)
+    {
+        return $value;
+    }
+
+    /**
+     * Returns the name of the given input. Also have a look at the method getAllInputNames if you want to
+     * provide multiple input elements (like in i18n input fields) within one virtual input. In that case
+     * this getName method returns the main-input name like the attribute in the SORM class.
+     * @return null
+     */
+    public function getName()
+    {
+        return $this->name;
+    }
+
+    /**
+     * Returns the value of this input.
+     * @return null
+     */
+    public function getValue()
+    {
+        return $this->value;
+    }
+
+    /**
+     * Returns an array with all names of all inputs that this Input-object has. Normally this is just one
+     * name because there is only one input. But if you think of i18n-inputs there are possibly more
+     * textareas - one for each language. In that case this function would return all names of all inputs that
+     * are present.
+     * @return string[]
+     */
+    public function getAllInputNames()
+    {
+        return [$this->getName()];
+    }
+
+    /**
+     * Renders the Input but maybe encapsulated in a template that is displayed only if a condition is true.
+     * This is helpful for the if-attribute on the Input like in setIfCondition.
+     * @return string
+     */
+    public function renderWithCondition()
+    {
+        if (!$this->permission) {
+            return '';
+        }
+        $html = $this->render();
+        if (!trim($html)) {
+            return '';
+        }
+        if ($this->if !== null) {
+            $html = '<template v-if="' . htmlReady($this->if) . '">' . $html . '</template>';
+        }
+        return $html;
+    }
+
+    /**
+     * This renders the Input.
+     * @return string
+     */
+    abstract public function render();
+
+    /**
+     * Returns the context-object which is usually a SimpleORMap object.
+     * @return null|\SimpleORMap
+     */
+    public function getContextObject()
+    {
+        if ($this->getParent()) {
+            return $this->getParent()->getContextObject();
+        }
+        return null;
+    }
+
+    /**
+     * Sets a special mapper-function to turn the request-values into the real values for the database.
+     * @param callable $callback
+     * @return $this
+     */
+    public function setMapper(Callable $callback)
+    {
+        $this->mapper = $callback;
+        return $this;
+    }
+
+    /**
+     * Sets the storing function. This would override the normal storing-function that just sets the value
+     * of a given context object like a SORM object.
+     * @param callable $store
+     * @return $this
+     */
+    public function setStoringFunction(Callable $store)
+    {
+        $this->store = $store;
+        return $this;
+    }
+
+    /**
+     * Sets a condition to display this input. The condition is a javascript condition which is used by vue to
+     * hide the input if the condition is not satisfies.
+     * @param string $if
+     * @return $this
+     */
+    public function setIfCondition($if)
+    {
+        $this->if = $if;
+        return $this;
+    }
+
+    /**
+     * Set if the user is able to see and edit this input
+     * @param boolean $if
+     * @return $this
+     */
+    public function setPermission(bool $permission)
+    {
+        $this->permission = $permission;
+        return $this;
+    }
+
+    /**
+     * Marks the input as a required field.
+     * @param $required
+     * @return $this
+     */
+    public function setRequired($required = true)
+    {
+        $this->required = $required;
+        return $this;
+    }
+
+    /**
+     * Returns the values from the request. Normally this is \Request::get, but special Input-classes could also
+     * return arrays or objects.
+     * @return string|null
+     */
+    public function getRequestValue()
+    {
+        return \Request::get($this->name);
+    }
+
+    protected function extractOptionsFromAttributes(array &$attributes)
+    {
+        $options = null;
+        if (isset($attributes['options'])) {
+            $options = $attributes['options'];
+            unset($attributes['options']);
+        }
+        return $options;
+    }
+}
diff --git a/lib/classes/forms/InputRow.php b/lib/classes/forms/InputRow.php
new file mode 100644
index 0000000000000000000000000000000000000000..fc866486a9723f4704703462b89273deb0fb8f8e
--- /dev/null
+++ b/lib/classes/forms/InputRow.php
@@ -0,0 +1,13 @@
+<?php
+
+namespace Studip\Forms;
+
+class InputRow extends Part
+{
+    public function render()
+    {
+        $template = $GLOBALS['template_factory']->open('forms/input_row');
+        $template->parts = $this->parts;
+        return $template->render();
+    }
+}
diff --git a/lib/classes/forms/MultiselectInput.php b/lib/classes/forms/MultiselectInput.php
new file mode 100644
index 0000000000000000000000000000000000000000..67d9ebffd891fdc5efe0323287a726f8b9ea9426
--- /dev/null
+++ b/lib/classes/forms/MultiselectInput.php
@@ -0,0 +1,31 @@
+<?php
+
+namespace Studip\Forms;
+
+class MultiselectInput extends Input
+{
+    public function render()
+    {
+        $options = $this->extractOptionsFromAttributes($this->attributes);
+
+        $name = $this->name;
+        if (substr($name, -2) === '[]') {
+            $name .= substr($name, 0, -2);
+        }
+
+        $template = $GLOBALS['template_factory']->open('forms/multiselect_input');
+        $template->title = $this->title;
+        $template->name = $name;
+        $template->value = $this->value;
+        $template->id = md5(uniqid());
+        $template->required = $this->required;
+        $template->attributes = arrayToHtmlAttributes($this->attributes);
+        $template->options = $options;
+        return $template->render();
+    }
+
+    public function getRequestValue()
+    {
+        return \Request::getArray($this->name);
+    }
+}
diff --git a/lib/classes/forms/NewsRangesInput.php b/lib/classes/forms/NewsRangesInput.php
new file mode 100644
index 0000000000000000000000000000000000000000..18d3170cef20106ee9a15af477ddf2d5d8814d67
--- /dev/null
+++ b/lib/classes/forms/NewsRangesInput.php
@@ -0,0 +1,134 @@
+<?php
+
+namespace Studip\Forms;
+
+class NewsRangesInput extends Input
+{
+
+    public function render()
+    {
+        $context = $this->getContextObject();
+        $sql = "SELECT CONCAT(`Seminar_id`, '__seminar') AS `range_id`, `name` FROM `seminare` WHERE `name` LIKE :input ";
+        if ($GLOBALS['perm']->have_perm('admin')) {
+            $sql .= "UNION SELECT CONCAT(`Institut_id`, '__institute') AS `range_id`, `Name` AS `name` FROM Institute WHERE `name` LIKE :input ";
+            if (!$GLOBALS['perm']->have_perm('root')) {
+                $sql .= "AND ";
+            }
+        }
+        if ($GLOBALS['perm']->have_perm('root')) {
+            $sql .= "UNION SELECT * FROM (SELECT BINARY 'studip__home' AS `range_id`, '"._('Stud.IP-Startseite')."' AS `name`) as tmp_global_table WHERE `name` LIKE :input ";
+            $sql .= "UNION SELECT CONCAT(`user_id`, '__person') AS `range_id`, CONCAT(`Vorname`, ' ', `Nachname`) AS `name` FROM `auth_user_md5` WHERE CONCAT(`Vorname`, ' ', `Nachname`) LIKE :input ";
+        } else {
+            $sql .= "UNION SELECT * FROM (SELECT '".\User::findCurrent()->id."__person' AS `range_id`, '".\addslashes(\User::findCurrent()->getFullName()." - "._('Profilseite'))."' AS `name`) as tmp_user_table WHERE `name` LIKE :input ";
+        }
+        $searchtype = new \SQLSearch($sql, _('Bereich suchen'));
+        $items = [];
+        $icons = [
+            'global' => 'home',
+            'sem'    => 'seminar',
+            'inst'   => 'institute',
+            'user'   => 'person'
+        ];
+        foreach ($context->{$this->name} as $newsrange) {
+            $items[] = [
+                'value'     => $newsrange->range_id,
+                'name'      => (string) $newsrange->name,
+                'icon'      => $icons[$newsrange->type],
+                'deletable' => \StudipNews::haveRangePermission('edit', $newsrange->range_id)
+            ];
+        }
+
+        $selectable = [];
+        $studip_options = [];
+        if ($GLOBALS['perm']->have_perm('root')) {
+            $studip_options[] = [
+                'value' => 'studip__home',
+                'name'  => _('Stud.IP-Startseite'),
+            ];
+        }
+        $studip_options[] = [
+            'value' => \User::findCurrent()->id . '__person',
+            'name' => _('Meine Profilseite')
+        ];
+        $selectable[] = [
+            'label' => _('Stud.IP'),
+            'options' => $studip_options
+        ];
+        if ($GLOBALS['perm']->have_perm('admin')) {
+            $inst_options = [];
+            foreach (\Institute::getMyInstitutes() as $institut) {
+                $inst_options[] = [
+                    'value' => $institut['Institut_id'] . '__institute',
+                    'name'  => $institut['Name'],
+                ];
+            }
+            if (count($inst_options)) {
+                $selectable[] = [
+                    'label' => _('Einrichtungen'),
+                    'options' => $inst_options
+                ];
+            }
+        } else {
+            $course_options = [];
+            foreach (\Course::findByUser(\User::findCurrent()->id) as $course) {
+                $course_options[] = [
+                    'value' => $course->getId()."__seminar",
+                    'name' => $course['name']
+                ];
+            }
+            if (count($course_options)) {
+                $selectable[] = [
+                    'label' => _('Veranstaltungen'),
+                    'options' => $course_options
+                ];
+            }
+        }
+
+        $template = $GLOBALS['template_factory']->open('forms/news_ranges_input');
+        $template->name = $this->name;
+        $template->items = $items;
+        $template->searchtype = $searchtype;
+        $template->selectable = $selectable;
+        $template->category_order = ['home', 'institute', 'seminar', 'person'];
+        return $template->render();
+    }
+
+    public function getRequestValue()
+    {
+        $new_ranges = \Request::getArray($this->name);
+        $context = $this->getContextObject();
+        if ($context) {
+            $options = $context->getRelationOptions($this->name);
+            $old_ranges = array_map(function ($r) {
+                return $r['range_id'];
+            }, $context[$this->name]->getArrayCopy());
+
+            foreach ($new_ranges as $index => $range_id) {
+                if (!in_array($range_id, $old_ranges)) {
+                    if (!\StudipNews::haveRangePermission('edit', $range_id)) {
+                        unset($new_ranges[$index]);
+                    }
+                }
+            }
+            foreach ($old_ranges as $index => $range_id) {
+                if (!in_array($range_id, $new_ranges)) {
+                    if (!\StudipNews::haveRangePermission('edit', $range_id)) {
+                        $new_ranges[] = $range_id;
+                    }
+                }
+            }
+
+            $class = $options['class_name'];
+            return array_map(function ($id) use ($class, $context) {
+                $range = new $class();
+                $range['range_id'] = $id;
+                if (!$context->id) {
+                    $context->setId($context->getNewId());
+                }
+                $range['news_id'] = $context->id;
+                return $range;
+            }, $new_ranges);
+        }
+        return [];
+    }
+}
diff --git a/lib/classes/forms/NoInput.php b/lib/classes/forms/NoInput.php
new file mode 100644
index 0000000000000000000000000000000000000000..c4aae60246fbc4a8826e7a490a0453ccdab72cdd
--- /dev/null
+++ b/lib/classes/forms/NoInput.php
@@ -0,0 +1,16 @@
+<?php
+
+namespace Studip\Forms;
+
+class NoInput extends Input
+{
+    public function render()
+    {
+        return '';
+    }
+
+    public function getAllInputNames()
+    {
+        return [];
+    }
+}
diff --git a/lib/classes/forms/NumberInput.php b/lib/classes/forms/NumberInput.php
new file mode 100644
index 0000000000000000000000000000000000000000..73c233a58f2ea46a7ef2766d49f9070209e09b0b
--- /dev/null
+++ b/lib/classes/forms/NumberInput.php
@@ -0,0 +1,18 @@
+<?php
+
+namespace Studip\Forms;
+
+class NumberInput extends Input
+{
+    public function render()
+    {
+        $template = $GLOBALS['template_factory']->open('forms/number_input');
+        $template->title = $this->title;
+        $template->name = $this->name;
+        $template->value = $this->value;
+        $template->id = md5(uniqid());
+        $template->required = $this->required;
+        $template->attributes = arrayToHtmlAttributes($this->attributes);
+        return $template->render();
+    }
+}
diff --git a/lib/classes/forms/Part.php b/lib/classes/forms/Part.php
new file mode 100644
index 0000000000000000000000000000000000000000..79c573bf7613cba900a9b63f2192e0a21513019b
--- /dev/null
+++ b/lib/classes/forms/Part.php
@@ -0,0 +1,226 @@
+<?php
+
+namespace Studip\Forms;
+
+abstract class Part
+{
+    protected $parent = null;
+    protected $contextobject = null;
+    protected $parts = [];
+    public $if = null;
+
+    /**
+     * Constructor of this Part. Can take one or more Part objects or Input objects or arrays representing an Input object.
+     * @param ...$parts
+     */
+    public function __construct(...$parts)
+    {
+        foreach ($parts as $part) {
+            if (is_subclass_of($part, Part::class)) {
+                $this->addPart($part);
+            } else {
+                if (!is_array($part)) {
+                    $part->setParent($this);
+                }
+                $this->parts[] = $part;
+            }
+        }
+    }
+
+    /**
+     * Sets the context-object which is most likely a SimpleORMap object
+     * @param $object
+     * @return $this
+     */
+    public function setContextObject($object)
+    {
+        $this->contextobject = $object;
+        return $this;
+    }
+
+    /**
+     * Returns the context object of this Part if there is any. If there is none it tries to return the context-object
+     * of a parent object.
+     * @return void|null
+     */
+    public function getContextObject()
+    {
+        if ($this->contextobject) {
+            return $this->contextobject;
+        } elseif ($this->parent) {
+            return $this->parent->getContextObject();
+        }
+    }
+
+    /**
+     * Adds a Part object on the next layer.
+     * @param Part $part
+     * @return $this
+     */
+    public function addPart(Part $part)
+    {
+        $part->setParent($this);
+        $this->parts[] = $part;
+        return $this;
+    }
+
+    /**
+     * Adds an Input to this Part.
+     * @param Input $input
+     * @return Input
+     */
+    public function addInput(Input $input)
+    {
+        $input->setParent($this);
+        $this->parts[] = $input;
+        return $input;
+    }
+
+    /**
+     * Renders this Part object. This could be a section or any other HTML element with child-elements.
+     * @return string
+     */
+    public function render()
+    {
+        return '';
+    }
+
+    /**
+     * Renders the Part element with a condition.
+     * @return string
+     */
+    public function renderWithCondition()
+    {
+        $html = $this->render();
+        if (!trim($html)) {
+            return '';
+        }
+        if ($this->if !== null) {
+            $html = '<template v-if="' . htmlReady($this->if) . '">' . $html . '</template>';
+        }
+        return $html;
+    }
+
+    /**
+     * Recursively returns all Input elements attached to this Part object or any child Parts.
+     * @return array
+     */
+    public function getAllInputs()
+    {
+        $inputs = [];
+        foreach ($this->parts as $part) {
+            if (is_subclass_of($part, Input::class) && $part->permission) {
+                $inputs[] = $part;
+            } elseif(is_subclass_of($part, Part::class)) {
+                $inputs = array_merge($inputs, $part->getAllInputs());
+            }
+        }
+        return $inputs;
+    }
+
+    /**
+     * Sets the parent object of this Part. Usually this is done automatically.
+     * @param Part $parent
+     * @return $this
+     * @throws \Exception
+     */
+    public function setParent(Part $parent)
+    {
+        $this->parent = $parent;
+        //Inputs aktualisieren?
+        foreach ($this->parts as $i => $part) {
+            if (is_array($part)) {
+                $input = $this->getInputFromArray($part);
+                $input->setParent($this);
+                $this->parts[$i] = $input;
+            }
+        }
+        return $this;
+    }
+
+    /**
+     * Sets a condition to display this Part. The condition is a javascript condition which is used by vue to
+     * hide the input if the condition is not satisfies.
+     * @param string $if
+     * @return $this
+     */
+    public function setIfCondition($if)
+    {
+        $this->if = $if;
+        return $this;
+    }
+
+    /**
+     * Returns an Input element from an array.
+     * @param array $data
+     * @return array|mixed
+     * @throws \Exception
+     */
+    public function getInputFromArray(array $data)
+    {
+        $context = $this->getContextObject();
+        if ($context && method_exists($context, 'getTableMetadata')) {
+            $metadata = $context->getTableMetadata();
+            $meta = $metadata['fields'][$data['name']];
+            if (!isset($data['type'])) {
+                if ($meta) {
+                    $data = array_merge(Input::getFielddataFromMeta($meta, $context), $data);
+                } else {
+                    $data['type'] = 'text';
+                }
+            }
+        }
+        if (!isset($data['label'])) {
+            $data['label'] = $data['name'];
+        }
+
+        if (!isset($data['value']) && $context && method_exists($context, 'isField')) {
+            if ($context->isField($data['name'])) {
+                $data['value'] = $context[$data['name']];
+            }
+        }
+        if (!$data['type']) {
+            return $data;
+        }
+
+        $classname = "\\Studip\\Forms\\".ucfirst($data['type'])."Input";
+        $attributes = $data;
+        unset($attributes['name']);
+        unset($attributes['label']);
+        unset($attributes['value']);
+        unset($attributes['type']);
+        unset($attributes['mapper']);
+        unset($attributes['store']);
+        unset($attributes['if']);
+        unset($attributes['permission']);
+        unset($attributes['required']);
+        unset($attributes['attributes']);
+        $attributes = array_merge($attributes, (array) $data['attributes']);
+        if (class_exists($classname)) {
+            $input = new $classname($data['name'], $data['label'], $data['value'], $attributes);
+        } elseif (class_exists($data['type'])) {
+            $classname = $data['type'];
+            $input = new $classname($data['name'], $data['label'], $data['value'], $attributes);
+        } else {
+            //this should not happen:
+            throw new \Exception(sprintf(_("Klasse %s oder %s existiert nicht."), $classname, $data['type']));
+        }
+
+        if ($data['mapper'] && is_callable($data['mapper'])) {
+            $input->mapper = $data['mapper'];
+        }
+        if ($data['store'] && is_callable($data['store'])) {
+            $input->store = $data['store'];
+        }
+        if ($data['if']) {
+            $input->if = $data['if'];
+        }
+        if (isset($data['permission'])) {
+            $input->permission = $data['permission'];
+        }
+        if ($data['required']) {
+            $input->required = true;
+        }
+        return $input;
+    }
+}
diff --git a/lib/classes/forms/QuicksearchInput.php b/lib/classes/forms/QuicksearchInput.php
new file mode 100644
index 0000000000000000000000000000000000000000..2531a2e19c3ee13134ae6d54bf987fbf789aabd9
--- /dev/null
+++ b/lib/classes/forms/QuicksearchInput.php
@@ -0,0 +1,18 @@
+<?php
+
+namespace Studip\Forms;
+
+class QuicksearchInput extends Input
+{
+    public function render()
+    {
+        $template = $GLOBALS['template_factory']->open('forms/checkbox_input');
+        $template->title = $this->title;
+        $template->name = $this->name;
+        $template->value = $this->value;
+        $template->id = md5(uniqid());
+        $template->required = $this->required;
+        $template->attributes = arrayToHtmlAttributes($this->attributes);
+        return $template->render();
+    }
+}
diff --git a/lib/classes/forms/RangeInput.php b/lib/classes/forms/RangeInput.php
new file mode 100644
index 0000000000000000000000000000000000000000..9f99d592929655fc7a0074fcf71179ccf836c4b8
--- /dev/null
+++ b/lib/classes/forms/RangeInput.php
@@ -0,0 +1,18 @@
+<?php
+
+namespace Studip\Forms;
+
+class RangeInput extends Input
+{
+    public function render()
+    {
+        $template = $GLOBALS['template_factory']->open('forms/range_input');
+        $template->title = $this->title;
+        $template->name = $this->name;
+        $template->value = $this->value;
+        $template->id = md5(uniqid());
+        $template->required = $this->required;
+        $template->attributes = arrayToHtmlAttributes($this->attributes);
+        return $template->render();
+    }
+}
diff --git a/lib/classes/forms/SelectInput.php b/lib/classes/forms/SelectInput.php
new file mode 100644
index 0000000000000000000000000000000000000000..0ba83c97215e825c19051aa33d1d1390a1c6fbcf
--- /dev/null
+++ b/lib/classes/forms/SelectInput.php
@@ -0,0 +1,21 @@
+<?php
+
+namespace Studip\Forms;
+
+class SelectInput extends Input
+{
+    public function render()
+    {
+        $options = $this->extractOptionsFromAttributes($this->attributes);
+
+        $template = $GLOBALS['template_factory']->open('forms/select_input');
+        $template->title = $this->title;
+        $template->name = $this->name;
+        $template->value = $this->value;
+        $template->id = md5(uniqid());
+        $template->required = $this->required;
+        $template->attributes = arrayToHtmlAttributes($this->attributes);
+        $template->options = $options;
+        return $template->render();
+    }
+}
diff --git a/lib/classes/forms/TextInput.php b/lib/classes/forms/TextInput.php
new file mode 100644
index 0000000000000000000000000000000000000000..4ba6eb9dc1056fcd3e2e0db8c3efb2517c477179
--- /dev/null
+++ b/lib/classes/forms/TextInput.php
@@ -0,0 +1,18 @@
+<?php
+
+namespace Studip\Forms;
+
+class TextInput extends Input
+{
+    public function render()
+    {
+        $template = $GLOBALS['template_factory']->open('forms/text_input');
+        $template->title = $this->title;
+        $template->name = $this->name;
+        $template->value = $this->value;
+        $template->id = md5(uniqid());
+        $template->required = $this->required;
+        $template->attributes = arrayToHtmlAttributes($this->attributes);
+        return $template->render();
+    }
+}
diff --git a/lib/classes/forms/TextareaInput.php b/lib/classes/forms/TextareaInput.php
new file mode 100644
index 0000000000000000000000000000000000000000..267c55e1388ecdb38ab7252b56f3dc65cb1c4f57
--- /dev/null
+++ b/lib/classes/forms/TextareaInput.php
@@ -0,0 +1,18 @@
+<?php
+
+namespace Studip\Forms;
+
+class TextareaInput extends Input
+{
+    public function render()
+    {
+        $template = $GLOBALS['template_factory']->open('forms/textarea_input');
+        $template->title = $this->title;
+        $template->name = $this->name;
+        $template->value = $this->value;
+        $template->id = md5(uniqid());
+        $template->required = $this->required;
+        $template->attributes = arrayToHtmlAttributes($this->attributes);
+        return $template->render();
+    }
+}
diff --git a/lib/models/NewsRange.class.php b/lib/models/NewsRange.class.php
index ab20cf1b7ebf8062ecdf9990a13f49b5e7fe4b3e..b13f0cc19e8448c8ead8dafdafcf8975caa40d05 100644
--- a/lib/models/NewsRange.class.php
+++ b/lib/models/NewsRange.class.php
@@ -52,7 +52,7 @@ class NewsRange extends SimpleORMap
     {
         switch ($this->type) {
             case 'global':
-                return 'Stud.IP';
+                return _('Stud.IP-Startseite');
                 break;
             case 'sem':
                 return $this->course->name;
diff --git a/lib/modules/NewsWidget.php b/lib/modules/NewsWidget.php
index c2df73d7513d17fb6f14eefcb4a36aa8a06f53c0..5d12608b5da7b41a7d8f0d785e1771eeb28cae14 100644
--- a/lib/modules/NewsWidget.php
+++ b/lib/modules/NewsWidget.php
@@ -49,7 +49,7 @@ class NewsWidget extends CorePlugin implements PortalPlugin
 
         if ($GLOBALS['perm']->have_perm('root')) {
             $navigation = new Navigation('', 'dispatch.php/news/edit_news/new/studip');
-            $navigation->setImage(Icon::create('add', 'clickable', ["title" => _('Ankündigungen bearbeiten')]), ['rel' => 'get_dialog']);
+            $navigation->setImage(Icon::create('add', Icon::ROLE_CLICKABLE, ['title' => _('Ankündigungen bearbeiten')]), ['data-dialog' => '1']);
             $icons[] = $navigation;
             if (Config::get()->NEWS_RSS_EXPORT_ENABLE) {
                 $navigation = new Navigation('', 'dispatch.php/news/rss_config/studip');
diff --git a/resources/assets/javascripts/bootstrap/forms.js b/resources/assets/javascripts/bootstrap/forms.js
index 503fdda512696bd6c5069f83b5513968a24fdf65..60ae9c3244e644d834049715a602a0b575dbb9f2 100644
--- a/resources/assets/javascripts/bootstrap/forms.js
+++ b/resources/assets/javascripts/bootstrap/forms.js
@@ -245,6 +245,114 @@ function createSelect2(element) {
 }
 
 STUDIP.ready(function () {
+    let forms = window.document.querySelectorAll('form.default.studipform:not(.vueified)');
+    if (forms.length > 0) {
+        STUDIP.Vue.load().then(({createApp}) => {
+            forms.forEach(f => {
+                createApp({
+                    el: f,
+                    data() {
+                        let params = JSON.parse(f.dataset.inputs);
+                        params.STUDIPFORM_REQUIRED = f.dataset.required ? JSON.parse(f.dataset.required) : [];
+                        params.STUDIPFORM_DISPLAYVALIDATION = false;
+                        params.STUDIPFORM_VALIDATIONNOTES = [];
+                        params.STUDIPFORM_AUTOSAVEURL = f.dataset.autosave;
+                        params.STUDIPFORM_REDIRECTURL = f.dataset.url;
+                        return params;
+                    },
+                    methods: {
+                        submit: function (e) {
+                            let v = this;
+                            v.STUDIPFORM_VALIDATIONNOTES = [];
+                            this.STUDIPFORM_DISPLAYVALIDATION = true;
+
+                            //validation:
+                            let validated = this.validate();
+
+                            if (!validated) {
+                                e.preventDefault();
+                                v.$el.scrollIntoView({
+                                    "behavior": "smooth"
+                                });
+                                return;
+                            }
+
+                            if (this.STUDIPFORM_AUTOSAVEURL) {
+                                let params = this.getFormValues();
+
+                                $.ajax({
+                                    url: this.STUDIPFORM_AUTOSAVEURL,
+                                    data: params,
+                                    type: 'post',
+                                    success() {
+                                        if (v.STUDIPFORM_REDIRECTURL) {
+                                            window.location.href = v.STUDIPFORM_REDIRECTURL
+                                        }
+                                    }
+                                });
+                                e.preventDefault();
+                            }
+                        },
+                        getFormValues() {
+                            let v = this;
+                            let params = {
+                                security_token: this.$refs.securityToken.value
+                            };
+                            Object.keys(v.$data).forEach(function (i) {
+                                if (!i.startsWith('STUDIPFORM_')) {
+                                    if (typeof v.$data[i] === 'boolean') {
+                                        params[i] = v.$data[i] ? 1 : 0;
+                                    } else {
+                                        params[i] = v.$data[i];
+                                    }
+                                }
+                            });
+                            return params;
+                        },
+                        validate() {
+                            let v = this;
+                            this.STUDIPFORM_VALIDATIONNOTES = [];
+
+                            let validated = this.$el.checkValidity();
+
+                            $(this.$el).find('input, select, textarea').each(function () {
+                                if (!this.validity.valid) {
+                                    let note = {
+                                        'name': $(this.labels[0]).find('.textlabel').text(),
+                                        'description': "Fehler!".toLocaleString(),
+                                        'describedby': this.id
+                                    };
+                                    if (this.validity.tooShort) {
+                                        note.description = "Geben Sie mindestens %s Zeichen ein.".toLocaleString().replace("%s", this.minLength);
+                                    }
+                                    if (this.validity.valueMissing) {
+                                        if (this.type === 'checkbox') {
+                                            note.description = "Dieses Feld muss ausgewählt sein.".toLocaleString();
+                                        } else {
+                                            note.description = "Hier muss ein Wert eingetragen werden.".toLocaleString();
+                                        }
+                                    }
+                                    v.STUDIPFORM_VALIDATIONNOTES.push(note);
+                                }
+                            });
+                            return validated;
+                        },
+                        setInputs(inputs) {
+                            for (const [key, value] of Object.entries(inputs)) {
+                                if (this[key] !== undefined) {
+                                    this[key] = value;
+                                }
+                            }
+                        }
+                    },
+                    mounted () {
+                        $(this.$el).addClass("vueified");
+                    }
+                });
+            });
+        });
+    }
+
     // Well, this is really nasty: Select2 can't determine the select
     // element's width if it is hidden (by itself or by it's parent).
     // This is due to the fact that elements are not rendered when hidden
diff --git a/resources/assets/javascripts/bootstrap/news.js b/resources/assets/javascripts/bootstrap/news.js
index 6329f168ba19a42919fd16bac48edfca0ee47bfc..aa2dc98c8a663fe7d91819cd6af0b0787e0e54bc 100644
--- a/resources/assets/javascripts/bootstrap/news.js
+++ b/resources/assets/javascripts/bootstrap/news.js
@@ -9,16 +9,6 @@ STUDIP.domReady(() => {
     }
     STUDIP.News.pending_ajax_request = false;
 
-    $(document).on('click', 'a[rel~="get_dialog"]', function(event) {
-        event.preventDefault();
-        STUDIP.News.get_dialog('news_dialog', $(this).attr('href'));
-    });
-
-    $(document).on('click', 'a[rel~="close_dialog"]', function(event) {
-        event.preventDefault();
-        $('#news_dialog').dialog('close');
-    });
-
     // open/close categories without ajax-request
     $(document).on('click', '.news_category_header', function(event) {
         event.preventDefault();
diff --git a/resources/assets/javascripts/lib/news.js b/resources/assets/javascripts/lib/news.js
index 80575ccf1a172b115fcbe41660d6e14c6eacdf3a..71d221f73fe28e3a8e837868fd96c1fe7a4612ee 100644
--- a/resources/assets/javascripts/lib/news.js
+++ b/resources/assets/javascripts/lib/news.js
@@ -59,33 +59,6 @@ const News = {
         });
     },
 
-    get_dialog (id, route) {
-        // initialize dialog
-        $('body').append(`<div id="${id}"></div>`);
-        $(`#${id}`).dialog({
-            modal: true,
-            height: News.dialog_height,
-            title: $gettext('Dialog wird geladen...'),
-            width: News.dialog_width,
-            close () {
-                $(`#${id}`).remove();
-            }
-        });
-
-        // load actual dialog content
-        $.get(route, 'html').done(function (html, status, xhr) {
-            $(`#${id}`).dialog('option', 'title', decodeURIComponent(xhr.getResponseHeader('X-Title')));
-            $(`#${id}`).html(html);
-            $(`#${id}_content`).css({
-                height : (News.dialog_height - 120) + 'px',
-                maxHeight: (News.dialog_height - 120) + 'px'
-            });
-
-            News.init(id);
-        }).fail(function () {
-            window.alert($gettext('Fehler beim Aufruf des News-Controllers'));
-        });
-    },
 
     update_dialog (id, route, form_data) {
         if (!News.pending_ajax_request) {
diff --git a/resources/assets/stylesheets/less/buttons.less b/resources/assets/stylesheets/less/buttons.less
index 37e56150fa7facd6974e9149d3dfbea1d09e85fb..d2b840ff5fcfa72ef0141aaccd3026396844a5bc 100644
--- a/resources/assets/stylesheets/less/buttons.less
+++ b/resources/assets/stylesheets/less/buttons.less
@@ -152,9 +152,9 @@ button,
         border: 0;
         margin: 0;
         padding: 0;
+        cursor: pointer;
 
         &[formaction] {
-            cursor: pointer;
             color: @base-color;
 
             transition: color 0.3s;
diff --git a/resources/assets/stylesheets/less/forms.less b/resources/assets/stylesheets/less/forms.less
index e4f75bafa4328acd931fabc3d3c1a68af6c2d6da..5ca970fb9158eb071736bbeec9344c34d0294186 100644
--- a/resources/assets/stylesheets/less/forms.less
+++ b/resources/assets/stylesheets/less/forms.less
@@ -96,6 +96,25 @@ form.default {
         }
     }
 
+    .formpart {
+        margin-bottom: @gap;
+
+        output.calculator_result {
+            display: block;
+            margin-top: 2.3ex;
+        }
+    }
+    .editablelist {
+        margin-bottom: @gap;
+        > li {
+            margin-bottom: 10px;
+            &:last-child {
+                margin-bottom: 0px;
+            }
+        }
+    }
+
+
     .label-text {
         display: inline-block;
         text-indent: 0.25ex;
@@ -190,6 +209,12 @@ form.default {
             color: red;
         }
     }
+    .studiprequired {
+        font-weight: bold;
+        .asterisk {
+            color: red;
+        }
+    }
 
     input[type=checkbox], input[type=radio] {
         vertical-align: text-bottom;
@@ -281,6 +306,7 @@ form.default {
         display: flex;
         align-items: center;
         flex-wrap: wrap;
+        align-items: flex-start;
         max-width: @max-width-m;
 
         &.size-s {
@@ -314,6 +340,9 @@ form.default {
                 margin-top: 0;
                 width: auto;
             }
+            .quicksearch_container input {
+                width: 100%;
+            }
         }
 
         .button {
@@ -443,6 +472,11 @@ form.default {
         }
     }
 
+    .validation_notes_icon {
+        position: relative;
+        top: -2px;
+    }
+
     &.show_validation_hints {
         :invalid, .invalid {
             .icon('before', 'exclaim-circle', 'attention', 16, 5px);
@@ -452,6 +486,42 @@ form.default {
             border-left: 4px solid @red;
         }
     }
+
+    //designing vue-select in studipform:
+    .vs__dropdown-toggle {
+        border-radius: 0px;
+    }
+    .vs__selected {
+        border-radius: 0px;
+        padding: 5px;
+    }
+
+    .range_input {
+        display: flex;
+        align-items: center;
+        input[type=range] {
+            &::-moz-range-track {
+                height: 11px;
+                border: 1px solid @content-color;
+                background-color: transparent;
+            }
+            &::-moz-range-progress {
+                background-color: @base-color;
+                height: 11px;
+            }
+            &::-moz-range-thumb {
+                border-radius: 0px;
+                width: 1.2em;
+                height: 1.2em;
+            }
+            &::-moz-range-thumb:hover {
+                background-color: @content-color;
+            }
+        }
+        output {
+            margin-left: 10px;
+        }
+    }
 }
 
 form.narrow {
diff --git a/resources/assets/stylesheets/scss/blubber.scss b/resources/assets/stylesheets/scss/blubber.scss
index 3af26ee7674aa07faf28996f2ac761b882204b7c..49eee3cb865a1e990fc4e138ed469e56ff37cc87 100644
--- a/resources/assets/stylesheets/scss/blubber.scss
+++ b/resources/assets/stylesheets/scss/blubber.scss
@@ -7,11 +7,6 @@
         filter: blur(1px);
         opacity: 0.5;
     }
-    [v-if],
-    [v-for],
-    [v-show] {
-        display: none;
-    }
     .context_info {
         .followunfollow {
             &.loading {
diff --git a/resources/assets/stylesheets/studip.scss b/resources/assets/stylesheets/studip.scss
index ed867ef91087cc68b4e935c8348608ac23138e58..1a5bb5e5b1fa5cfddcbf4756967a1e943f0bd2c5 100644
--- a/resources/assets/stylesheets/studip.scss
+++ b/resources/assets/stylesheets/studip.scss
@@ -54,3 +54,7 @@ html {
     overflow: auto;
     scroll-padding-top: calc(#{$bar-bottom-container-height} + 1em);
 }
+
+[v-cloak] {
+    display: none;
+}
diff --git a/resources/vue/base-components.js b/resources/vue/base-components.js
index b57094e559b94bb2ee1532aa700e1205d7f88f97..526687b39763215284e518e8ae748ffc806ca419 100644
--- a/resources/vue/base-components.js
+++ b/resources/vue/base-components.js
@@ -1,3 +1,5 @@
+import Multiselect from './components/Multiselect.vue';
+import EditableList from "./components/EditableList.vue";
 import Quicksearch from './components/Quicksearch.vue';
 import StudipActionMenu from './components/StudipActionMenu.vue';
 import StudipAssetImg from './components/StudipAssetImg.vue';
@@ -5,6 +7,10 @@ import StudipDateTime from './components/StudipDateTime.vue';
 import StudipDialog from './components/StudipDialog.vue';
 import StudipFileSize from './components/StudipFileSize.vue';
 import StudipIcon from './components/StudipIcon.vue';
+import RangeInput from './components/RangeInput.vue';
+import Datetimepicker from './components/Datetimepicker.vue';
+import TextareaWithToolbar from './components/TextareaWithToolbar.vue';
+import I18nTextarea from "./components/I18nTextarea.vue";
 // import StudipLoadingIndicator from './StudipLoadingIndicator.vue';
 import StudipMessageBox from './components/StudipMessageBox.vue';
 import StudipProxyCheckbox from './components/StudipProxyCheckbox.vue';
@@ -13,19 +19,25 @@ import StudipTooltipIcon from './components/StudipTooltipIcon.vue';
 import StudipSelect from './components/StudipSelect.vue';
 
 const BaseComponents = {
+    Multiselect,
+    EditableList,
     Quicksearch,
+    RangeInput,
     StudipActionMenu,
     StudipAssetImg,
     StudipDateTime,
+    Datetimepicker,
     StudipDialog,
     StudipFileSize,
     StudipIcon,
+    I18nTextarea,
 //    StudipLoadingIndicator,
     StudipMessageBox,
     StudipProxyCheckbox,
     StudipProxiedCheckbox,
     StudipTooltipIcon,
     StudipSelect,
+    TextareaWithToolbar
 };
 
 export default BaseComponents;
diff --git a/resources/vue/components/Datetimepicker.vue b/resources/vue/components/Datetimepicker.vue
new file mode 100644
index 0000000000000000000000000000000000000000..3ec930c12e01bbc4fa3e8db751ef1ffe26150612
--- /dev/null
+++ b/resources/vue/components/Datetimepicker.vue
@@ -0,0 +1,81 @@
+<template>
+    <span>
+        <input type="hidden" :name="name" :value="value">
+        <input type="text"
+               ref="visibleInput"
+               class="visible_input"
+               @change="setUnixTimestamp"
+               v-bind="$attrs"
+               v-on="$listeners">
+    </span>
+</template>
+
+<script>
+export default {
+    name: 'datetimepicker',
+    inheritAttrs: false,
+    props: {
+        name: {
+            type: String,
+            required: false
+        },
+        value: {
+            required: false
+        },
+        mindate: {
+            required: false
+        },
+        maxdate: {
+            required: false
+        }
+    },
+    methods: {
+        setUnixTimestamp () {
+            let formatted_date = this.$refs.visibleInput.value;
+            let date = formatted_date.match(/(\d+)/g);
+            date = new Date(`${date[2]}-${date[1]}-${date[0]} ${date[3]}:${date[4]}`);
+            this.$emit('input', Math.floor(date / 1000));
+        }
+    },
+    mounted () {
+        let value = parseInt(this.value, 10) !== NaN ? parseInt(this.value, 10) : this.value;
+        if (Number.isInteger(value)) {
+            let date = new Date(value * 1000);
+            let formatted_date =
+                (date.getDate() < 10 ? "0" : "") + date.getDate()
+                + "."
+                + (date.getMonth() < 9 ? "0" : "") + (date.getMonth() + 1)
+                + "."
+                + date.getFullYear()
+                + " "
+                + (date.getHours() < 10 ? "0" : "") + date.getHours()
+                + ":"
+                + (date.getMinutes() < 10 ? "0" : "") + date.getMinutes();
+            this.$refs.visibleInput.value = formatted_date;
+        } else {
+            this.$refs.visibleInput.value = value;
+        }
+        let v = this;
+        let params = {
+            onSelect () {
+                v.setUnixTimestamp();
+            }
+        };
+        if (this.mindate) {
+            params.minDate = new Date(this.mindate * 1000)
+        }
+        if (this.maxdate) {
+            params.maxDate = new Date(this.maxdate * 1000)
+        }
+        $(this.$refs.visibleInput).datetimepicker(params);
+    },
+    watch: {
+        mindat (new_data, old_data) {
+            $(this.$refs.visibleInput).datetimepicker('option', 'minDate', new Date(new_data * 1000));
+        },
+        maxdate (new_data, old_data) {
+            $(this.$refs.visibleInput).datetimepicker('option', 'maxDate', new Date(new_data * 1000));
+        }
+    }
+}
+</script>
diff --git a/resources/vue/components/EditableList.vue b/resources/vue/components/EditableList.vue
new file mode 100644
index 0000000000000000000000000000000000000000..8b2242272130db993ad376fae334e3c6a936133c
--- /dev/null
+++ b/resources/vue/components/EditableList.vue
@@ -0,0 +1,170 @@
+<template>
+    <div>
+        <div class="formpart">
+            <ul class="clean editablelist">
+                <li v-for="item in sortedItems" :key="item.id" :data-type="item.type">
+                    <studip-icon v-if="item.icon" :shape="item.icon" role="info" size="20" class="text-bottom" alt=""></studip-icon>
+                    <input v-if="name" type="hidden" :name="name + '[]'" :value="item.value">
+                    <span>{{item.name}}</span>
+                    <button v-if="item.deletable" @click.prevent="deleteItem(item)" :title="$gettextInterpolate('%{ name } löschen', {name: item.name})" class="undecorated">
+                        <studip-icon shape="trash" role="clickable" size="20" class="text-bottom"></studip-icon>
+                    </button>
+                </li>
+            </ul>
+            <quicksearch v-if="quicksearch" :searchtype="quicksearch" name="qs" @input="addRange" :placeholder="$gettext('Suchen')"></quicksearch>
+        </div>
+
+        <label v-if="selectable">
+            <translate>Oder aus Liste auswählen:</translate>
+            <select @change="quickselect" @keydown="navigate_or_select">
+                <option value=""><translate>Direkt auswählen ...</translate></option>
+                <template v-for="opt in selectable">
+                    <optgroup v-if="opt.label && opt.options" :label="opt.label">
+                        <option v-for="option in opt.options" :disabled="isSelected(option.value)" :value="JSON.stringify({value: option.value, name: option.name})">{{ option.name + (isSelected(option.value) ? ' ✓' : '') }}</option>
+                    </optgroup>
+                    <option v-else :disabled="isSelected(opt.value)" @click="quicksearch" :value="JSON.stringify({value: opt.value, name: opt.name})">{{ opt.name + (isSelected(option.value) ? ' ✓' : '') }}</option>
+                </template>
+            </select>
+
+        </label>
+    </div>
+</template>
+
+<script>
+export default {
+    name: 'editable-list',
+    props: {
+        name: {
+            type: String,
+            required: false
+        },
+        items: {
+            required: false,
+            type: Array
+        },
+        quicksearch: {
+            required: false
+        },
+        selectable: {
+            type: Array,
+            required: false
+        },
+        category_order: {
+            type: Array,
+            required: false,
+            default: []
+        }
+    },
+    data () {
+        return {
+            resort: false, //this is just for triggering the computed property sortedItems to be sorted again
+            preventChangeOfQuickselect: false
+        };
+    },
+    methods: {
+        addRange (id, name) {
+            let icon = null;
+            if (id.includes('__')) {
+                icon = id.split('__')[1];
+                id = id.split('__')[0];
+            }
+            let insert = true;
+            for (let i in this.items) {
+                if (this.items[i].value === id) {
+                    insert = false;
+                    break;
+                }
+            }
+
+            if (insert) {
+                this.items.push({
+                    value: id,
+                    name: name,
+                    icon: icon,
+                    deletable: true
+                });
+                this.changed();
+            }
+        },
+        changed () {
+            this.resort = !this.resort;
+            this.$emit('input', this.items.map(function (item) {
+                return item.value;
+            }));
+            this.$emit('items', this.items.map(function (item) {
+                return {
+                    value: item.value,
+                    name: item.name,
+                    icon: item.icon,
+                    deletable: item.deletable
+                };
+            }));
+        },
+        quickselect (event) {
+            if (event.target.value && !this.preventChangeOfQuickselect) {
+                let new_value = JSON.parse(event.target.value);
+                this.addRange(new_value.value, new_value.name);
+                event.target.value = '';
+            }
+        },
+        navigate_or_select (event) {
+            if (['ArrowDown', 'ArrowUp', 'ArrowLeft', 'ArrowRight'].includes(event.key)) {
+                //don't trigger change for 250 ms
+                this.preventChangeOfQuickselect = true;
+                window.setTimeout(() => {
+                    this.preventChangeOfQuickselect = false;
+                }, 250);
+            } else if (event.key === 'Enter') {
+                //select current selection
+                let new_value = JSON.parse(event.target.value);
+                this.addRange(new_value.value, new_value.name);
+                event.target.value = '';
+            }
+        },
+        deleteItem (item) {
+            for (let i in this.items) {
+                if (this.items[i].value === item.value) {
+                    this.$delete(this.items, i);
+                }
+            }
+            this.changed();
+        },
+        isSelected (id) {
+            if (id.includes('__')) {
+                id = id.split('__')[0];
+            }
+            for (let i in this.items) {
+                if (this.items[i].value === id) {
+                    return true;
+                }
+            }
+            return false;
+        }
+    },
+    computed: {
+        sortedItems () {
+            let v = this;
+            let i = this.resort;
+            let items = this.items.sort(function (a, b) {
+                if (a.icon === b.icon) {
+                    return a.name.localeCompare(b.name);
+                } else {
+                    let a_icon = a.icon || '';
+                    let b_icon = b.icon || '';
+                    if (v.category_order.indexOf(a_icon) > -1 && v.category_order.indexOf(b_icon) > -1) {
+                        return v.category_order.indexOf(a_icon) < v.category_order.indexOf(b_icon) ? -1 : 1;
+                    } else {
+                        return a_icon.localeCompare(b_icon);
+                    }
+                }
+            });
+            return items;
+        }
+    },
+    mounted () {
+        this.$emit('input', this.items.map(function (item) {
+            return item.value;
+        }));
+    }
+}
+</script>
diff --git a/resources/vue/components/I18nTextarea.vue b/resources/vue/components/I18nTextarea.vue
new file mode 100644
index 0000000000000000000000000000000000000000..c4d29768e3d6833e6247ba97fc3bb99b7c58f624
--- /dev/null
+++ b/resources/vue/components/I18nTextarea.vue
@@ -0,0 +1,190 @@
+<template>
+    <div class="i18n_group" v-if="languages.length > 1">
+        <div class="i18n"
+             v-for="language in languages"
+             v-if="(selectedLanguage !== null) && (language.id === selectedLanguage.id)"
+             :data-lang="language.name"
+             :data-icon="'url(' + assetsURL + 'images/languages/' + language.picture + ')'">
+            <input type=text
+                   :name="nameOfInput(language.id)"
+                   v-model="values[selectedLanguage.id]"
+                   :required="required && defaultLanguage === language.id"
+                   v-bind="$attrs"
+                   v-on="$listeners"
+                   v-if="type === 'text'">
+            <textarea :name="nameOfInput(language.id)"
+                      v-bind="$attrs"
+                      v-on="$listeners"
+                      v-model="values[language.id]"
+                      :required="required && defaultLanguage === language.id"
+                      v-else-if="type === 'textarea'">{{ values[language.id] }}</textarea>
+            <studip-wysiwyg :name="nameOfInput(language.id)"
+                            v-model="values[selectedLanguage.id]"
+                            v-bind="$attrs"
+                            v-on="$listeners"
+                            :required="required && defaultLanguage === language.id"
+                            v-else-if="type === 'wysiwyg' && !wysiwyg_disabled"></studip-wysiwyg>
+            <textarea-with-toolbar :name="nameOfInput(language.id)"
+                      v-else
+                      v-model="values[selectedLanguage.id]"
+                      v-bind="$attrs"
+                      :required="required && defaultLanguage === language.id"
+                      v-on="$listeners"></textarea-with-toolbar>
+        </div>
+        <input type="hidden"
+               v-for="language in languages"
+               v-if="(selectedLanguage !== null) && (language.id !== selectedLanguage.id)"
+               v-model="values[language.id]"
+               :required="required && defaultLanguage === language.id"
+               :name="nameOfInput(language.id)">
+        <select class="i18n"
+                tabindex="-1"
+                @change="selectLanguage"
+                :style="'background-image: url(' + assetsURL + 'images/languages/' + selectedLanguage.picture + ')'">
+            <option v-for="language in languages" :value="language.id">{{language.name}}</option>
+        </select>
+    </div>
+    <div v-else>
+        <input type=text
+               :name="name"
+               v-model="values[selectedLanguage.id]"
+               v-bind="$attrs"
+               v-on="$listeners"
+               :required="required"
+               v-if="type === 'text'">
+        <textarea :name="name"
+                  v-model="values[selectedLanguage.id]"
+                  v-bind="$attrs"
+                  v-on="$listeners"
+                  :required="required"
+                  v-else-if="type === 'textarea'"></textarea>
+        <studip-wysiwyg :name="name"
+                        v-model="values[selectedLanguage.id]"
+                        v-bind="$attrs"
+                        v-on="$listeners"
+                        :required="required"
+                        v-else-if="type === 'wysiwyg' && !wysiwyg_disabled"></studip-wysiwyg>
+        <textarea-with-toolbar :name="name"
+                  v-else
+                  v-model="values[selectedLanguage.id]"
+                  v-bind="$attrs"
+                  :required="required"
+                  v-on="$listeners"></textarea-with-toolbar>
+    </div>
+</template>
+
+<script>
+import StudipWysiwyg from './StudipWysiwyg.vue';
+export default {
+    name: 'i18n-textarea',
+    components: {
+        StudipWysiwyg
+    },
+    props: {
+        name: {
+            type: String,
+            required: false
+        },
+        wysiwyg: {
+            type: Boolean,
+            required: false,
+            default: false
+        },
+        type: {
+            type: String,
+            required: false,
+            default: "text"
+        },
+        value: {
+            required: false,
+            default: ""
+        },
+        wysiwyg_disabled: {
+            type: Boolean,
+            required: false,
+            default: false
+        },
+        required: {
+            type: Boolean,
+            required: false,
+            default: false
+        }
+    },
+    data () {
+        return {
+            selectedLanguage: {},
+            values: {}
+        };
+    },
+    mounted () {
+        for (let i in this.languages) {
+            this.selectedLanguage = this.languages[i];
+            break;
+        }
+        let jsonvalue = false;
+        try {
+            jsonvalue = JSON.parse(this.value);
+        } catch (except) {
+        }
+        if (jsonvalue !== false) {
+            this.values = jsonvalue;
+        } else {
+            let values = {};
+            values[this.selectedLanguage.id] = this.value;
+            this.values = values;
+        }
+    },
+    methods: {
+        selectLanguage (e) {
+            for (let i in this.languages) {
+                if (e.target.value === this.languages[i].id) {
+                    this.selectedLanguage = this.languages[i];
+                    break;
+                }
+            }
+        },
+        nameOfInput (language_id) {
+            return this.name + (this.defaultLanguage === language_id ? '' : '_i18n[' + language_id + ']')
+        }
+    },
+    computed: {
+        assetsURL () {
+            return STUDIP.ASSETS_URL;
+        },
+        defaultLanguage () {
+            return this.languages[0].id;
+        },
+        languages () {
+            let languages = [];
+            let language = {};
+            for (let i in STUDIP.CONTENT_LANGUAGES) {
+                if (STUDIP.INSTALLED_LANGUAGES[STUDIP.CONTENT_LANGUAGES[i]] !== undefined) {
+                    language = STUDIP.INSTALLED_LANGUAGES[STUDIP.CONTENT_LANGUAGES[i]];
+                    language.id = STUDIP.CONTENT_LANGUAGES[i];
+                    languages.push(language);
+                }
+            }
+            return languages;
+        }
+    },
+    inheritAttrs: false,
+    watch: {
+        values: {
+            handler(newValue, oldValue) {
+                this.$emit('input', newValue[this.defaultLanguage]);
+                let exportValue = {};
+                let input_all = {};
+                let name = null;
+                for (let i in this.languages) {
+                    exportValue[this.languages[i].id] = newValue[this.languages[i].id];
+                    name = this.nameOfInput(this.languages[i].id);
+                    input_all[name] = newValue[this.languages[i].id];
+                }
+                this.$emit('input_all_languages', exportValue);
+                this.$emit('allinputs', input_all);
+            },
+            deep: true
+        }
+    }
+}
+</script>
diff --git a/resources/vue/components/Multiselect.vue b/resources/vue/components/Multiselect.vue
new file mode 100644
index 0000000000000000000000000000000000000000..e1f180347313544098308297efde518183bdd5c2
--- /dev/null
+++ b/resources/vue/components/Multiselect.vue
@@ -0,0 +1,65 @@
+<template>
+    <v-select
+        multiple
+        v-model="selected"
+        :options="transformed_options"
+        :reduce="(option) => option.id"
+        v-bind="$attrs"
+        v-on="$listeners"
+    >
+        <div slot="no-options"><translate>Keine Auswahlmöglichkeiten</translate></div>
+    </v-select>
+</template>
+
+<script>
+import vSelect from 'vue-select';
+import 'vue-select/dist/vue-select.css'
+export default {
+    name: 'multiselect',
+    components: {
+        vSelect,
+    },
+    inheritAttrs: false,
+    props: {
+        name: {
+            type: String,
+            required: false
+        },
+        value: {
+            required: false
+        },
+        options: {
+            type: Object,
+            required: true
+        }
+    },
+    data () {
+        return {
+            selected: []
+        };
+    },
+    computed: {
+        transformed_options () {
+            let output = [];
+            Object.entries(this.options).forEach(obj => {
+                output.push({
+                    id: obj[0],
+                    label: obj[1]
+                });
+            });
+            return output;
+        }
+    },
+    mounted () {
+        this.selected = this.value;
+    },
+    watch: {
+        selected: {
+            handler(newValue, oldValue) {
+                this.$emit('input', newValue);
+            },
+            deep: true
+        }
+    }
+}
+</script>
diff --git a/resources/vue/components/Quicksearch.vue b/resources/vue/components/Quicksearch.vue
index 23fd5f4e6af2e105c32d8f7148b43de07c3c9166..94b0a3a89246a93a16ba5658d90f886eb0f727ae 100644
--- a/resources/vue/components/Quicksearch.vue
+++ b/resources/vue/components/Quicksearch.vue
@@ -3,7 +3,7 @@
         <input type="hidden"
                :name="name"
                :value="returnValue"
-               v-if="!autocomplete">
+               v-if="!autocomplete && name">
         <input type="text"
                :name="autocomplete ? name : null"
                v-model="inputValue"
@@ -38,7 +38,7 @@ export default {
         },
         name: {
             type: String,
-            required: true
+            required: false
         },
         value: {
             type: String,
@@ -61,6 +61,7 @@ export default {
             default: ''
         }
     },
+    inheritAttrs: false,
     data () {
         return {
             searching: false,
@@ -74,9 +75,9 @@ export default {
         };
     },
     methods: {
-        initialize (value) {
+        initialize (value, displayname) {
             this.initialValue = value;
-            this.inputValue = value;
+            this.inputValue = displayname ?? value;
             this.returnValue = value;
         },
         search (needle) {
@@ -117,7 +118,7 @@ export default {
             }
             this.results = [];
 
-            this.$emit('input', this.returnValue);
+            this.$emit('input', this.returnValue, this.inputValue);
         },
         selectUp () {
             if (this.selected > 0) {
@@ -156,7 +157,10 @@ export default {
         }
     },
     created () {
-        this.initialize(this.autocomplete ? this.value : this.needle);
+        this.initialize(
+            this.value,
+            this.autocomplete ? this.value : this.needle
+        );
     },
     computed: {
         isVisible() {
@@ -168,8 +172,8 @@ export default {
             this.reset(true);
             this.initialize(val);
         },
-        inputValue (needle) {
-            if (this.initialValue !== needle && needle.length > 2) {
+        inputValue (needle, oldneedle) {
+            if (oldneedle !== null && (oldneedle !== needle) && needle.length > 2) {
                 this.search(needle);
             }
         }
diff --git a/resources/vue/components/RangeInput.vue b/resources/vue/components/RangeInput.vue
new file mode 100644
index 0000000000000000000000000000000000000000..d60d525013a61286af866084b8ce658c05d87b98
--- /dev/null
+++ b/resources/vue/components/RangeInput.vue
@@ -0,0 +1,67 @@
+<template>
+    <div class="formpart range_input">
+        <input type="range"
+               :name="name"
+               :min="min"
+               :max="max"
+               :step="step"
+               :aria-valuemin="min"
+               :aria-valuemax="max"
+               :aria-valuenow="myValue"
+               v-bind="$attrs"
+               v-on="$listeners"
+               v-model="myValue">
+        <output for="fader"><translate :translate-params="{myValue: myValue ?? '1', max: max}">%{myValue} von %{max}</translate></output>
+    </div>
+</template>
+
+<script>
+export default {
+    name: 'range-input',
+    props: {
+        name: {
+            type: String,
+            required: true
+        },
+        value: {
+            required: false,
+            default: 1
+        },
+        min: {
+            type: Number,
+            required: false,
+            default: 1
+        },
+        max: {
+            type: Number,
+            required: false,
+            default: 10
+        },
+        step: {
+            type: Number,
+            required: false,
+            default: 1
+        }
+    },
+    data () {
+        return {
+            myValue: 1
+        };
+    },
+    mounted () {
+        this.myValue = this.value > this.min ? this.value : this.min;
+        if (this.myValue > this.max) {
+            this.myValue = this.max;
+        }
+    },
+    inheritAttrs: false,
+    watch: {
+        myValue: {
+            handler(newValue, oldValue) {
+                this.$emit('input', newValue);
+            },
+            deep: true
+        }
+    }
+}
+</script>
diff --git a/resources/vue/components/StudipWysiwyg.vue b/resources/vue/components/StudipWysiwyg.vue
index 707fb55d3eb7a6df2e583b782f4f15f9a533f542..bdeacfa5c04dab85cce1deb41dcee025f01e00fd 100755
--- a/resources/vue/components/StudipWysiwyg.vue
+++ b/resources/vue/components/StudipWysiwyg.vue
@@ -1,8 +1,9 @@
 <template>
-    <textarea 
+    <textarea
         :value="value"
         ref="studip_wysiwyg"
         class="studip-wysiwyg"
+        :required="required"
         @input="updateValue($event.target.value)"/>
 </template>
 
@@ -11,7 +12,12 @@
 export default {
     name: 'studip-wysiwyg',
     props: {
-        value: String
+        value: String,
+        required: {
+            type: Boolean,
+            required: false,
+            default: false
+        }
     },
     data() {
         return {
@@ -49,7 +55,3 @@ export default {
 
 }
 </script>
-
-<style>
-
-</style>
\ No newline at end of file
diff --git a/resources/vue/components/TextareaWithToolbar.vue b/resources/vue/components/TextareaWithToolbar.vue
new file mode 100644
index 0000000000000000000000000000000000000000..1f19864a54ab82dd1f6f5fa749c016b6ea45462f
--- /dev/null
+++ b/resources/vue/components/TextareaWithToolbar.vue
@@ -0,0 +1,19 @@
+<template>
+    <textarea :name="name" ref="textarea"
+              v-bind="$attrs" v-on="$listeners"></textarea>
+</template>
+
+<script>
+export default {
+    name: 'textarea-with-toolbar',
+    props: {
+        name: {
+            type: String,
+            required: false
+        }
+    },
+    mounted () {
+        $(this.$refs.textarea).addToolbar();
+    }
+}
+</script>
diff --git a/templates/forms/calculator_input.php b/templates/forms/calculator_input.php
new file mode 100644
index 0000000000000000000000000000000000000000..95e33c09295e729b396a37c15b1c80802b33007d
--- /dev/null
+++ b/templates/forms/calculator_input.php
@@ -0,0 +1,4 @@
+<div class="formpart">
+    <?= htmlReady($title) ?>
+    <output class="calculator_result" <?= $attributes ?>>{{ <?= htmlReady($value) ?> }}</output>
+</div>
diff --git a/templates/forms/checkbox_input.php b/templates/forms/checkbox_input.php
new file mode 100644
index 0000000000000000000000000000000000000000..b689d696f8e32b50b91cc8f6f78b531b3f303b6f
--- /dev/null
+++ b/templates/forms/checkbox_input.php
@@ -0,0 +1,16 @@
+<input type="hidden" name="<?= htmlReady($name) ?>" value="0">
+    <label<?= ($required ? ' class="studiprequired"' : '') ?>>
+    <input type="checkbox"
+           v-model="<?= htmlReady($this->name) ?>"
+           name="<?= htmlReady($this->name) ?>"
+           value="1"
+           id="<?= $id ?>"
+           <?= ($this->required ? 'required aria-required="true"' : '') ?>
+           <?= $attributes ?>>
+    <span class="textlabel">
+        <?= htmlReady($this->title) ?>
+    </span>
+    <? if ($this->required) : ?>
+        <span class="asterisk" title="<?= _('Dies ist ein Pflichtfeld') ?>" aria-hidden="true">*</span>
+    <? endif ?>
+</label>
diff --git a/templates/forms/datetimepicker_input.php b/templates/forms/datetimepicker_input.php
new file mode 100644
index 0000000000000000000000000000000000000000..f2d6b9271b9a450ee40074c33465b6824165b670
--- /dev/null
+++ b/templates/forms/datetimepicker_input.php
@@ -0,0 +1,15 @@
+<div class="formpart">
+    <label<?= ($this->required ? ' class="studiprequired"' : '') ?> for="<?= $id ?>">
+        <span class="textlabel">
+            <?= htmlReady($this->title) ?>
+        </span>
+        <? if ($this->required) : ?>
+            <span class="asterisk" title="<?= _('Dies ist ein Pflichtfeld') ?>" aria-hidden="true">*</span>
+        <? endif ?>
+    </label>
+    <datetimepicker v-model="<?= htmlReady($name) ?>"
+                    name="<?= htmlReady($name) ?>"
+                    id="<?= $id ?>"
+                    <?= ($this->required ? 'required aria-required="true"' : '')?>
+                    <?= $attributes ?>>
+</div>
diff --git a/templates/forms/fieldset.php b/templates/forms/fieldset.php
new file mode 100644
index 0000000000000000000000000000000000000000..491f7260ea121df88b6e47befb72f3c1fc696c1b
--- /dev/null
+++ b/templates/forms/fieldset.php
@@ -0,0 +1,8 @@
+<fieldset>
+    <? if ($legend) : ?>
+        <legend><?= htmlReady($this->legend) ?></legend>
+    <? endif ?>
+    <? foreach ($parts as $part) : ?>
+        <?= $part->renderWithCondition() ?>
+    <? endforeach ?>
+</fieldset>
diff --git a/templates/forms/form.php b/templates/forms/form.php
new file mode 100644
index 0000000000000000000000000000000000000000..72a1b25838e445ed550ff90ca58fbcaf96959711
--- /dev/null
+++ b/templates/forms/form.php
@@ -0,0 +1,67 @@
+<?
+$inputs = [];
+$allinputs = $form->getAllInputs();
+$required_inputs = [];
+foreach ($allinputs as $input) {
+    foreach ($input->getAllInputNames() as $name) {
+        $inputs[$name] = $input->getValue();
+    }
+
+    if ($input->required) {
+        $required_inputs[] = $input->getName();
+    }
+}
+$form_id = md5(uniqid());
+?><form v-cloak
+      method="post"
+      <? if (!$form->isAutoStoring()) : ?>
+          action="<?= htmlReady($form->getURL()) ?>"
+      <? else : ?>
+          data-autosave="<?= htmlReady($_SERVER['REQUEST_URI']) ?>"
+          data-url="<?= htmlReady($form->getURL()) ?>"
+      <? endif ?>
+      @submit="submit"
+      novalidate
+      id="<?= htmlReady($form_id) ?>"
+      data-inputs="<?= htmlReady(json_encode($inputs)) ?>"
+      data-required="<?= htmlReady(json_encode($required_inputs)) ?>"
+      class="default studipform<?= $form->isCollapsable() ? ' collapsable' : '' ?>">
+
+    <?= CSRFProtection::tokenTag(['ref' => 'securityToken']) ?>
+
+    <article aria-live="assertive"
+             class="validation_notes studip"
+             v-if="(STUDIPFORM_REQUIRED.length > 0) || STUDIPFORM_DISPLAYVALIDATION">
+        <header>
+            <h1>
+                <?= Icon::create('info-circle', Icon::ROLE_INFO)->asImg(17, ['class' => "text-bottom validation_notes_icon"]) ?>
+                <?= _('Hinweise zum Ausfüllen des Formulars') ?>
+            </h1>
+        </header>
+        <div class="required_note" v-if="STUDIPFORM_REQUIRED.length > 0">
+            <div aria-hidden>
+                <?= _('Pflichtfelder sind mit Sternchen gekennzeichnet.') ?>
+            </div>
+            <div class="sr-only">
+                <?= _('Dieses Formular enthält Pflichtfelder.') ?>
+            </div>
+
+        </div>
+        <div v-if="STUDIPFORM_DISPLAYVALIDATION && (STUDIPFORM_VALIDATIONNOTES.length > 0)">
+            <?= _('Folgende Angaben müssen korrigiert werden, um das Formular abschicken zu können:') ?>
+            <ul>
+                <li v-for="note in STUDIPFORM_VALIDATIONNOTES" :aria-describedby="note.describedby">{{ note.name + ": " + note.description }}</li>
+            </ul>
+        </div>
+    </article>
+
+    <div aria-live="polite">
+    <? foreach ($form->getParts() as $part) : ?>
+        <?= $part->renderWithCondition() ?>
+    <? endforeach ?>
+    </div>
+</form>
+
+<div data-dialog-button>
+    <?= \Studip\Button::create(_('Speichern'), null, ['form' => $form_id]) ?>
+</div>
diff --git a/templates/forms/hidden_input.php b/templates/forms/hidden_input.php
new file mode 100644
index 0000000000000000000000000000000000000000..27824cd016bb0912cf3bdef2ffceb4e9fffcf421
--- /dev/null
+++ b/templates/forms/hidden_input.php
@@ -0,0 +1,4 @@
+<input type="hidden"
+       name="<?= htmlReady($this->name) ?>"
+       value="<?= htmlReady($this->value) ?>"
+       id="<?= $id ?>" <?= $attributes ?>>
diff --git a/templates/forms/i18n_formatted_input.php b/templates/forms/i18n_formatted_input.php
new file mode 100644
index 0000000000000000000000000000000000000000..4e667f5f2bf8f518157827ad867feddfae790ffd
--- /dev/null
+++ b/templates/forms/i18n_formatted_input.php
@@ -0,0 +1,17 @@
+<div class="formpart">
+    <label<?= ($this->required ? ' class="studiprequired"' : '') ?> for="<?= $id ?>">
+        <span class="textlabel">
+            <?= htmlReady($this->title) ?>
+        </span>
+        <? if ($this->required) : ?>
+            <span class="asterisk" title="<?= _('Dies ist ein Pflichtfeld') ?>" aria-hidden="true">*</span>
+        <? endif ?>
+    </label>
+    <i18n-textarea type="wysiwyg"
+                   id="<?= $id ?>"
+                   name="<?= htmlReady($name) ?>"
+                   value="<?= htmlReady($value) ?>"
+                   @allinputs="setInputs"
+                   :wysiwyg_disabled="<?= \Config::get()->WYSIWYG ? 'false' : 'true' ?>" <?= $required ? 'required' : '' ?>>
+    </i18n-textarea>
+    </div>
diff --git a/templates/forms/i18n_text_input.php b/templates/forms/i18n_text_input.php
new file mode 100644
index 0000000000000000000000000000000000000000..5e99cd1cb517204fd6b832e845530f80653b4bc4
--- /dev/null
+++ b/templates/forms/i18n_text_input.php
@@ -0,0 +1,17 @@
+<div class="formpart">
+    <label<?= ($this->required ? ' class="studiprequired"' : '') ?> for="<?= $id ?>">
+        <span class="textlabel">
+            <?= htmlReady($this->title) ?>
+        </span>
+        <? if ($this->required) : ?>
+            <span class="asterisk" title="<?= _('Dies ist ein Pflichtfeld') ?>" aria-hidden="true">*</span>
+        <? endif ?>
+    </label>
+    <i18n-textarea type="text"
+                   id="<?= $id ?>"
+                   name="<?= htmlReady($this->name) ?>"
+                   value="<?= htmlReady($value) ?>"
+                   <?= $required ? 'required' : '' ?>
+                   @allinputs="setInputs">
+    </i18n-textarea>
+</div>
diff --git a/templates/forms/i18n_textarea_input.php b/templates/forms/i18n_textarea_input.php
new file mode 100644
index 0000000000000000000000000000000000000000..3209b6827e5e723e43d5e22ebeadcdb17dbb8f88
--- /dev/null
+++ b/templates/forms/i18n_textarea_input.php
@@ -0,0 +1,17 @@
+<div class="formpart">
+    <label<?= ($this->required ? ' class="studiprequired"' : '') ?> for="<?= $id ?>">
+        <span class="textlabel">
+            <?= htmlReady($this->title) ?>
+        </span>
+        <? if ($this->required) : ?>
+            <span class="asterisk" title="<?= _('Dies ist ein Pflichtfeld') ?>" aria-hidden="true">*</span>
+        <? endif ?>
+    </label>
+    <i18n-textarea type="textarea"
+                   id="<?= $id ?>"
+                   name="<?= htmlReady($this->name) ?>"
+                   value="<?= htmlReady($value) ?>"
+                   <?= $required ? 'required' : '' ?>
+                   @allinputs="setInputs">
+    </i18n-textarea>
+</div>
diff --git a/templates/forms/input_row.php b/templates/forms/input_row.php
new file mode 100644
index 0000000000000000000000000000000000000000..6bf522340b9eb86e629cad0d6599dca0e996f25a
--- /dev/null
+++ b/templates/forms/input_row.php
@@ -0,0 +1,5 @@
+<div class="hgroup">
+    <? foreach ($parts as $part) : ?>
+        <?= $part->renderWithCondition() ?>
+    <? endforeach ?>
+</div>
diff --git a/templates/forms/multiselect_input.php b/templates/forms/multiselect_input.php
new file mode 100644
index 0000000000000000000000000000000000000000..cd9aec6391dd90532d910972a475f71e3bf2bf2a
--- /dev/null
+++ b/templates/forms/multiselect_input.php
@@ -0,0 +1,17 @@
+<div class="formpart">
+    <label<?= ($this->required ? ' class="studiprequired"' : '') ?> for="<?= $id ?>">
+        <span class="textlabel">
+            <?= htmlReady($this->title) ?>
+        </span>
+        <? if ($this->required) : ?>
+            <span class="asterisk" title="<?= _('Dies ist ein Pflichtfeld') ?>" aria-hidden="true">*</span>
+        <? endif ?>
+    </label>
+    <multiselect name="<?= htmlReady($name) ?>[]" <?= ($required ? 'required aria-required="true"' : '') ?>
+                 :options="<?= htmlReady(json_encode($options)) ?>"
+                 :value="<?= htmlReady(json_encode($value)) ?>"
+                 v-model="<?= htmlReady($name) ?>"
+                 id="<?= $id ?>"
+                 <?= $attributes ?>>
+    </multiselect>
+</div>
diff --git a/templates/forms/news_ranges_input.php b/templates/forms/news_ranges_input.php
new file mode 100644
index 0000000000000000000000000000000000000000..ac1fe51f8a55b8f3f4a300efde407cc6a1c97a7c
--- /dev/null
+++ b/templates/forms/news_ranges_input.php
@@ -0,0 +1,7 @@
+<editable-list name="<?= htmlReady($this->name) ?>"
+               quicksearch="<?= htmlReady((string) $searchtype) ?>"
+               :items="<?= htmlReady(json_encode($items)) ?>"
+               :selectable="<?= htmlReady(json_encode($selectable)) ?>"
+               :category_order="<?= htmlReady(json_encode($category_order)) ?>"
+               @input="output => <?= htmlReady($this->name) ?> = output">
+</editable-list>
diff --git a/templates/forms/number_input.php b/templates/forms/number_input.php
new file mode 100644
index 0000000000000000000000000000000000000000..622553a6fac10ae15142a7f2628045c47ec31db4
--- /dev/null
+++ b/templates/forms/number_input.php
@@ -0,0 +1,14 @@
+<label<?= ($this->required ? ' class="studiprequired"' : '') ?> for="<?= $id ?>">
+    <span class="textlabel">
+        <?= htmlReady($this->title) ?>
+    </span>
+    <? if ($this->required) : ?>
+        <span class="asterisk" title="<?= _('Dies ist ein Pflichtfeld') ?>" aria-hidden="true">*</span>
+    <? endif ?>
+</label>
+<input type="number"
+       name="<?= htmlReady($name) ?>"
+       value="<?= htmlReady($value) ?>"
+       id="<?= $id ?>"
+       <?= ($required ? 'required aria-required="true"' : '') ?>
+       <?= $attributes ?>>
diff --git a/templates/forms/quicksearch_input.php b/templates/forms/quicksearch_input.php
new file mode 100644
index 0000000000000000000000000000000000000000..5a8fadd218eb23307a1bab40c419f98dfcd430d9
--- /dev/null
+++ b/templates/forms/quicksearch_input.php
@@ -0,0 +1,17 @@
+<div class="formpart">
+    <label<?= ($this->required ? ' class="studiprequired"' : '') ?> for="<?= $id ?>">
+        <span class="textlabel">
+            <?= htmlReady($this->title) ?>
+        </span>
+        <? if ($this->required) : ?>
+            <span class="asterisk" title="<?= _('Dies ist ein Pflichtfeld') ?>" aria-hidden="true">*</span>
+        <? endif ?>
+    </label>
+    <span>
+        <quicksearch value="<?= htmlReady($value) ?>"
+                     name="<?= htmlReady($name) ?>"
+                     id="<?= $id ?>"
+                     <?= ($this->required ? 'required aria-required="true"' : '') ?>
+                     <?= $attributes ?>>
+    </span>'
+</div>
diff --git a/templates/forms/range_input.php b/templates/forms/range_input.php
new file mode 100644
index 0000000000000000000000000000000000000000..c85b02d3a398d3bd3f76e2a25a5f23d0a64eb1f5
--- /dev/null
+++ b/templates/forms/range_input.php
@@ -0,0 +1,13 @@
+<label<?= ($this->required ? ' class="studiprequired"' : '') ?> for="<?= $id ?>">
+    <span class="textlabel">
+        <?= htmlReady($this->title) ?>
+    </span>
+    <? if ($this->required) : ?>
+        <span class="asterisk" title="<?= _('Dies ist ein Pflichtfeld') ?>" aria-hidden="true">*</span>
+    <? endif ?>
+</label>
+<range-input v-model="<?= htmlReady($name) ?>"
+             name="<?= htmlReady($name) ?>"
+             value="<?= htmlReady($value) ?>"
+             id="<?= $id ?>"
+             <?= $attributes ?>></range-input>
diff --git a/templates/forms/select_input.php b/templates/forms/select_input.php
new file mode 100644
index 0000000000000000000000000000000000000000..52d67768193ecebf1781bee2c50205db37271d7b
--- /dev/null
+++ b/templates/forms/select_input.php
@@ -0,0 +1,17 @@
+<div class="formpart">
+    <label<?= ($this->required ? ' class="studiprequired"' : '') ?> for="<?= $id ?>">
+        <span class="textlabel">
+            <?= htmlReady($this->title) ?>
+        </span>
+        <? if ($this->required) : ?>
+            <span class="asterisk" title="<?= _('Dies ist ein Pflichtfeld') ?>" aria-hidden="true">*</span>
+        <? endif ?>
+    </label>
+    <select class="select2" name="<?= htmlReady($this->name) ?>" <?= ($this->required ? 'required aria-required="true"' : '') ?> id="<?= $id ?>" <?= $attributes ?>>
+    <? foreach ($options as $key => $option) : ?>
+        <option value="<?= htmlReady($key) ?>"<?= ($key == $value ? " selected" : "") ?>>
+            <?= htmlReady($option) ?>
+        </option>
+    <? endforeach ?>
+    </select>
+</div>
diff --git a/templates/forms/text_input.php b/templates/forms/text_input.php
new file mode 100644
index 0000000000000000000000000000000000000000..546a125766ad5f0604db6def06d5d9ce1010c759
--- /dev/null
+++ b/templates/forms/text_input.php
@@ -0,0 +1,16 @@
+<div class="formpart">
+    <label<?= ($this->required ? ' class="studiprequired"' : '') ?> for="<?= $id ?>">
+        <span class="textlabel">
+            <?= htmlReady($this->title) ?>
+        </span>
+        <? if ($this->required) : ?>
+            <span class="asterisk" title="<?= _('Dies ist ein Pflichtfeld') ?>" aria-hidden="true">*</span>
+        <? endif ?>
+    </label>
+    <input type="text"
+           v-model="<?= htmlReady($this->name) ?>"
+           name="<?= htmlReady($this->name) ?>"
+           value="<?= htmlReady($this->value) ?>"
+           id="<?= $id ?>" <?= ($this->required ? 'required aria-required="true"' : '') ?>
+           <?= $attributes ?>>
+</div>
diff --git a/templates/forms/textarea_input.php b/templates/forms/textarea_input.php
new file mode 100644
index 0000000000000000000000000000000000000000..2afa96c381027ba880a10acbb088ed1021cdd73d
--- /dev/null
+++ b/templates/forms/textarea_input.php
@@ -0,0 +1,13 @@
+<label<?= ($this->required ? ' class="studiprequired"' : '') ?> for="<?= $id ?>">
+    <span class="textlabel">
+        <?= htmlReady($this->title) ?>
+    </span>
+    <? if ($this->required) : ?>
+        <span class="asterisk" title="<?= _('Dies ist ein Pflichtfeld') ?>" aria-hidden="true">*</span>
+    <? endif ?>
+</label>
+<textarea name="<?= htmlReady($name) ?>"
+          v-model="<?= htmlReady($name) ?>"
+          id="<?= $id ?>"
+          <?= ($required ? 'required aria-required="true"' : '') ?>
+          <?= $attributes ?>><?= htmlReady($value) ?></textarea>
diff --git a/templates/layouts/base.php b/templates/layouts/base.php
index 2528a642bfa86aa267c5a7a80a3a07429f6a3868..a0dc7136ff8abaaeb4fd342edc47a217e80c17d5 100644
--- a/templates/layouts/base.php
+++ b/templates/layouts/base.php
@@ -72,6 +72,7 @@ $getInstalledLanguages = function () {
                 value: '<? try {echo CSRFProtection::token();} catch (SessionRequiredException $e){}?>'
             },
             INSTALLED_LANGUAGES: <?= json_encode($getInstalledLanguages()) ?>,
+            CONTENT_LANGUAGES: <?= json_encode(array_keys($GLOBALS['CONTENT_LANGUAGES'])) ?>,
             STUDIP_SHORT_NAME: "<?= htmlReady(Config::get()->STUDIP_SHORT_NAME) ?>",
             URLHelper: {
                 base_url: "<?= $GLOBALS['ABSOLUTE_URI_STUDIP'] ?>",