diff options
author | Ben Burgess <88810029+bx80@users.noreply.github.com> | 2021-12-22 23:26:51 +0300 |
---|---|---|
committer | GitHub <noreply@github.com> | 2021-12-22 23:26:51 +0300 |
commit | d68dc6dd06606e8a7611c53d8ee63da8afbf99f7 (patch) | |
tree | b0363d254677ec77d42ff75352992405b41aef81 /core | |
parent | 10afd46db0e033e67127fb4e50eea986ed5296e6 (diff) |
Show a summary of new features (#18065)
* Added "What is new" notification display, populated by a new event
* Removed test example event hook
* Added support for applying a link attribute to menu items, fixes layout issue for mobile with html menu items
* Updated UI test screenshots
* Revert accidental edit
* Hide the "What's new" icon if there are no new features to show
* Changed to use changes.json, track user last viewed, added ui test
* Fix UserManager unit tests broken by new ts_changes_viewed user field
* Moved getChanges to separate helper class, added unit test, added user view access check
* Updated to add new changes table and populate only on plugin update/install
* Added missing fixture class, updated UI screenshots
* Updated matomo font to add ringing bell and new releases icons
* Fix for integration test
* Reworked class structure, removed unnecessary angular directive, merged templates, other tidy ups
* built vue files
* built vue files
* Added null user check, missing table exception handling, show plugin name in change title, better handling of missing change fields
* Added sample changes file, moved UserChanges db code to changes model, added return type hints, better db error code handling, various other improvements
* Revert accidental UI screenshot commit
* Fix for incorrect link name parameter in sample changes, switched back to using $db->query for INSERT IGNORE
* Integration test fix, UI screenshot updates
* Test fix
* Added link styling, show CoreHome changes without plugin prefix in title
* Update UI test screenshot
* Added styles to the popover, added event for filtering changes
* Test fix
* UI test screenshot updates
Co-authored-by: sgiehl <stefan@matomo.org>
Co-authored-by: bx80 <bx80@users.noreply.github.com>
Diffstat (limited to 'core')
-rw-r--r-- | core/Changes/Model.php | 219 | ||||
-rw-r--r-- | core/Changes/UserChanges.php | 77 | ||||
-rw-r--r-- | core/Db/Schema/Mysql.php | 19 | ||||
-rw-r--r-- | core/Menu/MenuAbstract.php | 15 | ||||
-rw-r--r-- | core/Plugin.php | 22 | ||||
-rw-r--r-- | core/Updates/4.7.0-b2.php | 70 | ||||
-rw-r--r-- | core/Version.php | 2 |
7 files changed, 417 insertions, 7 deletions
diff --git a/core/Changes/Model.php b/core/Changes/Model.php new file mode 100644 index 0000000000..a9b46ad7a3 --- /dev/null +++ b/core/Changes/Model.php @@ -0,0 +1,219 @@ +<?php +/** + * Matomo - free/libre analytics platform + * + * @link https://matomo.org + * @license http://www.gnu.org/licenses/gpl-3.0.html GPL v3 or later + * + */ +namespace Piwik\Changes; + +use Piwik\Exception\Exception; +use Piwik\Piwik; +use Piwik\Common; +use Piwik\Date; +use Piwik\Db; +use Piwik\Tracker\Db\DbException; +use Piwik\Updater\Migration; +use Piwik\Container\StaticContainer; +use Piwik\Plugin\Manager as PluginManager; + +/** + * Change model class + * + * Handle all data access operations for changes + * + */ +class Model +{ + + const NO_CHANGES_EXIST = 0; + const CHANGES_EXIST = 1; + const NEW_CHANGES_EXIST = 2; + + private $pluginManager; + + /** + * @var Db\AdapterInterface + */ + private $db; + + /** + * @param Db\AdapterInterface|null $db + * @param PluginManager|null $pluginManager + */ + public function __construct(?Db\AdapterInterface $db = null, ?PluginManager $pluginManager = null) + { + $this->db = ($db ?? Db::get()); + $this->pluginManager = ($pluginManager ?? PluginManager::getInstance()); + } + + /** + * Add any new changes for a plugin to the changes table + * + * @param string $pluginName + * + * @throws \Exception + */ + public function addChanges(string $pluginName): void + { + if ($this->pluginManager->isValidPluginName($pluginName) && $this->pluginManager->isPluginInFilesystem($pluginName)) { + + $plugin = $this->pluginManager->loadPlugin($pluginName); + if (!$plugin) { + return; + } + + $changes = $plugin->getChanges(); + foreach ($changes as $change) { + $this->addChange($pluginName, $change); + } + } + } + + /** + * Remove all changes for a plugin + * + * @param string $pluginName + */ + public function removeChanges(string $pluginName): void + { + $table = Common::prefixTable('changes'); + + try { + $this->db->query("DELETE FROM " . $table . " WHERE plugin_name = ?", [$pluginName]); + } catch (\Exception $e) { + if (Db::get()->isErrNo($e, Migration\Db::ERROR_CODE_TABLE_NOT_EXISTS)) { + return; + } + throw $e; + } + } + + /** + * Add a change item to the database table + * + * @param string $pluginName + * @param array $change + */ + public function addChange(string $pluginName, array $change): void + { + if(!isset($change['version']) || !isset($change['title']) || !isset($change['description'])) { + StaticContainer::get('Psr\Log\LoggerInterface')->warning( + "Change item for plugin {plugin} missing version, title or description fields - ignored", + ['plugin' => $pluginName]); + return; + } + + $table = Common::prefixTable('changes'); + + $fields = ['created_time', 'plugin_name', 'version', 'title', 'description']; + $params = [Date::now()->getDatetime(), $pluginName, $change['version'], $change['title'], $change['description']]; + + if (isset($change['link_name']) && isset($change['link'])) { + $fields[] = 'link_name'; + $fields[] = 'link'; + $params[] = $change['link_name']; + $params[] = $change['link']; + } + + $insertSql = 'INSERT IGNORE INTO ' . $table . ' ('.implode(',', $fields).') + VALUES ('.Common::getSqlStringFieldsArray($params).')'; + + try { + $this->db->query($insertSql, $params); + } catch (\Exception $e) { + if (Db::get()->isErrNo($e, Migration\Db::ERROR_CODE_TABLE_NOT_EXISTS)) { + return; + } + throw $e; + } + } + + /** + * Check if any changes items exist + * + * @param int|null $newerThanId Only count new changes as having a key > than this sequential key + * + * @return int + */ + public function doChangesExist(?int $newerThanId = null): int + { + $changes = $this->getChangeItems(); + + $all = 0; + $new = 0; + foreach ($changes as $c) { + $all++; + if ($newerThanId !== null && isset($c['idchange']) && $c['idchange'] > $newerThanId) { + $new++; + } + } + + if ($all === 0) { + return self::NO_CHANGES_EXIST; + } else if ($all > 0 && $new === 0) { + return self::CHANGES_EXIST; + } else { + return self::NEW_CHANGES_EXIST; + } + } + + /** + * Return an array of change items from the changes table + * + * @return array + * @throws DbException + */ + public function getChangeItems(): array + { + $showAtLeast = 10; // Always show at least this number of changes + $expireOlderThanDays = 90; // Don't show changes that were added to the table more than x days ago + + $table = Common::prefixTable('changes'); + $selectSql = "SELECT * FROM " . $table . " WHERE title IS NOT NULL ORDER BY idchange DESC"; + + try { + $changes = $this->db->fetchAll($selectSql); + } catch (\Exception $e) { + if (Db::get()->isErrNo($e, Migration\Db::ERROR_CODE_TABLE_NOT_EXISTS)) { + return []; + } + throw $e; + } + + // Remove expired changes, only if there are at more than the minimum changes + $cutOffDate = Date::now()->subDay($expireOlderThanDays); + foreach ($changes as $k => $change) { + if (isset($change['idchange'])) { + $changes[$k]['idchange'] = (int)$change['idchange']; + } + if (count($changes) > $showAtLeast && $change['created_time'] < $cutOffDate) { + unset($changes[$k]); + } + } + + /** + * Event triggered before changes are displayed + * + * Can be used to filter out unwanted changes + * + * **Example** + * + * Piwik::addAction('Changes.filterChanges', function ($changes) { + * foreach ($changes as $k => $c) { + * // Hide changes for the CoreHome plugin + * if (isset($c['plugin_name']) && $c['plugin_name'] == 'CoreHome') { + * unset($changes[$k]); + * } + * } + * }); + * + * @param array &$changes + */ + Piwik::postEvent('Changes.filterChanges', array(&$changes)); + + return $changes; + } + +} diff --git a/core/Changes/UserChanges.php b/core/Changes/UserChanges.php new file mode 100644 index 0000000000..5e971c776d --- /dev/null +++ b/core/Changes/UserChanges.php @@ -0,0 +1,77 @@ +<?php +/** + * Matomo - free/libre analytics platform + * + * @link https://matomo.org + * @license http://www.gnu.org/licenses/gpl-3.0.html GPL v3 or later + * + */ +namespace Piwik\Changes; + +use Piwik\Db; +use Piwik\Changes\Model as ChangesModel; +use Piwik\Plugins\UsersManager\Model as UsersModel; + +/** + * CoreHome user changes class + */ +class UserChanges +{ + + /** + * @var Db\AdapterInterface + */ + private $db; + private $user; + + /** + * @param array $user + * @param Db\AdapterInterface|null $db + */ + public function __construct(array $user, ?Db\AdapterInterface $db = null) + { + $this->db = ($db ?? Db::get()); + $this->user = $user; + } + + /** + * Return a value indicating if there are any changes available to show the user + * + * @return int Changes\Model::NO_CHANGES_EXIST, Changes\Model::CHANGES_EXIST or Changes\Model::NEW_CHANGES_EXIST + * @throws \Exception + */ + public function getNewChangesStatus(): int + { + $idchangeLastViewed = (isset($this->user['idchange_last_viewed']) ? $this->user['idchange_last_viewed'] : null); + + $changesModel = new ChangesModel($this->db); + return $changesModel->doChangesExist($idchangeLastViewed); + } + + /** + * Return an array of changes and update the user's changes last viewed value + * + * @return array + */ + public function getChanges(): array + { + $changesModel = new ChangesModel(Db::get()); + $changes = $changesModel->getChangeItems(); + + // Record the time that changes were viewed for the current user + $maxId = null; + foreach ($changes as $k => $change) { + if ($maxId < $change['idchange']) { + $maxId = $change['idchange']; + } + } + + if ($maxId) { + $usersModel = new UsersModel(); + $usersModel->updateUserFields($this->user['login'], ['idchange_last_viewed' => $maxId]); + } + + return $changes; + } + +} diff --git a/core/Db/Schema/Mysql.php b/core/Db/Schema/Mysql.php index 92d2b882fb..09b0b48922 100644 --- a/core/Db/Schema/Mysql.php +++ b/core/Db/Schema/Mysql.php @@ -52,6 +52,7 @@ class Mysql implements SchemaInterface superuser_access TINYINT(2) unsigned NOT NULL DEFAULT '0', date_registered TIMESTAMP NULL, ts_password_modified TIMESTAMP NULL, + idchange_last_viewed TIMESTAMP NULL, PRIMARY KEY(login) ) ENGINE=$engine DEFAULT CHARSET=$charset ", @@ -358,6 +359,19 @@ class Mysql implements SchemaInterface PRIMARY KEY (`key`) ) ENGINE=$engine DEFAULT CHARSET=$charset ", + 'changes' => "CREATE TABLE `{$prefixTables}changes` ( + `idchange` INTEGER UNSIGNED NOT NULL AUTO_INCREMENT, + `created_time` DATETIME NOT NULL, + `plugin_name` VARCHAR(255) NOT NULL, + `version` VARCHAR(20) NOT NULL, + `title` VARCHAR(255) NOT NULL, + `description` TEXT NULL, + `link_name` VARCHAR(255) NULL, + `link` VARCHAR(255) NULL, + PRIMARY KEY(`idchange`), + UNIQUE KEY unique_plugin_version_title (`plugin_name`, `version`, `title`) + ) ENGINE=$engine DEFAULT CHARSET=$charset + ", ); return $tables; @@ -574,8 +588,9 @@ class Mysql implements SchemaInterface // note that the token_auth value is anonymous, which is assigned by default as well in the Login plugin $db = $this->getDb(); $db->query("INSERT IGNORE INTO " . Common::prefixTable("user") . " - (`login`, `password`, `email`, `twofactor_secret`, `superuser_access`, `date_registered`, `ts_password_modified`) - VALUES ( 'anonymous', '', 'anonymous@example.org', '', 0, '$now', '$now' );"); + (`login`, `password`, `email`, `twofactor_secret`, `superuser_access`, `date_registered`, `ts_password_modified`, + `idchange_last_viewed`) + VALUES ( 'anonymous', '', 'anonymous@example.org', '', 0, '$now', '$now' , NULL);"); $model = new Model(); $model->addTokenAuth('anonymous', 'anonymous', 'anonymous default token', $now); diff --git a/core/Menu/MenuAbstract.php b/core/Menu/MenuAbstract.php index 2783d7f48d..5a276dce2e 100644 --- a/core/Menu/MenuAbstract.php +++ b/core/Menu/MenuAbstract.php @@ -98,10 +98,11 @@ abstract class MenuAbstract extends Singleton * @param bool|string $tooltip An optional tooltip to display or false to display the tooltip. * @param bool|string $icon An icon classname, such as "icon-add". Only supported by admin menu * @param bool|string $onclick Will execute the on click handler instead of executing the link. Only supported by admin menu. + * @param string $attribute Will add this string as a link attribute. * @since 2.7.0 * @api */ - public function addItem($menuName, $subMenuName, $url, $order = 50, $tooltip = false, $icon = false, $onclick = false) + public function addItem($menuName, $subMenuName, $url, $order = 50, $tooltip = false, $icon = false, $onclick = false, $attribute = false) { // make sure the idSite value used is numeric (hack-y fix for #3426) if (isset($url['idSite']) && !is_numeric($url['idSite'])) { @@ -116,7 +117,8 @@ abstract class MenuAbstract extends Singleton $order, $tooltip, $icon, - $onclick + $onclick, + $attribute ); } @@ -144,7 +146,7 @@ abstract class MenuAbstract extends Singleton * @param int $order * @param bool|string $tooltip Tooltip to display. */ - private function buildMenuItem($menuName, $subMenuName, $url, $order = 50, $tooltip = false, $icon = false, $onclick = false) + private function buildMenuItem($menuName, $subMenuName, $url, $order = 50, $tooltip = false, $icon = false, $onclick = false, $attribute = false) { if (!isset($this->menu[$menuName])) { $this->menu[$menuName] = array( @@ -158,17 +160,22 @@ abstract class MenuAbstract extends Singleton $this->menu[$menuName]['_order'] = $order; $this->menu[$menuName]['_name'] = $menuName; $this->menu[$menuName]['_tooltip'] = $tooltip; + $this->menu[$menuName]['_attribute'] = $attribute; if (!empty($this->menuIcons[$menuName])) { $this->menu[$menuName]['_icon'] = $this->menuIcons[$menuName]; } else { $this->menu[$menuName]['_icon'] = ''; } + if (!empty($onclick)) { + $this->menu[$menuName]['_onclick'] = $onclick; + } } if (!empty($subMenuName)) { $this->menu[$menuName][$subMenuName]['_url'] = $url; $this->menu[$menuName][$subMenuName]['_order'] = $order; $this->menu[$menuName][$subMenuName]['_name'] = $subMenuName; $this->menu[$menuName][$subMenuName]['_tooltip'] = $tooltip; + $this->menu[$menuName][$subMenuName]['_attribute'] = $attribute; $this->menu[$menuName][$subMenuName]['_icon'] = $icon; $this->menu[$menuName][$subMenuName]['_onclick'] = $onclick; $this->menu[$menuName]['_hasSubmenu'] = true; @@ -185,7 +192,7 @@ abstract class MenuAbstract extends Singleton private function buildMenu() { foreach ($this->menuEntries as $menuEntry) { - $this->buildMenuItem($menuEntry[0], $menuEntry[1], $menuEntry[2], $menuEntry[3], $menuEntry[4], $menuEntry[5], $menuEntry[6]); + $this->buildMenuItem($menuEntry[0], $menuEntry[1], $menuEntry[2], $menuEntry[3], $menuEntry[4], $menuEntry[5], $menuEntry[6], $menuEntry[7]); } } diff --git a/core/Plugin.php b/core/Plugin.php index 109592635a..4e1df7eba9 100644 --- a/core/Plugin.php +++ b/core/Plugin.php @@ -648,6 +648,28 @@ class Plugin } return $dependency; } + + /** + * Get all changes for this plugin + * + * @return array Array of changes + * [{"title":"abc","description":"xyz","linkName":"def","link":"https://link","version":"1.2.3"}] + */ + public function getChanges() + { + $file = Manager::getPluginDirectory($this->pluginName).'/changes.json'; + if (file_exists($file)) { + $json = file_get_contents($file); + if ($json) { + $changes = json_decode($json, true); + if ($changes && is_array($changes)) { + return array_reverse($changes); + } + } + } + return []; + } + } } diff --git a/core/Updates/4.7.0-b2.php b/core/Updates/4.7.0-b2.php new file mode 100644 index 0000000000..3317067c8d --- /dev/null +++ b/core/Updates/4.7.0-b2.php @@ -0,0 +1,70 @@ +<?php + +/** + * Matomo - free/libre analytics platform + * + * @link https://matomo.org + * @license http://www.gnu.org/licenses/gpl-3.0.html GPL v3 or later + * + */ + +namespace Piwik\Updates; + +use Piwik\Updater; +use Piwik\Updates as PiwikUpdates; +use Piwik\Updater\Migration; +use Piwik\Updater\Migration\Factory as MigrationFactory; + +/** + * Update for version 4.7.0-b2 + */ +class Updates_4_7_0_b2 extends PiwikUpdates +{ + /** + * @var MigrationFactory + */ + private $migration; + + public function __construct(MigrationFactory $factory) + { + $this->migration = $factory; + } + + /** + * Here you can define one or multiple SQL statements that should be executed during the update. + * + * @param Updater $updater + * + * @return Migration[] + */ + public function getMigrations(Updater $updater) + { + $migrations = []; + + // add column to track the last change a user viewed the changes list + $migrations[] = $this->migration->db->addColumn('user', 'idchange_last_viewed', + 'INTEGER UNSIGNED NULL'); + + $migrations[] = $this->migration->db->createTable('changes', array( + 'idchange' => 'INT(11) NOT NULL AUTO_INCREMENT', + 'created_time' => 'DATETIME NOT NULL', + 'plugin_name' => 'VARCHAR(255) NOT NULL', + 'version' => 'VARCHAR(20) NOT NULL', + 'title' => 'VARCHAR(255) NOT NULL', + 'description' => 'TEXT NOT NULL', + 'link_name' => 'VARCHAR(255) NULL', + 'link' => 'VARCHAR(255) NULL', + ), $primaryKey = 'idchange'); + + + $migrations[] = $this->migration->db->addUniqueKey('changes', ['plugin_name', 'version', 'title'], 'unique_plugin_version_title'); + + return $migrations; + } + + public function doUpdate(Updater $updater) + { + $updater->executeMigrations(__FILE__, $this->getMigrations($updater)); + } + +} diff --git a/core/Version.php b/core/Version.php index 013b7f6080..2fc51f589e 100644 --- a/core/Version.php +++ b/core/Version.php @@ -20,7 +20,7 @@ final class Version * The current Matomo version. * @var string */ - const VERSION = '4.7.0-b1'; + const VERSION = '4.7.0-b2'; const MAJOR_VERSION = 4; |