From 2df806ab549ac7b7522fb3443e654d83b7095843 Mon Sep 17 00:00:00 2001
From: Jan-Hendrik Willms <tleilax+studip@gmail.com>
Date: Wed, 3 May 2023 10:00:38 +0000
Subject: [PATCH] complete separate regular and responsive view settings for my
 courses, fixes #2071

Closes #2071

Merge request studip/studip!1557
---
 app/controllers/my_courses.php                |   4 +-
 ...5.4.3_combine_my_courses_view_settings.php | 167 ++++++++++++++++++
 .../ConfigValues/ConfigValuesUpdate.php       |   6 +-
 .../Routes/ConfigValues/HelperTrait.php       |   9 +-
 lib/models/ConfigValue.php                    |  10 ++
 resources/vue/components/MyCourses.vue        |   2 +-
 .../components/MyCoursesNewContentToggle.vue  |   9 +-
 .../vue/components/MyCoursesSidebarSwitch.vue |  25 +--
 resources/vue/mixins/MyCoursesMixin.js        |  21 ++-
 resources/vue/store/MyCoursesStore.js         |  23 +--
 10 files changed, 225 insertions(+), 51 deletions(-)
 create mode 100644 db/migrations/5.4.3_combine_my_courses_view_settings.php

diff --git a/app/controllers/my_courses.php b/app/controllers/my_courses.php
index c0dbeb3798a..996ad87a604 100644
--- a/app/controllers/my_courses.php
+++ b/app/controllers/my_courses.php
@@ -793,9 +793,7 @@ class MyCoursesController extends AuthenticatedController
                 '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,
-                'display_type'             => $GLOBALS['user']->cfg->MY_COURSES_TILED_DISPLAY ? 'tiles' : 'tables',
-                'responsive_type'          => $GLOBALS['user']->cfg->MY_COURSES_TILED_DISPLAY_RESPONSIVE ? 'tiles' : 'tables',
-                'navigation_show_only_new' => $GLOBALS['user']->cfg->MY_COURSES_SHOW_NEW_ICONS_ONLY,
+                'view_settings'            => $GLOBALS['user']->cfg->MY_COURSES_VIEW_SETTINGS,
                 'group_by'                 => $this->getGroupField(),
             ],
         ];
