diff options
| author | Jan-Hendrik Willms <tleilax+studip@gmail.com> | 2025-02-27 15:43:55 +0000 |
|---|---|---|
| committer | David Siegfried <david.siegfried@uni-vechta.de> | 2025-02-27 15:43:55 +0000 |
| commit | 8ba78ce50c8cf61ad2df91ffaa19952bb5f4fff9 (patch) | |
| tree | 6bc5d5674cc9905a9e190fab21b7270b564d80c0 | |
| parent | 257f38c62b0847f76fc477c6e014c5d2a0a5bf54 (diff) | |
replace old colour group selector and notifications with vue app that uses the same underlying data as my courses, relocate my courses vue components and remove now obsolete functions from meine_seminare_func, fixes #5165, fixes #5201
Closes #5165 and #5201
Merge request studip/studip!3892
33 files changed, 1167 insertions, 1306 deletions
diff --git a/app/controllers/admin/courses.php b/app/controllers/admin/courses.php index 0d65e13..b01cfa1 100644 --- a/app/controllers/admin/courses.php +++ b/app/controllers/admin/courses.php @@ -23,7 +23,6 @@ * @since 3.1 */ -require_once 'lib/meine_seminare_func.inc.php'; require_once 'lib/object.inc.php'; require_once 'lib/archiv.inc.php'; //for lastActivity in getCourses() method diff --git a/app/controllers/my_courses.php b/app/controllers/my_courses.php index a8d9797..3a422bf 100644 --- a/app/controllers/my_courses.php +++ b/app/controllers/my_courses.php @@ -23,15 +23,17 @@ * @category Stud.IP * @since 3.1 */ -require_once 'lib/meine_seminare_func.inc.php'; + +use DI\Attribute\Inject; + require_once 'lib/object.inc.php'; class MyCoursesController extends AuthenticatedController { - /** - * @var Callable - */ - private $performance_timer = null; + #[Inject] + private readonly MyCoursesHelper $helper; + + private ?Closure $performance_timer = null; public function before_filter(&$action, &$args) { @@ -84,13 +86,7 @@ class MyCoursesController extends AuthenticatedController $sem_key = $this->getSemesterKey(); $group_field = $this->getGroupField(); - $sem_courses = MyRealmModel::getPreparedCourses($sem_key, [ - 'group_field' => $group_field, - 'order_by' => null, - 'order' => 'asc', - 'studygroups_enabled' => Config::get()->MY_COURSES_ENABLE_STUDYGROUPS, - 'deputies_enabled' => Config::get()->DEPUTIES_ENABLE, - ]); + $sem_courses = $this->helper->getCourses($sem_key, $group_field); // Waiting list $this->waiting_list = MyRealmModel::getWaitingList($GLOBALS['user']->id); @@ -114,11 +110,11 @@ class MyCoursesController extends AuthenticatedController $this->setupSidebar($sem_key, $group_field, $this->check_for_new($sem_courses, $group_field)); - $this->vueApp = Studip\VueApp::create('MyCourses') + $this->vueApp = Studip\VueApp::create('my-courses/MyCourses') ->withStore( 'MyCoursesStore', 'mycourses', - $this->getMyCoursesData($sem_courses, $group_field) + $this->helper->getVueAppData($sem_courses, $group_field) ); } @@ -165,69 +161,24 @@ class MyCoursesController extends AuthenticatedController PageLayout::setTitle($this->title); - PageLayout::setHelpKeyword('Basis.VeranstaltungenOrdnen'); Navigation::activateItem('/browse/my_courses/list'); $this->current_semester = $sem ?: Semester::findCurrent()->semester_id; $this->semesters = Semester::findAllVisible(); - $group_field = $this->getGroupField(); - - $temp = MyRealmModel::getPreparedCourses('', [ - 'group_field' => $group_field, - 'order_by' => null, - 'order' => 'asc', - 'studygroups_enabled' => Config::get()->MY_COURSES_ENABLE_STUDYGROUPS, - 'deputies_enabled' => Config::get()->DEPUTIES_ENABLE, - ]); - - $groups = []; - $my_sem = []; - foreach ($temp as $courses) { - foreach ($courses as $course) { - $my_sem[$course['seminar_id']] = $course; - if ($group_field) { - fill_groups($groups, $course[$group_field], [ - 'seminar_id' => $course['seminar_id'], - 'sem_nr' => $course['veranstaltungsnummer'], - 'name' => $course['name'], - 'gruppe' => $course['gruppe'] - ]); - } - } - } - - if ($group_field == 'sem_number') { - correct_group_sem_number($groups, $my_sem); - } else { - add_sem_name($my_sem); - } - - sort_groups($group_field, $groups); - - // Ensure that a seminar is never in multiple groups - $sem_ids = []; - foreach ($groups as $group_id => $seminars) { - foreach ($seminars as $index => $seminar) { - if (in_array($seminar['seminar_id'], $sem_ids)) { - unset($seminars[$index]); - } else { - $sem_ids[] = $seminar['seminar_id']; - } - } - if (empty($seminars)) { - unset($groups[$group_id]); - } else { - $groups[$group_id] = $seminars; - } - } - $this->studygroups = $studygroups; - $this->groups = $groups; - $this->group_names = get_group_names($group_field, $groups); - $this->group_field = $group_field; - $this->my_sem = $my_sem; - $this->cid = Request::get('cid', ''); + $this->render_vue_app( + Studip\VueApp::create('my-courses/ColorGroupSelector') + ->withProps([ + 'store-url' => $this->store_groupsURL($studygroups), + 'cid' => Request::get('option', ''), + ]) + ->withStore( + 'MyCoursesStore', + 'mycoursesgroupselector', + $this->helper->createVueAppData(''), + ) + ); } /** @@ -246,33 +197,34 @@ class MyCoursesController extends AuthenticatedController Request::get('select_group_field', $GLOBALS['user']->cfg->MY_COURSES_GROUPING) ); $gruppe = Request::getArray('gruppe'); - if (!empty($gruppe)) { - foreach ($gruppe as $key => $value) { - $updated = CourseMember::findEachBySQL( - function (CourseMember $cm) use ($value) { - $cm->gruppe = $value; - $cm->store(); + + if (count($gruppe) > 0) { + CourseMember::findEachBySQL( + function (CourseMember $member) use (&$gruppe) { + $member->gruppe = $gruppe[$member->seminar_id]; + $member->store(); + + unset($gruppe[$member->seminar_id]); + }, + 'user_id = ? AND Seminar_id IN (?)', + [ + User::findCurrent()->id, + array_keys($gruppe) + ] + ); + + if (count($gruppe) > 0 && $deputies_enabled) { + Deputy::findEachBySQL( + function (Deputy $deputy) use ($gruppe) { + $deputy->gruppe = $gruppe[$deputy->range_id]; + $deputy->store(); }, - 'Seminar_id = ? AND user_id = ?', + 'user_id = ? AND range_id IN ?', [ - $key, - $GLOBALS['user']->id + User::findCurrent()->id, + array_keys($gruppe), ] ); - - if ($deputies_enabled && !$updated) { - Deputy::findEachBySQL( - function (Deputy $deputy) use ($value) { - $deputy->gruppe = $value; - $deputy->store(); - }, - 'range_id = ? AND user_id = ?', - [ - $key, - $GLOBALS['user']->id - ] - ); - } } } @@ -653,125 +605,9 @@ class MyCoursesController extends AuthenticatedController $sem_key = $this->getSemesterKey(); $group_field = $this->getGroupField(); - $sem_courses = MyRealmModel::getPreparedCourses($sem_key, [ - 'group_field' => $group_field, - 'order_by' => null, - 'order' => 'asc', - 'studygroups_enabled' => Config::get()->MY_COURSES_ENABLE_STUDYGROUPS, - 'deputies_enabled' => Config::get()->DEPUTIES_ENABLE, - ]); - - return $this->getMyCoursesData($sem_courses, $group_field); - } - - /** - * Get the data array for presenting the course list in Vue. - * - * @param array|null $sem_courses - * @param string $group_field - * @return array{ - * courses: array, - * groups: array, - * user_id: string, - * config: array{ - * allow_dozent_visibility: bool, - * open_groups: array, - * sem_number: bool, - * display_type: string, - * responsive_type: string, - * navigation_show_only_new: bool, - * group_by: string - * } - * } - */ - private function getMyCoursesData(?array $sem_courses, string $group_field): array - { - $sem_data = Semester::getAllAsArray(); - $temp_courses = []; - $groups = []; - - if (is_array($sem_courses)) { - foreach ($sem_courses as $_outer_index => $_outer) { - if ($group_field === 'sem_number') { - $_courses = []; - - foreach ($_outer as $course) { - $_courses[$course['seminar_id']] = $course; - if (!empty($course['children']) && is_array($course['children'])) { - foreach ($course['children'] as $child) { - $_courses[$child['seminar_id']] = $child; - } - } - } - - if ($_outer_index) { - $groups[] = [ - 'id' => $_outer_index, - 'name' => (string)$sem_data[$_outer_index]['name'], - 'data' => [ - [ - 'id' => md5($_outer_index), - 'label' => false, - 'ids' => array_keys($_courses), - ], - ], - ]; - } - $temp_courses = array_merge($temp_courses, $_courses); - } else { - $count = 1; - $_groups = []; - foreach ($_outer as $_inner_index => $_inner) { - $_courses = []; - - foreach ($_inner as $course) { - $_courses[$course['seminar_id']] = $course; - if (isset($course['children']) && is_array($course['children'])) { - foreach ($course['children'] as $child) { - $_courses[$child['seminar_id']] = $child; - } - } - } - - $label = $_inner_index; - if ($group_field === 'sem_tree_id' && !$label) { - $label = _('keine Zuordnung'); - } elseif ($group_field === 'gruppe') { - $label = _('Gruppe') . ' ' . $count++; - } - - $_groups[] = [ - 'id' => md5($_outer_index . $_inner_index), - 'label' => $label, - 'ids' => array_keys($_courses), - ]; - - $temp_courses = array_merge($temp_courses, $_courses); - } + $sem_courses = $this->helper->getCourses($sem_key, $group_field); - if ($_outer_index) { - $groups[] = [ - 'id' => $_outer_index, - 'name' => (string)$sem_data[$_outer_index]['name'], - 'data' => $_groups, - ]; - } - } - } - } - - return [ - 'setCourses' => $this->sanitizeNavigations(array_map([$this, 'convertCourse'], $temp_courses)), - 'setGroups' => $groups, - 'setUserId' => $GLOBALS['user']->id, - 'setConfig' => [ - 'allow_dozent_visibility' => Config::get()->ALLOW_DOZENT_VISIBILITY, - 'open_groups' => array_values($GLOBALS['user']->cfg->MY_COURSES_OPEN_GROUPS), - 'sem_number' => Config::get()->IMPORTANT_SEMNUMBER, - 'view_settings' => $GLOBALS['user']->cfg->MY_COURSES_VIEW_SETTINGS, - 'group_by' => $this->getGroupField(), - ], - ]; + return $this->helper->getVueAppData($sem_courses, $group_field); } /** @@ -942,137 +778,6 @@ class MyCoursesController extends AuthenticatedController ); } - private function convertCourse($course) - { - $is_teacher = in_array($course['user_status'], ['tutor', 'dozent']); - - $avatar = $course['sem_class']['studygroup_mode'] - ? StudygroupAvatar::getAvatar($course['seminar_id']) - : CourseAvatar::getAvatar($course['seminar_id']); - - $extra_navigation = false; - if ($is_teacher) { - $adminmodule = $course['sem_class']->getAdminModuleObject(); - if ($adminmodule) { - $adminnavigation = $adminmodule->getIconNavigation($course['seminar_id'], 0, $GLOBALS['user']->id); - $extra_navigation = [ - 'url' => URLHelper::getURL($adminnavigation->getURL(), ['cid' => $course['seminar_id']]), - 'icon' => $adminnavigation->getImage()->getShape(), - 'label' => $adminnavigation->getLinkAttributes()['title'] ?? _('Verwaltung'), - ]; - } - } - - return [ - 'id' => (string) $course['seminar_id'], - 'name' => (string) $course['name'], - 'number' => (string) $course['veranstaltungsnummer'], - 'group' => (int) $course['gruppe'], - 'admission_binding' => (bool) $course['admission_binding'], - 'children' => array_column($course['children'] ?? [], 'seminar_id'), - 'parent' => $course['parent_course'] ?? null, - - 'is_teacher' => in_array($course['user_status'], ['tutor', 'dozent']), - 'is_studygroup' => (bool) $course['sem_class']['studygroup_mode'], - 'is_hidden' => !$course['visible'], - 'is_deputy' => (bool) $course['is_deputy'], - 'is_group' => (bool) $course['is_group'], - - 'avatar' => $avatar->getURL(Avatar::MEDIUM), - - 'navigation' => $this->reduceNavigation($course['navigation']), - 'extra_navigation' => $extra_navigation, - ]; - } - - private function reduceNavigation($nav) - { - if (!$nav) { - return []; - } - - $result = []; - foreach (MyRealmModel::array_rtrim($nav) as $key => $n) { - if (!$n || !$n->isVisible(true)) { - $item = false; - } else { - $attr = $n->getLinkAttributes(); - if (empty($attr['title']) && $n->getImage()) { - $attr['title'] = (string) ($n->getImage()->getAttributes()['title'] ?? ''); - } - if (empty($attr['title'])) { - $attr['title'] = (string) $n->getTitle(); - } - $attr['title'] = (string) $attr['title']; - - $item = [ - 'url' => $n->getURL(), - 'icon' => $this->convertIcon($n->getImage()), - 'attr' => $attr, - 'important' => $n->getImage()->signalsAttention(), - ]; - } - $result[$key] = $item; - } - - return $result; - } - - private function sanitizeNavigations(array $courses) - { - // Count occurences of slots - $counters = []; - foreach ($courses as $course) { - foreach ($course['navigation'] as $key => $value) { - if (!isset($counters[$key])) { - $counters[$key] = 0; - } - if ($value) { - $counters[$key] += 1; - } - } - } - - // Detect which slots are not set at all - $remove = array_keys(array_filter($counters, function ($counter) { - return !$counter; - })); - - // Set positions by predefined positions without the always empty slots - $positions = array_diff(array_keys(MyRealmModel::getDefaultModules()), $remove); - - // Get other positions based on count - arsort($counters); - foreach ($counters as $key => $count) { - if ($count && !in_array($key, $positions)) { - $positions[] = $key; - } - } - - // Sort and filter course navigations - return array_map( - function ($course) use ($positions) { - $course['navigation'] = array_filter($course['navigation'], function ($key) use ($positions) { - return in_array($key, $positions); - }, ARRAY_FILTER_USE_KEY); - uksort($course['navigation'], function ($a, $b) use ($positions) { - return array_search($a, $positions) - array_search($b, $positions); - }); - $course['navigation'] = array_values($course['navigation']); - return $course; - }, - $courses - ); - } - - private function convertIcon(Icon $icon) - { - return [ - 'role' => $icon->getRole(), - 'shape' => $icon->getShape(), - ]; - } - private function getSemesterKey() { $config_sem = $GLOBALS['user']->cfg->MY_COURSES_SELECTED_CYCLE; @@ -1104,7 +809,7 @@ class MyCoursesController extends AuthenticatedController { $group_field = $GLOBALS['user']->cfg->MY_COURSES_GROUPING; - $forced_grouping = in_array(Config::get()->MY_COURSES_FORCE_GROUPING, getValidGroupingFields()) + $forced_grouping = in_array(Config::get()->MY_COURSES_FORCE_GROUPING, $this->getValidGroupingFields()) ? Config::get()->MY_COURSES_FORCE_GROUPING : 'sem_number'; @@ -1112,7 +817,7 @@ class MyCoursesController extends AuthenticatedController $forced_grouping = 'sem_number'; } - if (!$group_field || !in_array($group_field, getValidGroupingFields())) { + if (!$group_field || !in_array($group_field, $this->getValidGroupingFields())) { $group_field = 'sem_number'; } @@ -1123,6 +828,24 @@ class MyCoursesController extends AuthenticatedController return $group_field === 'not_grouped' ? 'sem_number' : $group_field; } + private function getValidGroupingFields(): array + { + $valid = [ + 'not_grouped', + 'sem_number', + 'sem_tree_id', + 'sem_status', + 'gruppe', + 'dozent_id', + ]; + + if (LvgruppeSeminar::countBySql('1') > 0) { + $valid[] = 'mvv'; + } + + return $valid; + } + /** * Returns all valid textual semester entries like 'last', 'future' etc * diff --git a/app/controllers/my_institutes.php b/app/controllers/my_institutes.php index 6093e72..31e25a6 100644 --- a/app/controllers/my_institutes.php +++ b/app/controllers/my_institutes.php @@ -1,6 +1,4 @@ <?php -require_once 'lib/meine_seminare_func.inc.php'; - class MyInstitutesController extends AuthenticatedController { public function before_filter(&$action, &$args) diff --git a/app/controllers/settings/notification.php b/app/controllers/settings/notification.php index a5aa276..dfb3dbe 100644 --- a/app/controllers/settings/notification.php +++ b/app/controllers/settings/notification.php @@ -14,7 +14,7 @@ * @since 2.4 */ -require_once 'settings.php'; +require_once __DIR__ . '/settings.php'; class Settings_NotificationController extends Settings_SettingsController { @@ -32,7 +32,7 @@ class Settings_NotificationController extends Settings_SettingsController { parent::before_filter($action, $args); - if (!Config::get()->MAIL_NOTIFICATION_ENABLE) { + if (!Config::get()->getValue('MAIL_NOTIFICATION_ENABLE')) { $message = _('Die Benachrichtigungsfunktion wurde in den Systemeinstellungen nicht freigeschaltet.'); throw new AccessDeniedException($message); } @@ -49,153 +49,76 @@ class Settings_NotificationController extends Settings_SettingsController /** * Display the notification settings of a user. */ - public function index_action() + public function index_action(): void { - $group_field = 'sem_number'; $semesters = Semester::findAllVisible(); $seminars = MyRealmModel::getCourses( array_key_first($semesters), array_key_last($semesters), - ['deputies_enabled' => Config::get()->DEPUTIES_ENABLE] + ['deputies_enabled' => Config::get()->getValue('DEPUTIES_ENABLE')] ); - if (!count($seminars)) { + if (count($seminars) === 0) { $message = sprintf(_('Sie haben zur Zeit keine Veranstaltungen belegt. Bitte nutzen Sie %s<b>Veranstaltung suchen / hinzufügen</b>%s um sch für Veranstaltungen anzumdelden.'), '<a href="' . URLHelper::getLink('dispatch.php/search/courses') . '">', '</a>'); PageLayout::postInfo($message); $this->render_nothing(); return; } - $modules_notification = new ModulesNotification(); - $enabled_modules = $modules_notification->registered_notification_modules; - $groups = []; - $my_sem = []; - foreach ($seminars as $seminar) { - $su = CourseMember::find([$seminar->id, User::findCurrent()->id]); - - if (!$su && Config::get()->DEPUTIES_ENABLE) { - $su = Deputy::find([$seminar->id, User::findCurrent()->id]); - } - - if (!$su) { - continue; - } - - $my_sem[$seminar['Seminar_id']] = [ - 'obj_type' => "sem", - 'sem_nr' => $seminar->veranstaltungsnummer, - 'name' => $seminar['Name'], - 'visible' => $seminar['visible'], - 'gruppe' => $su->gruppe, - 'sem_status' => $seminar->status, - 'sem_number' => Semester::getIndexById($seminar->start_semester->id), - 'sem_number_end' => Semester::getIndexById($seminar->end_semester->id ?? '') ?: '-1', - ]; - if ($group_field) { - fill_groups($groups, Semester::getIndexById($seminar->start_semester->id), [ - 'seminar_id' => $seminar['Seminar_id'], - 'sem_nr' => $seminar->veranstaltungsnummer, - 'name' => $seminar['Name'], - 'gruppe' => $su->gruppe, - ]); - } - } - - correct_group_sem_number($groups, $my_sem); - - - sort_groups($group_field, $groups); - $group_names = get_group_names($group_field, $groups); - $notifications = $this->user->course_notifications; - $open = UserConfig::get($this->user->user_id)->MY_COURSES_OPEN_GROUPS; - $checked = []; - foreach ($groups as $group_id => $group_members) { - if (!in_array($group_id, $open)) { - continue; - } - foreach ($group_members as $member) { - $checked[$member['seminar_id']] = []; - foreach ($enabled_modules as $index => $module) { - $notify = $notifications->findOneBy('seminar_id', $member['seminar_id']); - $checked[$member['seminar_id']][$index] = $notify && in_array($index, $notify->notification_data->getArrayCopy()); - } - $checked[$member['seminar_id']]['all'] = count($enabled_modules) === count(array_filter($checked[$member['seminar_id']])); - } - } - - $this->modules = $enabled_modules; - $this->groups = $groups; - $this->group_names = $group_names; - $this->group_field = 'sem_number'; - $this->open = $open; - $this->seminars = $my_sem; - $this->notifications = $notifications; - $this->checked = $checked; + $this->render_vue_app( + Studip\VueApp::create('my-courses/NotificationConfiguration') + ->withProps([ + 'store-url' => $this->storeURL(), + 'modules' => collect( + app(ModulesNotification::class)->registered_notification_modules + )->map( + fn(array $module, int $id): array => array_merge($module, ['id' => $id]) + )->values(), + 'notifications' => collect($this->user->course_notifications)->reduce( + function (array $carry, CourseMemberNotification $notification): array { + $carry[$notification->seminar_id] = array_map('intval', $notification->notification_data->getArrayCopy()); + return $carry; + }, + [] + ), + ]) + ->withStore( + 'MyCoursesStore', + 'mycoursesnotificationstore', + app(MyCoursesHelper::class)->createVueAppData('') + ) + ); } /** * Stores the notification settings of a user. */ - public function store_action() + public function store_action(): void { - $this->check_ticket(); - foreach (Request::getArray('m_checked') as $course_id => $checked) { - unset($checked['empty']); - if (!count($checked)) { - CourseMemberNotification::deleteBySQL('user_id=? AND seminar_id=?', [$this->user->user_id, $course_id]); + CSRFProtection::verifyUnsafeRequest(); + + $course_ids = Request::optionArray('course_ids'); + $notifications = Request::getArray('notifications'); + + $changed = 0; + foreach ($course_ids as $course_id) { + if (!isset($notifications[$course_id])) { + $changed += CourseMemberNotification::deleteBySQL( + 'user_id=? AND seminar_id=?', + [$this->user->user_id, $course_id] + ); } else { $notify = new CourseMemberNotification([$this->user->user_id, $course_id]); - $notify->notification_data = array_keys($checked); - $notify->store(); + $notify->notification_data = $notifications[$course_id]; + $changed += $notify->store(); } } - PageLayout::postSuccess(_('Die Einstellungen wurden gespeichert.')); - $this->redirect('settings/notification'); - } - /** - * Opens a specific area. - * - * @param String $id Id of the area to be opened - */ - public function open_action($id) - { - $open = $this->config->MY_COURSES_OPEN_GROUPS; - if (!in_array($id, $open)) { - $open[] = $id; + if ($changed > 0) { + PageLayout::postSuccess(_('Die Einstellungen wurden gespeichert.')); } - $this->config->store('MY_COURSES_OPEN_GROUPS', $open); - - $this->redirect('settings/notification'); - } - - /** - * Closes a specific area. - * - * @param String $id Id of the area to be closed - */ - public function close_action($id) - { - $open = $this->config->MY_COURSES_OPEN_GROUPS; - $open = array_diff($open, [$id]); - $this->config->store('MY_COURSES_OPEN_GROUPS', $open); $this->redirect('settings/notification'); } - - public function module_icon($area) - { - $mapping = [ - 'documents' => 'files', - 'elearning_interface' => 'learnmodule', - 'scm' => 'infopage', - 'votes' => 'vote', - 'basic_data' => 'seminar', - 'participants' => 'persons', - 'plugins' => 'plugin', - ]; - - return $mapping[$area] ?: $area; - } } diff --git a/app/controllers/settings/settings.php b/app/controllers/settings/settings.php index 294b288..a2cd32e 100644 --- a/app/controllers/settings/settings.php +++ b/app/controllers/settings/settings.php @@ -15,6 +15,12 @@ require_once 'lib/messaging.inc.php'; +/** + * @property User $user + * @property bool $restricted + * @property UserConfig $config + * @property email_validation_class $validator + */ abstract class Settings_SettingsController extends AuthenticatedController { // Stores message which shall be send to the user via email diff --git a/app/views/calendar/schedule/_colour_selector.php b/app/views/calendar/schedule/_colour_selector.php deleted file mode 100644 index c9cfec1..0000000 --- a/app/views/calendar/schedule/_colour_selector.php +++ /dev/null @@ -1,18 +0,0 @@ -<?php -/** - * @var string $selected_colour_id - */ -?> -<? foreach ($GLOBALS['PERS_TERMIN_KAT'] as $colour_id => $data) : ?> - <td class="colour"> - <input type="radio" name="colour_id" value="<?= htmlReady($colour_id) ?>" - aria-label="<?= sprintf(_('Farbe %s zuordnen'), htmlReady($colour_id)) ?>" - <?= $selected_colour_id === $colour_id ? 'checked' : '' ?> - id="colour-<?= htmlReady($colour_id) ?>"> - <label for="colour-<?= htmlReady($colour_id) ?>" - style="background-color: <?= htmlReady($data['border_color']) ?>;"> - <span class="colour-id"></span> - <span class="checked-icon"><?= Icon::create('accept', Icon::ROLE_INFO) ?></span> - </label> - </td> -<? endforeach ?> diff --git a/app/views/calendar/schedule/course_info.php b/app/views/calendar/schedule/course_info.php index 42c8b39..2a86a31 100644 --- a/app/views/calendar/schedule/course_info.php +++ b/app/views/calendar/schedule/course_info.php @@ -14,17 +14,19 @@ <? if ($membership) : ?> <fieldset> <legend><?= _('Farbe') ?></legend> - <table class="default mycourses-group-selector"> - <tr> - <?= $this->render_partial( - 'my_courses/group_selector', - [ - 'course_id' => $course->id, - 'selected_group_id' => $membership->gruppe + <?= Studip\VueApp::create('ColourSelector') + ->withProps([ + 'autofocus' => true, + 'colours' => collect()->range(0, 8)->map( + fn($group) => [ + 'id' => $group, + 'class' => 'gruppe' . $group, + 'label' => sprintf(_('Gruppe %u zuordnen'), $group + 1), ] - ) ?> - </tr> - </table> + )->values(), + 'input-name' => 'gruppe[' . htmlReady($course->id) . ']', + 'model-value' => $membership->gruppe, + ]) ?> </fieldset> <? endif ?> <fieldset> diff --git a/app/views/calendar/schedule/entry.php b/app/views/calendar/schedule/entry.php index 6109041..fa563fd 100644 --- a/app/views/calendar/schedule/entry.php +++ b/app/views/calendar/schedule/entry.php @@ -10,14 +10,14 @@ <?= CSRFProtection::tokenTag() ?> <fieldset> <legend><?= _('Farbe') ?></legend> - <table class="colour-selector"> - <tr> - <?= $this->render_partial( - 'calendar/schedule/_colour_selector', - ['selected_colour_id' => $entry->colour_id] - ) ?> - </tr> - </table> + <?= Studip\VueApp::create('ColourSelector') + ->withProps([ + 'autofocus' => true, + 'colours' => collect($GLOBALS['PERS_TERMIN_KAT'])->map( + fn($data, $id) => ['id' => $id, 'colour' => $data['bgcolor']] + )->values(), + 'model-value' => $entry->colour_id, + ]) ?> </fieldset> <fieldset> <legend><?= _('Zeit') ?></legend> diff --git a/app/views/my_courses/group_selector.php b/app/views/my_courses/group_selector.php deleted file mode 100644 index 128b7eb..0000000 --- a/app/views/my_courses/group_selector.php +++ /dev/null @@ -1,20 +0,0 @@ -<?php -/** - * @var string $course_id - * @var string $selected_group_id - */ -?> -<? for ($i = 0; $i < 9; $i++) : ?> - <td class="gruppe<?= $i ?> mycourses-group-selector" onclick="this.querySelector('input').checked = true;"> - <input type="radio" name="gruppe[<?= htmlReady($course_id) ?>]" value="<?= $i ?>" - aria-label="<?= sprintf(_('Gruppe %u zuordnen'), $i + 1) ?>" - id="course-group-<?= htmlReady($course_id) ?>-<?= $i ?>" - <?= $selected_group_id == $i ? 'checked' : '' ?>> - <label for="course-group-<?= htmlReady($course_id) ?>-<?= $i ?>"> - <span class="group-number"><?= $i + 1 ?></span> - <span class="checked-icon"> - <?= Icon::create('accept', Icon::ROLE_INFO) ?> - </span> - </label> - </td> -<? endfor ?> diff --git a/app/views/my_courses/groups.php b/app/views/my_courses/groups.php deleted file mode 100644 index ee9b964..0000000 --- a/app/views/my_courses/groups.php +++ /dev/null @@ -1,77 +0,0 @@ -<?php -/** - * @var MyCoursesController $controller - * @var bool $studygroups - * @var string $cid - * @var array $groups - * @var array $group_names - * @var array $semesters - * @var string $group_field - * @var string $current_semester - */ -?> -<form method="post" action="<?= $controller->store_groups($studygroups) ?>" class="default"> - <?= CSRFProtection::tokenTag() ?> - - <input type="hidden" name="cid" value="<?= htmlReady($cid) ?>"> - <table class="default collapsable"> - <caption><?= _('Gruppenzuordnung') ?></caption> - <colgroup> - <col> - <? for ($i = 0; $i < 9; $i += 1): ?> - <col style="width: 32px"> - <? endfor; ?> - </colgroup> - <thead> - <tr> - <th><?= _('Veranstaltung') ?></th> - <th colspan="100%"><?= _('Gruppe/Farbe') ?></th> - </tr> - </thead> - <? foreach ($groups as $group_id => $group_members): ?> - <tbody <? if (isset($semesters[$group_id]['semester_id']) && $current_semester != $semesters[$group_id]['semester_id']) echo 'class="collapsed"'; ?>> - <? if ($group_field !== 'not_grouped'): ?> - - <tr class="table_header header-row"> - <th colspan="10" class="toggle-indicator"> - <a class="toggler" href="#"> - <? if (is_array($group_names[$group_id])): ?> - <?= htmlReady($group_names[$group_id][1] . ' > ' . $group_names[$group_id][0]) ?> - <? else: ?> - <?= htmlReady($group_names[$group_id]) ?> - <? endif; ?> - </a> - </th> - </tr> - <? endif; ?> - <? foreach ($group_members as $member): ?> - <tr> - <td> - <a href="<?= URLHelper::getLink('dispatch.php/course/go?to=' . $member['seminar_id']) ?>"> - <?= htmlReady(Config::get()->IMPORTANT_SEMNUMBER ? $my_sem[$member['seminar_id']]['veranstaltungsnummer'] : '') ?> - <?= htmlReady($my_sem[$member['seminar_id']]['name']) ?> - </a> - <? if (!$my_sem[$member['seminar_id']]['visible']): ?> - <?= _('(versteckt)') ?> - <? endif; ?> - </td> - <?= $this->render_partial( - 'my_courses/group_selector', - [ - 'course_id' => $member['seminar_id'], - 'selected_group_id' => $my_sem[$member['seminar_id']]['gruppe'] - ] - ) ?> - </tr> - <? endforeach; ?> - </tbody> - <? endforeach; ?> - </table> - - <div align="center" data-dialog-button> - <div class="button-group"> - <?= Studip\Button::createAccept(_('Speichern')) ?> - <?= Studip\LinkButton::createCancel(_('Abbrechen'), $controller->url_for('my_courses/groups')) ?> - </div> - </div> -</form> diff --git a/app/views/settings/notification.php b/app/views/settings/notification.php deleted file mode 100644 index 1ee9888..0000000 --- a/app/views/settings/notification.php +++ /dev/null @@ -1,102 +0,0 @@ -<? use Studip\Button, Studip\LinkButton; ?> -<form method="post" action="<?= $controller->url_for('settings/notification/store') ?>" class="default"> - <?= CSRFProtection::tokenTag() ?> - <input type="hidden" name="studip_ticket" value="<?= get_ticket() ?>"> - - <table class="default" id="settings-notifications"> - <caption> - <?= _('Benachrichtigung über neue Inhalte anpassen')?> - </caption> - <colgroup> - <col width="7px"> - <col width="100%"> - <? for ($i = 0; $i < count($modules); $i += 1): ?> - <col width="20px"> - <? endfor; ?> - </colgroup> - <thead> - <tr> - <th colspan="2"><?= _('Veranstaltung') ?></th> - <? foreach ($modules as $id => $module): ?> - <?php $icon = $module['icon']->copyWithRole(Icon::ROLE_INFO); ?> - <th> - <?=$icon->asImg(['class' => 'middle', 'title' => $module['name']]) ?> - </th> - <? endforeach; ?> - <th><?= _('Alle') ?></th> - </tr> - <tr> - <td colspan="2"> - <?= _('Benachrichtigungen für die folgenden Veranstaltungen:') ?> - </td> - <? $i = 0; ?> - <? foreach ($modules as $index => $data): ?> - <td> - <input type="checkbox" name="all[columns][]" value="<?= $i++ ?>" - <? if (!empty($checked) && count(array_filter($checked, function ($item) use ($index) { return $item[$index]; })) == count($checked)) echo 'checked'; ?>> - </td> - <? endforeach; ?> - <td> - <input type="checkbox" name="all[all]" value="all" - <? if (!empty($checked) && count(array_filter($checked, function ($item) { return $item['all']; })) == count($checked)) echo 'checked'; ?>> - - </td> - </tr> - </thead> - <? foreach ($groups as $id => $members): ?> - <tbody> - <? if ($group_field !== 'not_grouped'): ?> - <tr> - <th colspan="<?= 3 + count($modules) ?>"> - <? if (in_array($id, $open)): ?> - <a class="tree" style="font-weight:bold" name="<?= $id ?>" - href="<?= $controller->url_for('settings/notification/close', $id) ?>#<?= $id ?>" - <?= tooltip(_('Gruppierung schließen'), true) ?>> - <?= Icon::create('arr_1down', 'clickable')->asImg() ?> - <? else: ?> - <a class="tree" name="<?= $id ?>" - href="<?= $controller->url_for('settings/notification/open', $id) ?>#<?= $id ?>" - <?= tooltip(_('Gruppierung öffnen'), true) ?>> - <?= Icon::create('arr_1right', 'clickable')->asImg() ?> - <? endif; ?> - <?= htmlReady(my_substr(implode(' > ', (array)$group_names[$id]), 0, 70)) ?> - </a> - </th> - </tr> - <? endif; ?> - <? if ($id === 'not_grouped' || in_array($id, $open)): ?> - <? foreach ($members as $member): ?> - <tr> - <td class="gruppe<?= $seminars[$member['seminar_id']]['gruppe'] ?>"> </td> - <td> - <a href="<?= URLHelper::getLink('dispatch.php/course/go', ['to' => $member['seminar_id']]) ?>"> - <?= Config::get()->IMPORTANT_SEMNUMBER ? htmlReady($seminars[$member['seminar_id']]['sem_nr']) : '' ?> - <?= htmlReady(my_substr($seminars[$member['seminar_id']]['name'], 0, 70)) ?> - </a> - <? if (!$seminars[$member['seminar_id']]['visible']): ?> - <?= _('(versteckt)') ?> - <? endif; ?> - <input type="hidden" name="m_checked[<?= $member['seminar_id'] ?>][empty]" value="0"> - </td> - <? foreach ($modules as $index => $data): ?> - <td> - <input type="checkbox" name="m_checked[<?= $member['seminar_id'] ?>][<?= $index ?>]" - value="1" - <? if ($checked[$member['seminar_id']][$index]) echo 'checked'; ?>> - </td> - <? endforeach; ?> - <td> - <input type="checkbox" name="all[rows][]" value="<?= $member['seminar_id'] ?>" - <? if (isset($checked[$member['seminar_id']]) && count(array_filter($checked[$member['seminar_id']])) == count($modules) + 1) echo 'checked'; ?>> - </td> - </tr> - <? endforeach; ?> - <? endif; ?> - </tbody> - <? endforeach; ?> - </table> - <footer> - <?= Button::create(_('Übernehmen'), 'store', ['title' => _('Änderungen übernehmen')]) ?> - <?= LinkButton::create(_('Abbrechen'), $controller->url_for('settings/notification')) ?> - </footer> -</form> diff --git a/lib/classes/ModulesNotification.php b/lib/classes/ModulesNotification.php index 96aa660..22586fb 100644 --- a/lib/classes/ModulesNotification.php +++ b/lib/classes/ModulesNotification.php @@ -77,9 +77,7 @@ class ModulesNotification $this->subject = _("Stud.IP Benachrichtigung"); } - - - public function getAllNotifications ($user_id = null) + public function getAllNotifications($user_id = null) { if ($user_id === null) { $user_id = $GLOBALS['user']->id; diff --git a/lib/classes/MyCoursesHelper.php b/lib/classes/MyCoursesHelper.php new file mode 100644 index 0000000..c4ef51b --- /dev/null +++ b/lib/classes/MyCoursesHelper.php @@ -0,0 +1,266 @@ +<?php +final class MyCoursesHelper +{ + public function createVueAppData(string $sem_key, string $group_field = 'sem_number'): array + { + return $this->getVueAppData( + $this->getCourses($sem_key, $group_field), + $group_field + ); + } + + public function getCourses(string $sem_key, string $group_field = 'sem_number'): array + { + return MyRealmModel::getPreparedCourses($sem_key, [ + 'group_field' => $group_field, + 'order_by' => null, + 'order' => 'asc', + 'studygroups_enabled' => Config::get()->MY_COURSES_ENABLE_STUDYGROUPS, + 'deputies_enabled' => Config::get()->DEPUTIES_ENABLE, + ]); + } + + /** + * Get the data array for presenting the course list in Vue. + * + * @param array|null $sem_courses + * @param string $group_field + * @return array{ + * courses: array, + * groups: array, + * user_id: string, + * config: array{ + * allow_dozent_visibility: bool, + * open_groups: array, + * sem_number: bool, + * display_type: string, + * responsive_type: string, + * navigation_show_only_new: bool, + * group_by: string + * } + * } + */ + public function getVueAppData(?array $sem_courses, string $group_field = 'sem_number'): array + { + $sem_data = Semester::getAllAsArray(); + $temp_courses = []; + $groups = []; + + if (is_array($sem_courses)) { + foreach ($sem_courses as $_outer_index => $_outer) { + if ($group_field === 'sem_number') { + $_courses = []; + + foreach ($_outer as $course) { + $_courses[$course['seminar_id']] = $course; + if (!empty($course['children']) && is_array($course['children'])) { + foreach ($course['children'] as $child) { + $_courses[$child['seminar_id']] = $child; + } + } + } + + if ($_outer_index) { + $groups[] = [ + 'id' => $_outer_index, + 'name' => (string)$sem_data[$_outer_index]['name'], + 'data' => [ + [ + 'id' => md5($_outer_index), + 'label' => false, + 'ids' => array_keys($_courses), + ], + ], + ]; + } + $temp_courses = array_merge($temp_courses, $_courses); + } else { + $count = 1; + $_groups = []; + foreach ($_outer as $_inner_index => $_inner) { + $_courses = []; + + foreach ($_inner as $course) { + $_courses[$course['seminar_id']] = $course; + if (isset($course['children']) && is_array($course['children'])) { + foreach ($course['children'] as $child) { + $_courses[$child['seminar_id']] = $child; + } + } + } + + $label = $_inner_index; + if ($group_field === 'sem_tree_id' && !$label) { + $label = _('keine Zuordnung'); + } elseif ($group_field === 'gruppe') { + $label = _('Gruppe') . ' ' . $count++; + } + + $_groups[] = [ + 'id' => md5($_outer_index . $_inner_index), + 'label' => $label, + 'ids' => array_keys($_courses), + ]; + + $temp_courses = array_merge($temp_courses, $_courses); + } + + if ($_outer_index) { + $groups[] = [ + 'id' => $_outer_index, + 'name' => (string)$sem_data[$_outer_index]['name'], + 'data' => $_groups, + ]; + } + } + } + } + + return [ + 'setCourses' => $this->sanitizeNavigations(array_map([$this, 'convertCourse'], $temp_courses)), + 'setGroups' => $groups, + 'setUserId' => $GLOBALS['user']->id, + 'setConfig' => [ + 'allow_dozent_visibility' => Config::get()->ALLOW_DOZENT_VISIBILITY, + 'open_groups' => array_values($GLOBALS['user']->cfg->MY_COURSES_OPEN_GROUPS), + 'sem_number' => Config::get()->IMPORTANT_SEMNUMBER, + 'view_settings' => $GLOBALS['user']->cfg->MY_COURSES_VIEW_SETTINGS, + 'group_by' => $group_field, + ], + ]; + } + + private function sanitizeNavigations(array $courses): array + { + // Count occurences of slots + $counters = []; + foreach ($courses as $course) { + foreach ($course['navigation'] as $key => $value) { + if (!isset($counters[$key])) { + $counters[$key] = 0; + } + if ($value) { + $counters[$key] += 1; + } + } + } + + // Detect which slots are not set at all + $remove = array_keys(array_filter($counters, function ($counter) { + return !$counter; + })); + + // Set positions by predefined positions without the always empty slots + $positions = array_diff(array_keys(MyRealmModel::getDefaultModules()), $remove); + + // Get other positions based on count + arsort($counters); + foreach ($counters as $key => $count) { + if ($count && !in_array($key, $positions)) { + $positions[] = $key; + } + } + + // Sort and filter course navigations + return array_map( + function ($course) use ($positions) { + $course['navigation'] = array_filter($course['navigation'], function ($key) use ($positions) { + return in_array($key, $positions); + }, ARRAY_FILTER_USE_KEY); + uksort($course['navigation'], function ($a, $b) use ($positions) { + return array_search($a, $positions) - array_search($b, $positions); + }); + $course['navigation'] = array_values($course['navigation']); + return $course; + }, + $courses + ); + } + + private function convertCourse($course) + { + $is_teacher = in_array($course['user_status'], ['tutor', 'dozent']); + + $avatar = $course['sem_class']['studygroup_mode'] + ? StudygroupAvatar::getAvatar($course['seminar_id']) + : CourseAvatar::getAvatar($course['seminar_id']); + + $extra_navigation = false; + if ($is_teacher) { + $adminmodule = $course['sem_class']->getAdminModuleObject(); + if ($adminmodule) { + $adminnavigation = $adminmodule->getIconNavigation($course['seminar_id'], 0, $GLOBALS['user']->id); + $extra_navigation = [ + 'url' => URLHelper::getURL($adminnavigation->getURL(), ['cid' => $course['seminar_id']]), + 'icon' => $adminnavigation->getImage()->getShape(), + 'label' => $adminnavigation->getLinkAttributes()['title'] ?? _('Verwaltung'), + ]; + } + } + + return [ + 'id' => (string) $course['seminar_id'], + 'name' => (string) $course['name'], + 'number' => (string) $course['veranstaltungsnummer'], + 'group' => (int) $course['gruppe'], + 'admission_binding' => (bool) $course['admission_binding'], + 'children' => array_column($course['children'] ?? [], 'seminar_id'), + 'parent' => $course['parent_course'] ?? null, + + 'is_teacher' => in_array($course['user_status'], ['tutor', 'dozent']), + 'is_studygroup' => (bool) $course['sem_class']['studygroup_mode'], + 'is_hidden' => !$course['visible'], + 'is_deputy' => (bool) $course['is_deputy'], + 'is_group' => (bool) $course['is_group'], + + 'avatar' => $avatar->getURL(Avatar::MEDIUM), + + 'navigation' => $this->reduceNavigation($course['navigation']), + 'extra_navigation' => $extra_navigation, + ]; + } + + private function reduceNavigation($nav): array + { + if (!$nav) { + return []; + } + + $result = []; + foreach (MyRealmModel::array_rtrim($nav) as $key => $n) { + if (!$n || !$n->isVisible(true)) { + $item = false; + } else { + $attr = $n->getLinkAttributes(); + if (empty($attr['title']) && $n->getImage()) { + $attr['title'] = (string) ($n->getImage()->getAttributes()['title'] ?? ''); + } + if (empty($attr['title'])) { + $attr['title'] = (string) $n->getTitle(); + } + $attr['title'] = (string) $attr['title']; + + $item = [ + 'url' => $n->getURL(), + 'icon' => $this->convertIcon($n->getImage()), + 'attr' => $attr, + 'important' => $n->getImage()->signalsAttention(), + ]; + } + $result[$key] = $item; + } + + return $result; + } + + /** + * @return array{role: string, shape: string} + */ + private function convertIcon(Icon $icon): array + { + return [ + 'role' => $icon->getRole(), + 'shape' => $icon->getShape(), + ]; + } +} diff --git a/lib/classes/MyRealmModel.php b/lib/classes/MyRealmModel.php index f3ab44c..dea3cb5 100644 --- a/lib/classes/MyRealmModel.php +++ b/lib/classes/MyRealmModel.php @@ -23,7 +23,6 @@ * @since 3.1 */ -require_once 'lib/meine_seminare_func.inc.php'; require_once 'lib/object.inc.php'; class MyRealmModel diff --git a/lib/meine_seminare_func.inc.php b/lib/meine_seminare_func.inc.php deleted file mode 100644 index 5313f8d..0000000 --- a/lib/meine_seminare_func.inc.php +++ /dev/null @@ -1,273 +0,0 @@ -<?php -# Lifter002: TODO -# Lifter007: TODO -# Lifter003: TODO -# Lifter010: TODO - -/** - * - * @param string $group_field - * @param array $groups - */ -function get_group_names(string $group_field, array $groups): array -{ - $mapper = function (): string { - return 'unknown'; - }; - if ($group_field === 'sem_number') { - $all_semester = Semester::findAllVisible(); - $mapper = function ($key) use ($all_semester): string { - return (string) ($all_semester[$key]['name'] ?? _('unbekanntes Semester')); - }; - } elseif ($group_field === 'sem_tree_id') { - $mapper = function ($key): string { - return StudipStudyArea::getNode($key)->getPath(' > '); - }; - } elseif ($group_field === 'sem_status') { - $mapper = function ($key): string { - $sem_type = $GLOBALS['SEM_TYPE'][$key]; - return "{$sem_type['name']} ({$GLOBALS['SEM_CLASS'][$sem_type['class']]['name']})"; - }; - } elseif ($group_field === 'no_grouped') { - $mapper = function (): string { - return _('keine Gruppierung'); - }; - } elseif ($group_field === 'gruppe') { - $groupcount = 0; - $mapper = function () use (&$groupcount): string { - $groupcount += 1; - return _('Gruppe') . " {$groupcount}"; - }; - } elseif ($group_field === 'dozent_id') { - $mapper = function ($key): string { - return get_fullname($key, 'no_title_short'); - }; - } elseif ($group_field === 'mvv') { - $mapper = function ($key): string { - $module = Modul::find($key); - return $module ? (string) $module->getDisplayName() : _('Keinem Modul zugeordnet'); - }; - } - - $result = []; - foreach (array_keys($groups) as $key) { - $result[$key] = $mapper($key); - } - return $result; -} - -/** - * - * @param string $group_field - * @param array $groups - */ -function sort_groups($group_field, &$groups) -{ - switch ($group_field) { - case 'sem_number': - krsort($groups, SORT_NUMERIC); - break; - - case 'gruppe': - ksort($groups, SORT_NUMERIC); - break; - - case 'sem_tree_id': - uksort($groups, function ($a, $b) { - $a_obj = StudipStudyArea::getNode($a); - $b_obj = StudipStudyArea::getNode($b); - return strcmp($a_obj->name, $b_obj->name); - }); - break; - - case 'sem_status': - uksort($groups, function ($a, $b) { - global $SEM_CLASS,$SEM_TYPE; - return strnatcasecmp( - $SEM_TYPE[$a]['name'] . ' (' . $SEM_CLASS[$SEM_TYPE[$a]['class']]['name'] . ')', - $SEM_TYPE[$b]['name'] . ' (' . $SEM_CLASS[$SEM_TYPE[$b]['class']]['name'] . ')' - ); - }); - break; - - case 'dozent_id': - uksort($groups, function ($a,$b) { - $replacements = ['ä' => 'ae', 'ö' => 'oe', 'ü' => 'ue']; - return strnatcasecmp( - str_replace(array_keys($replacements), array_values($replacements), mb_strtolower(get_fullname($a, 'no_title_short'))), - str_replace(array_keys($replacements), array_values($replacements), mb_strtolower(get_fullname($b, 'no_title_short'))) - ); - }); - break; - - case 'mvv': - uksort($groups, function ($a, $b): int { - $module_a = Modul::find($a); - $module_b = Modul::find($b); - - if (!$module_a) { - return 1; - } - if (!$module_b) { - return -1; - } - return strnatcasecmp($module_a->getDisplayName(), $module_b->getDisplayName()); - }); - break; - } - - foreach ($groups as $key => &$value) { - usort($value, function ($a, $b) { - if ($a['gruppe'] != $b['gruppe']) { - return (int)($a['gruppe'] - $b['gruppe']); - } else { - if (Config::get()->IMPORTANT_SEMNUMBER) { - return strnatcasecmp($a['sem_nr'] ?? '', $b['sem_nr'] ?? ''); - } else { - return strnatcmp($a['name'], $b['name']); - } - } - }); - } - return true; -} - -/** - * - * @param array $groups - * @param array $my_obj - */ -function correct_group_sem_number(&$groups, &$my_obj): bool -{ - if (!is_array($groups) || !is_array($my_obj)) { - return false; - } - - $current_semester = Semester::findCurrent(); - - $my_sem = array_filter( - $my_obj, - fn($values) => $values['obj_type'] === 'sem' - ); - - Course::findEachMany( - function (Course $course) use (&$groups, &$my_obj, $current_semester) { - if (count($course->semesters) === 1) { - return; - } - - $obj_data = $my_obj[$course->id]; - - if ( - $course->isOpenEnded() - && $course->start_semester->beginn < $current_semester->beginn - ) { - unset($groups[$obj_data['sem_number']][$course->id]); - - fill_groups($groups, Semester::getIndexById($current_semester->id), [ - 'seminar_id' => $course->id, - 'name' => $obj_data['name'], - 'gruppe' => $obj_data['gruppe'], - ]); - - if (count($groups[$obj_data['sem_number']]) === 0) { - unset($groups[$obj_data['sem_number']]); - } - } else { - $to_sem = $obj_data['sem_number_end']; - for ($i = $obj_data['sem_number']; $i <= $to_sem; ++$i){ - fill_groups($groups, $i, [ - 'seminar_id' => $course->id, - 'name' => $obj_data['name'], - 'gruppe' => $obj_data['gruppe'] - ]); - } - } - - if (User::findCurrent()->getConfiguration()->getValue('SHOWSEM_ENABLE')) { - $my_obj[$course->id]['name'] .= ' (' . $course->getTextualSemester() . ')'; - } - }, - array_keys($my_sem) - ); - - return true; -} - -/** - * - * @param mixed $my_obj - */ -function add_sem_name(&$my_obj): bool -{ - if ($GLOBALS['user']->cfg->getValue('SHOWSEM_ENABLE')) { - $sem_data = Semester::findAllVisible(); - if (is_array($my_obj)) { - foreach ($my_obj as $seminar_id => $values){ - if ($values['obj_type'] == 'sem' && $values['sem_number'] != $values['sem_number_end']){ - $sem_name = " (" . $sem_data[$values['sem_number']]['name'] . " - "; - $sem_name .= (($values['sem_number_end'] == -1) ? _("unbegrenzt") : $sem_data[$values['sem_number_end']]['name']) . ")"; - $my_obj[$seminar_id]['name'] .= $sem_name; - } else { - $my_obj[$seminar_id]['name'] .= " (" . $sem_data[$values['sem_number']]['name'] . ") "; - } - } - } - } - return true; -} - -/** - * - * @param array $groups - * @param string|null $group_key - * @param array $group_entry - * - * @return bool - */ -function fill_groups(array &$groups, ?string $group_key, array $group_entry): bool -{ - if (is_null($group_key)){ - $group_key = 'not_grouped'; - } - - if (!isset($groups[$group_key]) || !is_array($groups[$group_key])) { - $groups[$group_key] = []; - } - - $group_entry['name'] = str_replace( - ['ä', 'ö', 'ü'], - ['ae', 'oe', 'ue'], - mb_strtolower($group_entry['name']) - ); - if (!in_array($group_entry, $groups[$group_key])) { - $groups[$group_key][$group_entry['seminar_id']] = $group_entry; - return true; - } - - return false; -} - -/** - * This function returns all valid fields that may be used for course - * grouping in "My Courses". - * - * @return array All fields that may be specified for course grouping - */ -function getValidGroupingFields(): array -{ - $valid = [ - 'not_grouped', - 'sem_number', - 'sem_tree_id', - 'sem_status', - 'gruppe', - 'dozent_id', - ]; - - if (LvgruppeSeminar::countBySql('1') > 0) { - $valid[] = 'mvv'; - } - - return $valid; -} diff --git a/resources/assets/javascripts/bootstrap/settings.js b/resources/assets/javascripts/bootstrap/settings.js index caae5b2..2acaabe 100644 --- a/resources/assets/javascripts/bootstrap/settings.js +++ b/resources/assets/javascripts/bootstrap/settings.js @@ -32,55 +32,6 @@ STUDIP.domReady(() => { }); }); -// -$(document).on('change', '#settings-notifications :checkbox', function() { - var name = $(this).attr('name'); - - if (name === 'all[all]') { - $(this) - .closest('table') - .find(':checkbox') - .prop('checked', this.checked); - return; - } - - if (/all\[columns\]/.test(name)) { - var index = - $(this) - .closest('td') - .index() + 2; - $(this) - .closest('table') - .find('tbody td:nth-child(' + index + ') :checkbox') - .prop('checked', this.checked); - } else if (/all\[rows\]/.test(name)) { - $(this) - .closest('td') - .siblings() - .find(':checkbox') - .prop('checked', this.checked); - } - - $('.notification.settings tbody :checkbox[name^=all]').each(function() { - var other = $(this) - .closest('td') - .siblings() - .find(':checkbox'); - this.checked = other.filter(':not(:checked)').length === 0; - }); - - $('.notification.settings thead :checkbox').each(function() { - var index = - $(this) - .closest('td') - .index() + 2, - other = $(this) - .closest('table') - .find('tbody td:nth-child(' + index + ') :checkbox'); - this.checked = other.filter(':not(:checked)').length === 0; - }); -}); - $(document).on('input', '#new_password', function() { var message = $(this).data().message; if (this.validity.patternMismatch) { diff --git a/resources/assets/javascripts/bootstrap/vue.js b/resources/assets/javascripts/bootstrap/vue.js index c0fe942..ac22629 100644 --- a/resources/assets/javascripts/bootstrap/vue.js +++ b/resources/assets/javascripts/bootstrap/vue.js @@ -46,9 +46,12 @@ STUDIP.ready(() => { const promises = [Promise.resolve()]; for (const [index, name] of Object.entries(config.stores)) { + promises.push( import(`../../../vue/store/${name}.js`).then(storeConfig => { - store.registerModule(index, storeConfig.default); + if (!store.hasModule(index)) { + store.registerModule(index, storeConfig.default); + } const dataElement = document.getElementById(`vue-store-data-${index}`); if (dataElement) { @@ -106,5 +109,10 @@ STUDIP.ready(() => { plugins.forEach(plugin => app.use(plugin, { store })) app.mount(node); + + const dialog = node.closest('.studip-dialog'); + if (dialog !== null) { + $(dialog).on('dialogclose', () => app.unmount()); + } }); }); diff --git a/resources/assets/stylesheets/scss/colour-selector.scss b/resources/assets/stylesheets/scss/colour-selector.scss new file mode 100644 index 0000000..0585f2a --- /dev/null +++ b/resources/assets/stylesheets/scss/colour-selector.scss @@ -0,0 +1,51 @@ +.colour-selector { + $padding: 2px; + + position: relative; + + background-clip: padding-box; + width: var(--icon-size-default); + + &.mycourses-group-selector { + border: 1px solid var(--color--table-border); + } + + input[type="radio"] { + @extend .sr-only; + + &:focus + label { + // Replicates the focus on the label + // @see https://css-tricks.com/copy-the-browsers-native-focus-styles/ + outline: 5px auto Highlight; + outline: 5px auto -webkit-focus-ring-color; + outline-offset: 1px; + } + + &:checked + label { + // We need to insert a pseudo element since the above focus + // indicator would be lost + &::before { + content: ''; + display: inline-block; + position: absolute; + top: 0; + right: 0; + bottom: 0; + left: 0; + outline: 2px solid var(--white); + outline-offset: -4px; + } + } + } + + label { + box-sizing: border-box; + cursor: pointer; + height: calc(var(--icon-size-default) + 2 * $padding); + margin: 0 !important; + padding: $padding; + position: relative; + text-indent: 0 !important; + width: calc(var(--icon-size-default) + 2 * $padding); + } +} diff --git a/resources/assets/stylesheets/scss/my_courses.scss b/resources/assets/stylesheets/scss/my_courses.scss index 2e26dee..9d34b5c 100644 --- a/resources/assets/stylesheets/scss/my_courses.scss +++ b/resources/assets/stylesheets/scss/my_courses.scss @@ -9,73 +9,6 @@ background: var(--white); } -form.default table.mycourses-group-selector { - table-layout: fixed; - width: unset; - - td { - width: 2em; - } -} - -form.default td.mycourses-group-selector, -table.colour-selector td.colour { - position: relative; - - background-clip: padding-box; - - &.mycourses-group-selector { - border: 1px solid fade-out($brand-color-lighter, 0.8); - } - - &.colour { - padding-left: 0.1em; - padding-right: 0.1em; - } - - input[type="radio"] { - @extend .sr-only; - - &:checked + label { - .group-number { - display: none; - } - .checked-icon { - display: inline; - } - } - } - - &:hover label { - .group-number { - display: none; - } - .checked-icon { - display: inline; - } - } - - label { - text-align: center; - font-size: large; - font-weight: bold; - cursor: pointer; - - background-color: var(--white); - margin-bottom: 0; - text-indent: 0; - - height: 1.2em; - - .group-number { - display: inline; - } - .checked-icon { - display: none; - } - } -} - $icon-padding: 3px; .my-courses-navigation { diff --git a/resources/assets/stylesheets/scss/schedule.scss b/resources/assets/stylesheets/scss/schedule.scss index c163f42..c2be9ed 100644 --- a/resources/assets/stylesheets/scss/schedule.scss +++ b/resources/assets/stylesheets/scss/schedule.scss @@ -3,18 +3,6 @@ form.default.schedule-entry { section.nowrap { flex-wrap: nowrap; } - - table.colour-selector td.colour { - label { - width: 1.5em; - height: 1.5em; - - .studip-icon { - height: 1.5em; - filter: drop-shadow(0 0 2px var(--white)) - } - } - } } .fc.schedule { diff --git a/resources/assets/stylesheets/scss/tables.scss b/resources/assets/stylesheets/scss/tables.scss index 788392d..f2978ba 100644 --- a/resources/assets/stylesheets/scss/tables.scss +++ b/resources/assets/stylesheets/scss/tables.scss @@ -214,7 +214,7 @@ td.tree-elbow-line, td.tree-elbow-end { background-color: var(--group-color-8) !important; } -#my_seminars, #settings-notifications { +#my_seminars { .gruppe0, .gruppe1, .gruppe2, .gruppe3, .gruppe4, .gruppe5, .gruppe6, .gruppe7, .gruppe9 { width: 1px; diff --git a/resources/assets/stylesheets/studip.scss b/resources/assets/stylesheets/studip.scss index 42ab072..6d1d16a 100644 --- a/resources/assets/stylesheets/studip.scss +++ b/resources/assets/stylesheets/studip.scss @@ -24,6 +24,7 @@ @import "scss/buttons"; @import "scss/calendar"; @import "scss/clipboard"; +@import "scss/colour-selector"; @import "scss/consultation"; @import "scss/contacts"; @import "scss/contentbar"; diff --git a/resources/vue/components/ColourSelector.vue b/resources/vue/components/ColourSelector.vue new file mode 100644 index 0000000..43520b3 --- /dev/null +++ b/resources/vue/components/ColourSelector.vue @@ -0,0 +1,54 @@ +<template> + <table class="colour-selector"> + <tbody> + <tr ref="row"> + <td v-for="colour in colours" :key="`colour-${colour.id}`" + class="colour colour-selector" + > + <input type="radio" + :name="inputName" + :id="`colour-${colour.id}`" + :value="colour.id" + v-model="selectedColor" + :aria-label="colour.label ?? null" + > + <label :for="`colour-${colour.id}`" + :class="colour.class ?? null" + :style="{backgroundColor: colour.colour ?? null}"> + </label> + </td> + </tr> + </tbody> + </table> +</template> +<script setup> +import {onMounted, ref} from "vue"; + +const selectedColor = defineModel({ + type: Number, + default: () => null, +}); + +const row = ref(null); + +const props = defineProps({ + autofocus: { + type: Boolean, + default: false, + }, + colours: { + type: Array, + required: true + }, + inputName: { + type: String, + default: 'colour_id' + }, +}); + +if (props.autofocus) { + onMounted(() => { + row.value.querySelector('input[type="radio"]:checked')?.focus(); + }); +} +</script> diff --git a/resources/vue/components/my-courses/ColorGroupSelector.vue b/resources/vue/components/my-courses/ColorGroupSelector.vue new file mode 100644 index 0000000..f017cc7 --- /dev/null +++ b/resources/vue/components/my-courses/ColorGroupSelector.vue @@ -0,0 +1,204 @@ +<template> + <form method="post" :action="storeUrl" class="default" @submit="secured = false"> + <input type="hidden" :name="csrf.name" :value="csrf.value"> + <input v-if="cid" + type="hidden" + name="cid" + :value="cid" + > + <input v-for="(group, id) in courseGroups" + :key="`input-${id}`" + type="hidden" + :name="`gruppe[${id}]`" + :value="group" + > + + <table class="default"> + <caption>{{ $gettext('Gruppenzuordnung') }}</caption> + <colgroup> + <col> + <col v-for="i in maxgroups" + :key="`col-${i}`" + style="width: 34px" + > + </colgroup> + <thead> + <tr> + <th>{{ $gettext('Veranstaltung') }}</th> + <th :colspan="maxgroups">{{ $gettext('Gruppe/Farbe') }}</th> + </tr> + </thead> + <tbody v-for="group in groups" + :key="`group-${group.id}`" + > + <tr> + <th>{{ group.name }}</th> + <th v-for="i in maxgroups" :key="`group-label-${group.id}-${i}`"> + {{ i }} + </th> + </tr> + <tr v-for="course in getCoursesForGroup(group)" + :key="`course-${group.id}-${course.id}`" + role="radiogroup" + :aria-label="$gettext('Gruppenauswahl für Veranstaltung %{name}', course)" + > + <td> + <a :href="getCourseURL(course)"> + {{ getCourseName(course) }} + <template v-if="course.is_hidden"> + {{ $gettext('(versteckt)') }} + </template> + </a> + </td> + <td v-for="(i, index) in maxgroups" + class="colour-selector mycourses-group-selector" + :key="`selector-${group.id}-${course.id}-${i}`" + @mouseover="hovered = {id: course.id, index}" + @mouseleave="hovered = false" + > + <input type="radio" + :name="`gruppe[${course.id}]`" + :id="`course-${course.id}-group-${i}`" + :value="index" + :aria-label="$gettext('Gruppe %{i} zuordnen', {i})" + v-model="courseGroups[course.id]" + > + <label :for="`course-${course.id}-group-${i}`" + :class="`gruppe${index}`" + > + </label> + </td> + </tr> + </tbody> + </table> + + <footer data-dialog-button style="text-align: center"> + <button type="submit" class="button accept"> + {{ $gettext('Speichern') }} + </button> + <button type="reset" class="button" @click.prevent="reset()"> + {{ $gettext('Zurücksetzen') }} + </button> + <button v-if="dialog" + class="button cancel" + type="button" + @click.prevent="closeDialog()" + > + {{ $gettext('Abbrechen') }} + </button> + </footer> + </form> +</template> +<script> +import { mapState } from "vuex"; +import { createMixin } from "../../mixins/MyCoursesMixin"; + +export default { + name: "MyCoursesColorGroupSelector", + mixins: [ + createMixin(true), + ], + props: { + cid: String, + maxgroups: { + type: Number, + default: 9 + }, + storeUrl: String, + }, + data() { + return { + courseGroups: [], + dialog: null, + hovered: false, + inDialog: false, + secured: true, + }; + }, + computed: { + ...mapState('mycoursesgroupselector', [ + 'courses', + 'groups', + 'config', + ]), + + isChanged() { + return Object.entries(this.courses).some(([id, course]) => { + return this.courseGroups[id] !== course.group; + }); + } + }, + methods: { + closeDialog() { + STUDIP.Dialog.close(); + }, + getCoursesForGroup(group) { + return group.data.map(item => item.ids).flat().map(id => this.courses[id]); + }, + reset() { + this.courseGroups = Object.values(this.courses).reduce( + (all, course) => { + all[course.id] = course.group; + return all; + }, + {} + ); + }, + + securityHandler(event) { + if (!this.isChanged || !this.secured) { + return; + } + + event.preventDefault(); + }, + securityHandlerDialog(event) { + if ( + !this.isChanged + || !this.secured + || window.confirm(this.$gettext('Ihre Eingaben wurden bislang noch nicht gespeichert.')) + ) { + return true; + } + + event.preventDefault(); + event.stopPropagation(); + return false; + } + }, + created() { + this.reset(); + }, + mounted() { + this.dialog = this.$el?.closest('.studip-dialog'); + + if (this.dialog !== null) { + $(this.dialog).on('dialogbeforeclose', this.securityHandlerDialog); + + this.$nextTick(() => { + this.dialog.querySelector('.ui-dialog-content input[type="radio"]:checked')?.focus(); + }); + } else { + window.addEventListener('beforeunload', this.securityHandler); + } + }, + beforeUnmount() { + if (this.dialog !== null) { + $(this.dialog).off('dialogbeforeclose', this.securityHandlerDialog); + } else { + window.removeEventListener('beforeunload', this.securityHandler); + } + } +} +</script> +<style lang="scss" scoped> +table.default { + th, + td { + padding-right: 0; + } + tbody th:not(:first-child) { + text-align: center; + } +} +</style> diff --git a/resources/vue/components/MyCoursesColorPicker.vue b/resources/vue/components/my-courses/ColorPicker.vue index 68eae5d..d2354c9 100644 --- a/resources/vue/components/MyCoursesColorPicker.vue +++ b/resources/vue/components/my-courses/ColorPicker.vue @@ -66,7 +66,7 @@ export default { </script> <style lang="scss"> -@use '../../assets/stylesheets/mixins.scss'; +@use '../../../assets/stylesheets/mixins.scss'; .my-courses-color-picker { list-style: none; diff --git a/resources/vue/components/MyCourses.vue b/resources/vue/components/my-courses/MyCourses.vue index 85369c7..96a68fb 100644 --- a/resources/vue/components/MyCourses.vue +++ b/resources/vue/components/my-courses/MyCourses.vue @@ -25,33 +25,33 @@ to="#tiled-courses-sidebar-switch .sidebar-widget-content .widget-list" name="sidebar-switch" > - <MyCoursesSidebarSwitch /> + <SidebarSwitch /> </Teleport> <Teleport v-if="hasSidebarElements" to="#tiled-courses-new-contents-toggle .sidebar-widget-content .widget-list" name="sidebar-content-toggle" > - <MyCoursesNewContentToggle /> + <NewContentToggle /> </Teleport> </div> </template> <script> -import MyCoursesTables from './MyCoursesTables.vue'; -import MyCoursesTiles from './MyCoursesTiles.vue'; -import MyCoursesMixin from '../mixins/MyCoursesMixin.js'; -import MyCoursesSidebarSwitch from "./MyCoursesSidebarSwitch.vue"; -import MyCoursesNewContentToggle from "./MyCoursesNewContentToggle.vue"; +import TableView from './TableView.vue'; +import TileView from './TileView.vue'; +import MyCoursesMixin from '../../mixins/MyCoursesMixin.js'; +import SidebarSwitch from './SidebarSwitch.vue'; +import NewContentToggle from './NewContentToggle.vue'; export default { name: 'MyCourses', mixins: [MyCoursesMixin], components: { - MyCoursesTables, - MyCoursesTiles, - MyCoursesSidebarSwitch, - MyCoursesNewContentToggle, + TableView, + TileView, + SidebarSwitch, + NewContentToggle, }, data() { return { @@ -61,8 +61,8 @@ export default { computed: { displayComponent () { return this.displayedType === 'tiles' - ? MyCoursesTiles - : MyCoursesTables; + ? TileView + : TableView; }, displayedType () { return this.getViewConfig('tiled') ? 'tiles' : 'table'; diff --git a/resources/vue/components/MyCoursesNavigation.vue b/resources/vue/components/my-courses/Navigation.vue index e782c7a..bea4845 100644 --- a/resources/vue/components/MyCoursesNavigation.vue +++ b/resources/vue/components/my-courses/Navigation.vue @@ -11,7 +11,7 @@ <script> export default { - name: 'my-courses-navigation', + name: 'Navigation', props: { navigation: Object, iconSize: { diff --git a/resources/vue/components/MyCoursesNewContentToggle.vue b/resources/vue/components/my-courses/NewContentToggle.vue index 3ced26d..20b0bf0 100644 --- a/resources/vue/components/MyCoursesNewContentToggle.vue +++ b/resources/vue/components/my-courses/NewContentToggle.vue @@ -9,8 +9,8 @@ </template> <script> -import Sidebar from "../../assets/javascripts/lib/sidebar.js"; -import MyCoursesMixin from '../mixins/MyCoursesMixin.js'; +import Sidebar from "../../../assets/javascripts/lib/sidebar.js"; +import MyCoursesMixin from '../../mixins/MyCoursesMixin.js'; export default { name: 'my-courses-new-content-toggle', diff --git a/resources/vue/components/my-courses/NotificationConfiguration.vue b/resources/vue/components/my-courses/NotificationConfiguration.vue new file mode 100644 index 0000000..0153f49 --- /dev/null +++ b/resources/vue/components/my-courses/NotificationConfiguration.vue @@ -0,0 +1,218 @@ +<template> + <form :action="storeUrl" method="post" @submit="secured = false"> + <input type="hidden" :name="csrf.name" :value="csrf.value"> + <input v-for="course in courses" + :key="`hidden-input-${course.id}`" + type="hidden" + name="course_ids[]" + :value="course.id"> + + <table class="default"> + <caption>{{ $gettext('Benachrichtigung über neue Inhalte anpassen') }}</caption> + <colgroup> + <col style="width: 1px"> + <col style="width: 100%"> + <col v-for="module in modules" style="width: 20px" :key="`module-col-${module.id}`"> + <col style="width: 20px"> + </colgroup> + <thead> + <tr> + <th colspan="2">{{ $gettext('Veranstaltung') }}</th> + <th v-for="module in modules" :key="`module-header-${module.id}`"> + <studip-icon :shape="module.icon.shape" + role="info" + :title="module.name" + ></studip-icon> + </th> + <th>{{ $gettext('Alle') }}</th> + </tr> + <tr> + <td colspan="2"> + {{ $gettext('Benachrichtigungen für die folgenden Veranstaltungen:') }} + </td> + <td v-for="module in modules" :key="`module-icon-${module.id}`"> + <input type="checkbox" + v-bind.prop="checkedAllColumns(module)" + @click="toggleColumn(module)"> + </td> + <td> + <input type="checkbox" + v-bind.prop="checkedAll()" + @click="toggleAll()" + > + </td> + </tr> + </thead> + <tbody v-for="group in groups" + :key="`group-${group.id}`" + > + <tr> + <th :colspan="3 + modules.length">{{ group.name }}</th> + </tr> + <tr v-for="course in getCoursesForGroup(group)" :key="`course-${course.id}`"> + <td :class="`gruppe${course.group}`"></td> + <td> + <a :href="getCourseURL(course)"> + {{ getCourseName(course) }} + </a> + </td> + <td v-for="module in modules" :key="`course-${course.id}-module-${module.id}`"> + <input type="checkbox" + :name="`notifications[${course.id}][]`" + :value="module.id" + v-model="courseModules[course.id]"> + </td> + <td> + <input type="checkbox" + :value="course.id" + v-bind.prop="checkedAllRows(course)" + @click="toggleRow(course)"> + </td> + </tr> + </tbody> + <tfoot> + <tr> + <td :colspan="3 + modules.length"> + <button type="submit" class="button accept" :title="$gettext('Änderungen übernehmen')"> + {{ $gettext('Speichern') }} + </button> + <button type="reset" class="button" @click.prevent="reset()"> + {{ $gettext('Zurücksetzen') }} + </button> + </td> + </tr> + </tfoot> + </table> + </form> +</template> +<script> +import {mapState} from "vuex"; +import { createMixin } from "../../mixins/MyCoursesMixin"; + +export default { + name: 'MyCoursesNotificationConfiguration', + mixins: [ + createMixin(true) + ], + props: { + storeUrl: String, + modules: Object, + notifications: Object, + }, + data() { + return { + courseModules: {}, + secured: true, + } + }, + computed: { + ...mapState('mycoursesnotificationstore', [ + 'courses', + 'groups', + 'config', + ]), + + isChanged() { + return Object.entries(this.courseModules).some(([id, notifications]) => { + const original = this.notifications[id].toSorted(); + const current = notifications.toSorted(); + return JSON.stringify(original) !== JSON.stringify(current); + }); + } + }, + methods: { + checkedAll() { + const allCount = this.modules.length; + const checkedCount = this.modules.filter(module => this.checkedAllColumns(module).checked).length; + const indeterminateCount = this.modules.filter(module => this.checkedAllColumns(module).indeterminate).length; + return { + checked: checkedCount === allCount, + indeterminate: checkedCount !== allCount && indeterminateCount > 0, + }; + }, + checkedAllColumns(module) { + const allCount = Object.keys(this.courseModules).length; + const checkedCount = Object.values(this.courseModules).filter(modules => modules.includes(module.id)).length; + return { + checked: checkedCount === allCount, + indeterminate: checkedCount > 0 && checkedCount < allCount, + }; + }, + checkedAllRows(course) { + const allCount = this.modules.length; + const checkedCount = this.courseModules[course.id].length; + return { + checked: checkedCount === allCount, + indeterminate: checkedCount > 0 && checkedCount < allCount, + }; + }, + getCoursesForGroup(group) { + const ids = group.data.map(item => item.ids).flat(); + return this.getCourses(ids); + }, + reset() { + // We need to copy the values instead we would work on the original + // data and could never detect any changes + this.courseModules = Object.keys(this.courses).reduce( + (carry, id) => { + carry[id] = this.notifications[id]?.concat() ?? []; + return carry; + }, + {} + ); + }, + securityHandler(event) { + if (!this.isChanged || !this.secured) { + return; + } + + event.preventDefault(); + }, + toggleAll() { + const value = this.checkedAll().checked ? [] : this.modules.map(m => m.id); + Object.keys(this.courses).forEach(courseId => { + this.courseModules[courseId] = value; + }); + }, + toggleColumn(module) { + const disable = this.checkedAllColumns(module).checked; + Object.entries(this.courseModules).forEach(([courseId, modules]) => { + if (disable && this.courseModules[courseId].includes(module.id)) { + this.courseModules[courseId].splice( + this.courseModules[courseId].indexOf(module.id), + 1 + ); + } else if (!disable && !modules.includes(module.id)) { + this.courseModules[courseId].push(module.id); + } + }); + }, + toggleRow(course) { + if (this.checkedAllRows(course).checked) { + this.courseModules[course.id] = []; + } else { + this.courseModules[course.id] = Object.values(this.modules).map(m => m.id); + } + } + }, + created() { + this.reset(); + + window.addEventListener('beforeunload', this.securityHandler); + }, + beforeUnmount() { + window.removeEventListener('beforeunload', this.securityHandler); + } +}; +</script> +<style lang="scss" scoped> +table.default { + tbody td:first-child { + padding-left: 0; + } + thead td:last-child, + tbody td:last-child { + border-left: 1px solid var(--color--table-border); + } +} +</style> diff --git a/resources/vue/components/MyCoursesSidebarSwitch.vue b/resources/vue/components/my-courses/SidebarSwitch.vue index 788c053..8742982 100644 --- a/resources/vue/components/MyCoursesSidebarSwitch.vue +++ b/resources/vue/components/my-courses/SidebarSwitch.vue @@ -14,8 +14,8 @@ </template> <script> -import Sidebar from "../../assets/javascripts/lib/sidebar.js"; -import MyCoursesMixin from '../mixins/MyCoursesMixin.js'; +import Sidebar from "../../../assets/javascripts/lib/sidebar.js"; +import MyCoursesMixin from '../../mixins/MyCoursesMixin.js'; export default { name: 'my-courses-sidebar-switch', diff --git a/resources/vue/components/MyCoursesTables.vue b/resources/vue/components/my-courses/TableView.vue index 3041735..e7f6650 100644 --- a/resources/vue/components/MyCoursesTables.vue +++ b/resources/vue/components/my-courses/TableView.vue @@ -56,9 +56,8 @@ {{ course.number }} </td> <td :class="{'subcourse-indented': isChild(course)}"> - <a :href="urlFor('dispatch.php/course/go', {to: course.id})"> - {{ getCourseName(course, getConfig('sem_number') && responsiveDisplay) }} - <span v-if="course.is_deputy">{{ $gettext('[Vertretung]') }}</span> + <a :href="getCourseURL(course)"> + {{ getCourseName(course) }} </a> <span v-if="course.is_hidden" class="course-hidden-info"> {{ $gettext('[versteckt]') }} @@ -72,11 +71,11 @@ ></studip-action-menu> </div> - <my-courses-navigation :navigation="getNavigationForCourse(course)" :icon-size="iconSize"></my-courses-navigation> + <navigation :navigation="getNavigationForCourse(course)" :icon-size="iconSize"></navigation> </div> </td> <td v-if="!responsiveDisplay" class="course-navigation"> - <my-courses-navigation :navigation="getNavigationForCourse(course, true)" :icon-size="iconSize"></my-courses-navigation> + <navigation :navigation="getNavigationForCourse(course, true)" :icon-size="iconSize"></navigation> </td> <td v-if="!responsiveDisplay" class="actions"> <studip-action-menu :items="getActionMenuForCourse(course)"></studip-action-menu> @@ -88,7 +87,7 @@ </template> <script> -import MyCoursesMixin from '../mixins/MyCoursesMixin.js'; +import MyCoursesMixin from '../../mixins/MyCoursesMixin.js'; const defaultIconSize = parseInt( getComputedStyle(document.body).getPropertyValue('--icon-size-default'), @@ -96,7 +95,7 @@ const defaultIconSize = parseInt( ); export default { - name: 'MyCoursesTables', + name: 'TableView', mixins: [MyCoursesMixin], props: { iconSize: { diff --git a/resources/vue/components/MyCoursesTiles.vue b/resources/vue/components/my-courses/TileView.vue index 355d24c..bd51562 100644 --- a/resources/vue/components/MyCoursesTiles.vue +++ b/resources/vue/components/my-courses/TileView.vue @@ -11,7 +11,7 @@ <section class="studip-grid"> <template v-for="course in getOrderedCourses(subgroup.ids)" :key="course.id"> <div class="course-group-label" v-if="isParent(course)"> - {{ getCourseName(course, getConfig('sem_number')) }} + {{ getCourseName(course) }} </div> <article class="studip-grid-element" :data-course-id="course.id" :class="getCourseCssClasses(course)"> @@ -24,12 +24,10 @@ ></studip-action-menu> </span> - <a :href="urlFor('dispatch.php/course/go', {to: course.id})" class="tiles-grid-element-header-content" :title="getCourseName(course, getConfig('sem_number'))"> + <a :href="getCourseURL(course)" class="tiles-grid-element-header-content" :title="getCourseName(course)"> <span :style="{backgroundImage: `url(${course.avatar})`}" class="tiles-grid-element-header-image"></span> <span class="tiled-grid-element-header-title"> - {{ getCourseName(course, getConfig('sem_number')) }} - <span v-if="course.is_deputy">{{ $gettext('[Vertretung]') }}</span> - + {{ getCourseName(course) }} </span> </a> @@ -39,10 +37,10 @@ </span> </header> <footer class="tiles-grid-element-footer"> - <my-courses-navigation :navigation="getNavigationForCourse(course)" :icon-size="iconSize"></my-courses-navigation> + <navigation :navigation="getNavigationForCourse(course)" :icon-size="iconSize"></navigation> </footer> - <my-courses-color-picker v-if="showColorPickerForCourse(course)" :course="course" v-on:color-picked="changeColor"></my-courses-color-picker> + <color-picker v-if="showColorPickerForCourse(course)" :course="course" v-on:color-picked="changeColor"></color-picker> </article> </template> </section> @@ -53,13 +51,13 @@ <script> -import MyCoursesMixin from '../mixins/MyCoursesMixin.js'; -import MyCoursesColorPicker from './MyCoursesColorPicker.vue'; +import MyCoursesMixin from '../../mixins/MyCoursesMixin.js'; +import ColorPicker from './ColorPicker.vue'; export default { - name: 'my-courses-tiles', + name: 'TileView', mixins: [MyCoursesMixin], - components: {MyCoursesColorPicker}, + components: {ColorPicker}, props: { iconSize: { type: Number, @@ -158,10 +156,10 @@ export default { </script> <style lang="scss" scoped> -@use '../../assets/stylesheets/mixins'; -@use '../../assets/stylesheets/scss/breakpoints' as *; -@use '../../assets/stylesheets/scss/variables'; -@import '../../assets/stylesheets/scss/visibility'; // Needs to be imported (breakpoint variables are missing) +@use '../../../assets/stylesheets/mixins'; +@use '../../../assets/stylesheets/scss/breakpoints' as *; +@use '../../../assets/stylesheets/scss/variables'; +@import '../../../assets/stylesheets/scss/visibility'; // Needs to be imported (breakpoint variables are missing) $tile-border-width: 1px; $tile-color-width: 15px; diff --git a/resources/vue/mixins/MyCoursesMixin.js b/resources/vue/mixins/MyCoursesMixin.js index fa28388..1ce95a2 100644 --- a/resources/vue/mixins/MyCoursesMixin.js +++ b/resources/vue/mixins/MyCoursesMixin.js @@ -1,185 +1,217 @@ import Responsive from '../../assets/javascripts/lib/responsive.js'; import { mapState, mapActions, mapGetters } from 'vuex'; -import MyCoursesNavigation from '../components/MyCoursesNavigation.vue'; - -export default { - components: { MyCoursesNavigation }, - data () { - return { - responsiveDisplay: false, - }; - }, - methods: { - ...mapActions('mycourses', [ - 'toggleOpenGroup', - 'updateConfigValue', - ]), - - getViewConfig(key) { - return this.getConfig( - 'view_settings', - this.responsiveDisplay ? 'responsive' : 'regular', - key - ); - }, - updateViewConfig(key, value) { - let config = this.getConfig('view_settings'); - config[this.responsiveDisplay ? 'responsive' : 'regular'][key] = value; - return this.updateConfigValue({ - key: 'view_settings', - value: config - }); +import Navigation from '../components/my-courses/Navigation.vue'; +import { $gettext } from "../../assets/javascripts/lib/gettext"; + +function createMixin(minimal = false) { + const result = { + data () { + return { + responsiveDisplay: false, + }; }, + methods: { + getCourseName(course) { + let name = course.name; + + // Include sem number + if ( + this.config?.sem_number + && !this.responsiveDisplay + ) { + name = `${course.number} ${name}`; + } - getCourseName(course, include_number = false) { - let name = course.name; - if (include_number) { - name = `${course.number} ${course.name}`; - } - return name.trim(); - }, + // Show deputy info + if (course.is_deputy) { + name = `${name} ${$gettext('[Vertretung]')}`; + } - urlFor(url, parameters, ignore_params) { - return STUDIP.URLHelper.getURL(url, parameters, ignore_params); - }, + return name; + }, + getCourseURL(course) { + return this.urlFor('dispatch.php/course/go', {to: course.id}, true); + }, - getCourses (ids) { - return ids.map(id => this.courses[id]); - }, + urlFor(url, parameters, ignore_params) { + return STUDIP.URLHelper.getURL(url, parameters, ignore_params); + }, - isParent (course) { - return course.children.length > 0 && course.children.every(childId => { - return this.courses[childId] !== undefined; - }); + getCourses (ids) { + return ids.map(id => this.courses[id]); + }, }, - isChild (course) { - return course.parent !== null && this.courses[course.parent] !== undefined; - }, - - getHiddenTooltip(course) { - let infotext = this.$gettext('Versteckte Veranstaltungen können über die Suchfunktionen nicht gefunden werden.'); - infotext += ' '; - if (course.is_teacher && this.getConfig('allow_dozent_visibility')) { - infotext += this.$gettext('Um die Veranstaltung sichtbar zu machen, wählen Sie den Punkt "Sichtbarkeit" im Administrationsbereich der Veranstaltung.'); - } else { - infotext += this.$gettext('Um die Veranstaltung sichtbar zu machen, wenden Sie sich an Administrierende.'); + computed: { + csrf() { + return STUDIP.CSRF_TOKEN; } - return infotext; }, - - getActionMenuForCourse(course, withColorPicker = false) { - let menu = []; - - if (!course.is_studygroup) { - menu.push({ - url: this.urlFor(`dispatch.php/course/details/index/${course.id}`, {from: this.urlFor('dispatch.php/my_courses/index')}), - label: this.$gettext('Veranstaltungsdetails'), - icon: 'info-circle', - attributes: { - 'data-dialog': '', - }, - }); + created() { + this.responsiveDisplay = Responsive.isResponsive(); + Responsive.media_query.addEventListener('change', () => { + this.responsiveDisplay = Responsive.isResponsive(); + }) + } + }; + + if (!minimal) { + result.components = { Navigation }; + + result.computed = { + ...result.computed, + + ...mapState('mycourses', [ + 'courses', + 'groups', + 'userid', + 'config', + ]), + ...mapGetters('mycourses', [ + 'isGroupOpen', + 'getConfig', + ]), + + numberOfNavElements () { + return Math.max( + ...Object.values(this.courses).map(course => { + const navigation = this.getNavigationForCourse(course, true); + return Object.values(navigation).length; + }) + ); } + }; - if (withColorPicker) { - // Color grouping - menu.push({ - emit: 'show-color-picker', - emitArguments: [course], - label: this.$gettext('Farbgruppierung ändern'), - icon: 'group4' - }); - } + result.methods = { + ...result.methods, - // Extra navigation? - if (!course.is_group) { - if (course.extra_navigation) { - menu.push(course.extra_navigation); - } else if (course.admission_binding) { + ...mapActions('mycourses', [ + 'toggleOpenGroup', + 'updateConfigValue', + ]), + + getActionMenuForCourse(course, withColorPicker = false) { + let menu = []; + + if (!course.is_studygroup) { menu.push({ - url: this.urlFor('dispatch.php/my_courses/decline_binding'), - label: this.$gettext('Aus der Veranstaltung austragen'), - icon: 'decline/door-leave', + url: this.urlFor(`dispatch.php/course/details/index/${course.id}`, {from: this.urlFor('dispatch.php/my_courses/index')}), + label: this.$gettext('Veranstaltungsdetails'), + icon: 'info-circle', attributes: { - title: this.$gettext('Die Teilnahme ist bindend. Bitte wenden Sie sich an die Lehrenden.'), + 'data-dialog': '', }, - disabled: true }); - } else { + } + + if (withColorPicker) { + // Color grouping menu.push({ - url: this.urlFor(`dispatch.php/my_courses/decline/${course.id}`, {cmd: 'suppose_to_kill'}), - label: this.$gettext('Aus der Veranstaltung austragen'), - icon: 'door-leave' + emit: 'show-color-picker', + emitArguments: [course], + label: this.$gettext('Farbgruppierung ändern'), + icon: 'group4' }); } - } - - return menu; - }, - getNavigationForCourse(course, gaps = false) { - let navigation = {}; - - Object.entries(course.navigation).forEach(([key, nav]) => { - if (!nav && !gaps) { - return; + // Extra navigation? + if (!course.is_group) { + if (course.extra_navigation) { + menu.push(course.extra_navigation); + } else if (course.admission_binding) { + menu.push({ + url: this.urlFor('dispatch.php/my_courses/decline_binding'), + label: this.$gettext('Aus der Veranstaltung austragen'), + icon: 'decline/door-leave', + attributes: { + title: this.$gettext('Die Teilnahme ist bindend. Bitte wenden Sie sich an die Lehrenden.'), + }, + disabled: true + }); + } else { + menu.push({ + url: this.urlFor(`dispatch.php/my_courses/decline/${course.id}`, {cmd: 'suppose_to_kill'}), + label: this.$gettext('Aus der Veranstaltung austragen'), + icon: 'door-leave' + }); + } } - if (this.getViewConfig('only_new') && !nav.important) { - return; + return menu; + }, + getHiddenTooltip(course) { + let infotext = this.$gettext('Versteckte Veranstaltungen können über die Suchfunktionen nicht gefunden werden.'); + infotext += ' '; + if (course.is_teacher && this.getConfig('allow_dozent_visibility')) { + infotext += this.$gettext('Um die Veranstaltung sichtbar zu machen, wählen Sie den Punkt "Sichtbarkeit" im Administrationsbereich der Veranstaltung.'); + } else { + infotext += this.$gettext('Um die Veranstaltung sichtbar zu machen, wenden Sie sich an Administrierende.'); } - - let result = nav ? Object.assign({}, nav) : false; - if (nav) { - if (nav.important) { - result.class = 'my-courses-navigation-important'; - result.icon.role = 'attention'; - result.icon.shape = result.icon.shape.replace(/^new\//, ''); - } else { - result.class = false; - result.icon.role = 'clickable'; + return infotext; + }, + getNavigationForCourse(course, gaps = false) { + let navigation = {}; + + Object.entries(course.navigation).forEach(([key, nav]) => { + if (!nav && !gaps) { + return; } - result.url = this.urlFor('dispatch.php/course/go', { - to: course.id, - redirect_to: result.url, - }); - } + if (this.getViewConfig('only_new') && !nav.important) { + return; + } - navigation[key] = result; - }); + let result = nav ? Object.assign({}, nav) : false; + if (nav) { + if (nav.important) { + result.class = 'my-courses-navigation-important'; + result.icon.role = 'attention'; + result.icon.shape = result.icon.shape.replace(/^new\//, ''); + } else { + result.class = false; + result.icon.role = 'clickable'; + } + + result.url = this.urlFor('dispatch.php/course/go', { + to: course.id, + redirect_to: result.url, + }); + } - return navigation; - }, - }, - - computed: { - ...mapState('mycourses', [ - 'courses', - 'groups', - 'userid', - 'config', - ]), - ...mapGetters('mycourses', [ - 'isGroupOpen', - 'getConfig', - ]), - - numberOfNavElements () { - return Math.max( - ...Object.values(this.courses).map(course => { - const navigation = this.getNavigationForCourse(course, true); - return Object.values(navigation).length; - }) - ); - } - }, + navigation[key] = result; + }); - created () { - this.responsiveDisplay = Responsive.isResponsive(); - Responsive.media_query.addListener(() => { - this.responsiveDisplay = Responsive.isResponsive(); - }) + return navigation; + }, + getViewConfig(key) { + return this.getConfig( + 'view_settings', + this.responsiveDisplay ? 'responsive' : 'regular', + key + ); + }, + isChild (course) { + return course.parent !== null && this.courses[course.parent] !== undefined; + }, + isParent (course) { + return course.children.length > 0 && course.children.every(childId => { + return this.courses[childId] !== undefined; + }); + }, + updateViewConfig(key, value) { + let config = this.getConfig('view_settings'); + config[this.responsiveDisplay ? 'responsive' : 'regular'][key] = value; + return this.updateConfigValue({ + key: 'view_settings', + value: config + }); + }, + }; } + + return result; } + +export default createMixin(); + +export { createMixin }; |
