Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
92.86% covered (success)
92.86%
52 / 56
60.00% covered (warning)
60.00%
3 / 5
CRAP
0.00% covered (danger)
0.00%
0 / 1
DashboardLayout
92.86% covered (success)
92.86%
52 / 56
60.00% covered (warning)
60.00%
3 / 5
15.08
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 get
88.46% covered (success)
88.46%
23 / 26
0.00% covered (danger)
0.00%
0 / 1
9.12
 save
100.00% covered (success)
100.00%
18 / 18
100.00% covered (success)
100.00%
1 / 1
2
 hasRow
88.89% covered (success)
88.89%
8 / 9
0.00% covered (danger)
0.00%
0 / 1
2.01
 reset
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
1<?php
2
3/**
4 * Per-admin dashboard widget layout storage.
5 *
6 * Persists which dashboard widgets an admin sees and in which order, as a
7 * JSON document in the faqadmindashboard table (one row per user).
8 *
9 * This Source Code Form is subject to the terms of the Mozilla Public License,
10 * v. 2.0. If a copy of the MPL was not distributed with this file, You can
11 * obtain one at https://mozilla.org/MPL/2.0/.
12 *
13 * @package   phpMyFAQ
14 * @author    Thorsten Rinne <thorsten@phpmyfaq.de>
15 * @copyright 2026 phpMyFAQ Team
16 * @license   https://www.mozilla.org/MPL/2.0/ Mozilla Public License Version 2.0
17 * @link      https://www.phpmyfaq.de
18 * @since     2026-05-18
19 */
20
21declare(strict_types=1);
22
23namespace phpMyFAQ\Administration;
24
25use JsonException;
26use phpMyFAQ\Configuration;
27use phpMyFAQ\Database;
28
29readonly class DashboardLayout
30{
31    public function __construct(
32        private Configuration $configuration,
33    ) {
34    }
35
36    /**
37     * Returns the stored widget layout for a user, or an empty array when none exists.
38     *
39     * @return array<int, array{key: string, position: int, visible: bool}>
40     */
41    public function get(int $userId): array
42    {
43        $query = sprintf(
44            'SELECT config FROM %sfaqadmindashboard WHERE user_id = %d',
45            Database::getTablePrefix(),
46            $userId,
47        );
48
49        $result = $this->configuration->getDb()->query($query);
50        if ($result === false) {
51            return [];
52        }
53
54        $row = $this->configuration->getDb()->fetchObject($result);
55        if (!is_object($row) || !is_string($row->config ?? null) || $row->config === '') {
56            return [];
57        }
58
59        try {
60            $config = json_decode($row->config, associative: true, depth: 16, flags: JSON_THROW_ON_ERROR);
61        } catch (JsonException) {
62            return [];
63        }
64
65        if (!is_array($config)) {
66            return [];
67        }
68
69        $layout = [];
70        foreach ($config as $entry) {
71            if (!is_array($entry)) {
72                continue;
73            }
74
75            $layout[] = [
76                'key' => (string) ($entry['key'] ?? ''),
77                'position' => (int) ($entry['position'] ?? 0),
78                'visible' => (bool) ($entry['visible'] ?? true),
79            ];
80        }
81
82        return $layout;
83    }
84
85    /**
86     * Stores the widget layout for a user, replacing any previous layout.
87     *
88     * @param array<int, array{key: string, position: int, visible: bool}> $config
89     * @throws JsonException
90     */
91    public function save(int $userId, array $config): bool
92    {
93        $database = $this->configuration->getDb();
94        $prefix = Database::getTablePrefix();
95        $encoded = $database->escape(json_encode($config, JSON_THROW_ON_ERROR));
96
97        // Update an existing row in place rather than delete-then-insert: this avoids
98        // a window where a failing insert would leave the user without any layout.
99        if ($this->hasRow($userId)) {
100            $update = sprintf(
101                "UPDATE %sfaqadmindashboard SET config = '%s' WHERE user_id = %d",
102                $prefix,
103                $encoded,
104                $userId,
105            );
106
107            return $database->query($update) !== false;
108        }
109
110        $insert = sprintf(
111            "INSERT INTO %sfaqadmindashboard (user_id, config) VALUES (%d, '%s')",
112            $prefix,
113            $userId,
114            $encoded,
115        );
116
117        return $database->query($insert) !== false;
118    }
119
120    /**
121     * Returns whether a layout row already exists for the given user.
122     */
123    private function hasRow(int $userId): bool
124    {
125        $query = sprintf(
126            'SELECT user_id FROM %sfaqadmindashboard WHERE user_id = %d',
127            Database::getTablePrefix(),
128            $userId,
129        );
130
131        $result = $this->configuration->getDb()->query($query);
132        if ($result === false) {
133            return false;
134        }
135
136        return is_object($this->configuration->getDb()->fetchObject($result));
137    }
138
139    /**
140     * Removes the stored layout for a user, reverting them to the default layout.
141     */
142    public function reset(int $userId): bool
143    {
144        $query = sprintf('DELETE FROM %sfaqadmindashboard WHERE user_id = %d', Database::getTablePrefix(), $userId);
145
146        return $this->configuration->getDb()->query($query) !== false;
147    }
148}