diff --git a/db/migrations/5.4.3_combine_my_courses_view_settings.php b/db/migrations/5.4.3_combine_my_courses_view_settings.php
new file mode 100644
index 00000000000..f191a530d8a
--- /dev/null
+++ b/db/migrations/5.4.3_combine_my_courses_view_settings.php
@@ -0,0 +1,167 @@
+<?php
+
+/**
+ * - MY_COURSES_TILED_DISPLAY
+ * - MY_COURSES_TILED_DISPLAY_RESPONSIVE
+ * - MY_COURSES_SHOW_NEW_ICONS_ONLY
+ */
+final class CombineMyCoursesViewSettings extends Migration
+{
+    const OLD_FIELDS = [
+        'MY_COURSES_SHOW_NEW_ICONS_ONLY' => false,
+        'MY_COURSES_TILED_DISPLAY' => false,
+        'MY_COURSES_TILED_DISPLAY_RESPONSIVE' => true,
+    ];
+
+    public function description()
+    {
+        return 'Combines the different view settings for my courses into a single configuration';
+    }
+
+    protected function up()
+    {
+        // Add new configuration
+        $query = "INSERT IGNORE INTO `config` (
+                    `field`, `value`, `type`, `range`,
+                    `section`, `description`,
+                    `mkdate`, `chdate`
+                  ) VALUES (
+                    'MY_COURSES_VIEW_SETTINGS', ?, 'array', 'user',
+                    'MeineVeranstaltungen', 'Konfiguration der Ansicht \"Meine Veranstaltungen\"',
+                    UNIX_TIMESTAMP(), UNIX_TIMESTAMP()
+                  )";
+        DBManager::get()->execute($query, [
+            json_encode($this->convertOldConfig(['MY_COURSES_TILED_DISPLAY_RESPONSIVE' => true]))
+        ]);
+
+        // Migrate old settings
+        $this->migrateOldConfigurations();
+
+        // Drop old configuration
+        $query = "DELETE `config`, `config_values`
+                  FROM `config`
+                  LEFT JOIN `config_values` USING (`field`)
+                  WHERE `field` IN (?)";
+        DBManager::get()->execute($query, [
+            array_keys(self::OLD_FIELDS),
+        ]);
+    }
+
+    protected function down()
+    {
+        // Restore old configuration
+        $query = "INSERT IGNORE INTO `config` (
+                    `field`, `value`, `type`, `range`,
+                    `section`, `description`,
+                    `mkdate`, `chdate`
+                  ) VALUES (
+                    'MY_COURSES_SHOW_NEW_ICONS_ONLY', 0, 'boolean', 'user',
+                    'MeineVeranstaltungen', 'Nur Icons für neue Inhalte sollen angezeigt werden',
+                    UNIX_TIMESTAMP(), UNIX_TIMESTAMP()
+                  ), (
+                    'MY_COURSES_TILED_DISPLAY', 0, 'boolean', 'user',
+                    'MeineVeranstaltungen', 'Hat die Kachelansicht unter \"Meine Veranstaltungen\" aktiviert',
+                    UNIX_TIMESTAMP(), UNIX_TIMESTAMP()
+                  ), (
+                    'MY_COURSES_TILED_DISPLAY_RESPONSIVE', 0, 'boolean', 'user',
+                    'MeineVeranstaltungen', 'Hat die Kachelansicht unter \"Meine Veranstaltungen\" aktiviert (responsiv)',
+                    UNIX_TIMESTAMP(), UNIX_TIMESTAMP()
+                  )";
+        DBManager::get()->exec($query);
+
+        // Migrate new settings
+        $this->migrateNewConfigurations();
+
+        // Drop new configuration
+        $query = "DELETE `config`, `config_values`
+                  FROM `config`
+                  LEFT JOIN `config_values` USING (`field`)
+                  WHERE `field` = 'MY_COURSES_VIEW_SETTINGS'";
+        DBManager::get()->exec($query);
+    }
+
+    private function migrateOldConfigurations(): void
+    {
+        $query = "SELECT `value`
+                  FROM `config_values`
+                  WHERE `range_id` = :user_id AND `field` = :field";
+        $values_statement = DBManager::get()->prepare($query);
+
+        $query = "INSERT IGNORE INTO `config_values` (`field`, `range_id`, `value`, `mkdate`, `chdate`)
+                  VALUES ('MY_COURSES_VIEW_SETTINGS', :user_id, :value, UNIX_TIMESTAMP(), UNIX_TIMESTAMP())";
+        $insert_statement = DBManager::get()->prepare($query);
+
+        $query = "SELECT DISTINCT `range_id`
+                  FROM `config_values`
+                  WHERE `field` IN (?)";
+        $user_ids = DBManager::get()->fetchFirst($query, [
+            array_keys(self::OLD_FIELDS),
+        ]);
+        foreach ($user_ids as $user_id) {
+            $values_statement->bindValue(':user_id', $user_id);
+
+            $config = self::OLD_FIELDS;
+            foreach (array_keys(self::OLD_FIELDS) as $field) {
+                $values_statement->bindValue(':field', $field);
+                $values_statement->execute();
+
+                $config[$field] = $values_statement->fetchColumn();
+            }
+
+            $insert_statement->execute([
+                ':user_id' => $user_id,
+                ':value'   => json_encode($this->convertOldConfig($config)),
+            ]);
+        }
+    }
+
+    private function convertOldConfig(array $config): array
+    {
+        return [
+            'regular' => [
+                'tiled'    => (bool) $config['MY_COURSES_TILED_DISPLAY'],
+                'only_new' => (bool) $config['MY_COURSES_SHOW_NEW_ICONS_ONLY'],
+            ],
+            'responsive' => [
+                'tiled'    => (bool) $config['MY_COURSES_TILED_DISPLAY_RESPONSIVE'],
+                'only_new' => (bool) $config['MY_COURSES_SHOW_NEW_ICONS_ONLY'],
+            ],
+        ];
+    }
+
+    private function migrateNewConfigurations(): void
+    {
+        $query = "INSERT IGNORE INTO `config_values` (`field`, `range_id`, `value`, `mkdate`, `chdate`)
+                  VALUES (:field, :user_id, :value, UNIX_TIMESTAMP(), UNIX_TIMESTAMP())";
+        $insert_statement = DBManager::get()->prepare($query);
+
+        $query = "SELECT `range_id`, `value`
+                  FROM `config_values`
+                  WHERE `field` = 'MY_COURSES_VIEW_SETTINGS'";
+        $statement = DBManager::get()->exec($query);
+        $statement->setFetchMode(PDO::FETCH_ASSOC);
+        foreach ($statement as $row) {
+            $config = json_decode($row['value'], true);
+
+            $insert_statement->bindValue(':user_id', $row['user_id']);
+
+            foreach ($this->convertNewConfig($config) as $field => $value) {
+                if ($value !== self::OLD_FIELDS[$field]) {
+                    $insert_statement->bindValue(':field', $field);
+                    $insert_statement->bindValue(':value', (int) $value);
+                    $insert_statement->execute();
+                }
+            }
+        }
+    }
+
+    private function convertNewConfig(array $config): array
+    {
+        return [
+            'MY_COURSES_SHOW_NEW_ICONS_ONLY' => $config['regular']['only_new'] || $config['responsive']['only_new'],
+            'MY_COURSES_TILED_DISPLAY' => $config['regular']['tiled'],
+            'MY_COURSES_TILED_DISPLAY_RESPONSIVE' => $config['responsive']['tiled'],
+        ];
+    }
+
+}
diff --git a/lib/classes/JsonApi/Routes/ConfigValues/ConfigValuesUpdate.php b/lib/classes/JsonApi/Routes/ConfigValues/ConfigValuesUpdate.php
index cf46134eb00..c4fda2f33a1 100644
--- a/lib/classes/JsonApi/Routes/ConfigValues/ConfigValuesUpdate.php
+++ b/lib/classes/JsonApi/Routes/ConfigValues/ConfigValuesUpdate.php
@@ -31,7 +31,11 @@ class ConfigValuesUpdate extends JsonApiController
         $resource = $this->findOrFakeConfigValue($range, $field);
 
         // TODO: zunächst kann diese Route nur Konfigurationseinstellungen vom Typ bool ändern
-        if ('boolean' !== $resource->entry['type'] && $resource->entry['field'] !== 'MY_COURSES_OPEN_GROUPS') {
+        if (
+            'boolean' !== $resource->entry['type']
+            && $resource->entry['field'] !== 'MY_COURSES_OPEN_GROUPS'
+            && $resource->entry['field'] !== 'MY_COURSES_VIEW_SETTINGS'
+        ) {
             throw new NotImplementedException();
         }
 
diff --git a/lib/classes/JsonApi/Routes/ConfigValues/HelperTrait.php b/lib/classes/JsonApi/Routes/ConfigValues/HelperTrait.php
index 3742fb5815d..eac1d379f3d 100644
--- a/lib/classes/JsonApi/Routes/ConfigValues/HelperTrait.php
+++ b/lib/classes/JsonApi/Routes/ConfigValues/HelperTrait.php
@@ -2,11 +2,12 @@
 
 namespace JsonApi\Routes\ConfigValues;
 
+use ConfigValue;
 use JsonApi\Errors\RecordNotFoundException;
 
 trait HelperTrait
 {
-    private function generateId(\ConfigValue $resource): string
+    private function generateId(ConfigValue $resource): string
     {
         return join('_', [$resource['range_id'], $resource['field']]);
     }
@@ -29,10 +30,10 @@ trait HelperTrait
         return $range;
     }
 
-    private function findOrFakeConfigValue(?\Range $range, string $field)
+    private function findOrFakeConfigValue(?\Range $range, string $field): ConfigValue
     {
         // first search optimistically for this config value
-        if ($configValue = \ConfigValue::find([$field, $range->id])) {
+        if ($configValue = ConfigValue::find([$field, $range->id])) {
             return $configValue;
         }
 
@@ -41,7 +42,7 @@ trait HelperTrait
             throw new RecordNotFoundException();
         }
 
-        return \ConfigValue::build([
+        return ConfigValue::build([
             'field' => $field,
             'range_id' => $range->id,
             'value' => $configEntry->value,
diff --git a/lib/models/ConfigValue.php b/lib/models/ConfigValue.php
index 4e27ec7843c..829567d4ea9 100644
--- a/lib/models/ConfigValue.php
+++ b/lib/models/ConfigValue.php
@@ -12,6 +12,16 @@
  * @license     http://www.gnu.org/licenses/gpl-2.0.html GPL version 2
  *
  * @category    Stud.IP
+ *
+ * @property array $id
+ * @property string $field
+ * @property string $range_id
+ * @property string $value
+ * @property int $mkdate
+ * @property int $chdate
+ * @property string $comment
+ *
+ * @property ConfigEntry $entry
  */
 
 class ConfigValue extends SimpleORMap
diff --git a/resources/vue/components/MyCourses.vue b/resources/vue/components/MyCourses.vue
index 769d3152a4e..40480251c4b 100644
--- a/resources/vue/components/MyCourses.vue
+++ b/resources/vue/components/MyCourses.vue
@@ -55,7 +55,7 @@ export default {
                  : MyCoursesTables;
         },
         displayedType () {
-            return this.getConfig(this.viewConfig);
+            return this.getViewConfig('tiled') ? 'tiles' : 'table';
         },
         iconSize () {
             if (this.displayedType !== 'tiles' && !this.responsiveDisplay) {
diff --git a/resources/vue/components/MyCoursesNewContentToggle.vue b/resources/vue/components/MyCoursesNewContentToggle.vue
index 49bf7eda233..3ced26d950f 100644
--- a/resources/vue/components/MyCoursesNewContentToggle.vue
+++ b/resources/vue/components/MyCoursesNewContentToggle.vue
@@ -2,7 +2,7 @@
     <ul class="widget-list widget-options">
         <li>
             <a href="#" class="options-checkbox" :class="showNewContents ? 'options-checked' : 'options-unchecked'" @click.prevent="toggleNewContents">
-                <translate>Nur neue Inhalte anzeigen</translate>
+                {{ $gettext('Nur neue Inhalte anzeigen') }}
             </a>
         </li>
     </ul>
@@ -17,15 +17,12 @@ export default {
     mixins: [MyCoursesMixin],
     computed: {
         showNewContents () {
-            return this.getConfig('navigation_show_only_new');
+            return this.getViewConfig('only_new');
         },
     },
     methods: {
         toggleNewContents() {
-            this.updateConfigValue({
-                key: 'navigation_show_only_new',
-                value: !this.getConfig('navigation_show_only_new'),
-            }).then(() => {
+            this.updateViewConfig('only_new', !this.showNewContents).then(() => {
                 Sidebar.close();
             });
         },
diff --git a/resources/vue/components/MyCoursesSidebarSwitch.vue b/resources/vue/components/MyCoursesSidebarSwitch.vue
index 5abc1fb7b6b..788c053a21c 100644
--- a/resources/vue/components/MyCoursesSidebarSwitch.vue
+++ b/resources/vue/components/MyCoursesSidebarSwitch.vue
@@ -1,13 +1,13 @@
 <template>
     <ul class="widget-list widget-links sidebar-views">
         <li :class="{ active: tableView }">
-            <a href="#" @click.prevent="setTableView">
-                <translate>Tabellarische Ansicht</translate>
+            <a href="#" @click.prevent="setTiledView(false)">
+                {{ $gettext('Tabellarische Ansicht') }}
             </a>
         </li>
         <li :class="{ active: tilesView }">
-            <a href="#" @click.prevent="setTilesView">
-                <translate>Kachelansicht</translate>
+            <a href="#" @click.prevent="setTiledView(true)">
+                {{ $gettext('Kachelansicht') }}
             </a>
         </li>
     </ul>
@@ -22,24 +22,15 @@ export default {
     mixins: [MyCoursesMixin],
     computed: {
         tableView () {
-            return this.getConfig(this.viewConfig) === 'tables';
+            return !this.getViewConfig('tiled');
         },
         tilesView () {
-            return this.getConfig(this.viewConfig) === 'tiles';
+            return this.getViewConfig('tiled');
         },
     },
     methods: {
-        setTableView () {
-            this.setView('tables');
-        },
-        setTilesView () {
-            this.setView('tiles');
-        },
-        setView (view) {
-            this.updateConfigValue({
-                key: this.viewConfig,
-                value: view
-            }).then(() => {
+        setTiledView (state) {
+            this.updateViewConfig('tiled', state).then(() => {
                 Sidebar.close();
             });
         }
diff --git a/resources/vue/mixins/MyCoursesMixin.js b/resources/vue/mixins/MyCoursesMixin.js
index 203340b5ebb..39e11e739d0 100644
--- a/resources/vue/mixins/MyCoursesMixin.js
+++ b/resources/vue/mixins/MyCoursesMixin.js
@@ -16,6 +16,22 @@ export default {
             '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
+            });
+        },
+
         getCourseName(course, include_number = false) {
             let name = course.name;
             if (include_number) {
@@ -110,7 +126,7 @@ export default {
                     return;
                 }
 
-                if (this.getConfig('navigation_show_only_new') && !nav.important) {
+                if (this.getViewConfig('only_new') && !nav.important) {
                     return;
                 }
 
@@ -150,9 +166,6 @@ export default {
             'getConfig',
         ]),
 
-        viewConfig () {
-            return this.responsiveDisplay ? 'responsive_type' : 'display_type';
-        },
         numberOfNavElements () {
             return Math.max(
                 ...Object.values(this.courses).map(course => {
diff --git a/resources/vue/store/MyCoursesStore.js b/resources/vue/store/MyCoursesStore.js
index a0900404e44..08c03891bef 100644
--- a/resources/vue/store/MyCoursesStore.js
+++ b/resources/vue/store/MyCoursesStore.js
@@ -1,17 +1,7 @@
 const configMapping = {
-    display_type: value => {
+    view_settings: value => {
         return {
-            MY_COURSES_TILED_DISPLAY: value === 'tiles',
-        }
-    },
-    responsive_type: value => {
-        return {
-            MY_COURSES_TILED_DISPLAY_RESPONSIVE: value === 'tiles',
-        }
-    },
-    navigation_show_only_new: value => {
-        return {
-            MY_COURSES_SHOW_NEW_ICONS_ONLY: value,
+            MY_COURSES_VIEW_SETTINGS: value
         };
     },
     open_groups: value => {
@@ -19,7 +9,6 @@ const configMapping = {
             MY_COURSES_OPEN_GROUPS: value,
         };
     },
-
 };
 
 export default {
@@ -39,8 +28,12 @@ export default {
             }
             return state.config.open_groups.includes(group.id);
         },
-        getConfig: (state) => (key) => {
-            return state.config[key];
+        getConfig: (state) => (...keys) => {
+            let config = state.config;
+            for (const key of keys) {
+                config = config[key];
+            }
+            return config;
         },
     },
 
-- 
GitLab