From d89d2545b747160ac91c1bfc93abaebc8a96f1b6 Mon Sep 17 00:00:00 2001 From: Tim Otten Date: Mon, 6 Jun 2016 18:23:27 -0600 Subject: [PATCH] CRM-18792 - CRM_Core_Theme - Add helper for loading CSS files from themes CRM-18792 - CRM_Core_Resources - Load civicrm.css through theme system CRM-18792 - Rename `CRM_Core_Theme` to `\Civi\Core\Theme` CRM-18792 - Civi\Core\Theme - Remove statics WIP CRM_Core_Resources::addCoreStyles - Revert change CRM-18792 - addStyleFile - Always pass through to theme. Support fallback. Rename `Civi\Core\Theme` to `Civi\Core\Themes` The class manages a list of themes -- not just a single theme. CRM-18792 - Add org.civicrm.demotheme CRM-18792 - Add uncommitted test files (`Civi\Core\Themes`) CRM-18792 - Fix regression in CRM_Core_ResourceTest CRM-18792 - Theme naming - Use prefix '_' for hidden themes This cleans up a few things: * Previously, there was a special case for using FALLBACK in `search_order`. * If you're creating a multitheme extension, you may want to define a base theme (which is extended by the others). Previously, you were required to show this base theme as a user-selectable option. Now, it can be hidden. * There was a bug where `resolveUrl()` would sometimes call the wrong callback. (It used resolver for `$active` instead of `$themeKey`.) CRM-18792 - Themes - File overrides and excludes should use same naming Previously, when using `addStyleFile($cssExt,$css$file)`, the file overrides and exlcudes would combine them differently e.g. * For `addStyleFile('civicrm','css/bootstrap.css')` * Override `css/bootstrap.css` * Exclude `civicrm:css/bootstrap.css` * For `('org.foo.bar','css/bang.css')` * Override `org.foo.bar-css/bang.css` * Exclude `org.foo.bar:css/bang.css` Now, they use the same notation: * For `addStyleFile('civicrm','css/bootstrap.css')` * Override `css/bootstrap.css` * Exclude `css/bootstrap.css` * For `('org.foo.bar','css/bang.css')` * Override `org.foo.bar-css/bang.css` * Exclude `org.foo.bar-css/bang.css` "Display Preferences" - Add the `theme_backend` and `theme_frontend` settings hook_civicrm_activeTheme - Allow extensions and CMS modules to choose active theme CRM_Utils_Hook::themes() - Tweak docblock Civi\Core\Themes - Move cache from `short` to `long` Remove tools/extensions/org.civicrm.demotheme Fix merge ahem errors --- CRM/Admin/Form/Preferences/Display.php | 2 + CRM/Core/Resources.php | 34 ++- CRM/Utils/Hook.php | 57 ++++ Civi/Core/Container.php | 5 + Civi/Core/Themes.php | 283 ++++++++++++++++++ Civi/Core/Themes/Resolvers.php | 97 ++++++ settings/Core.setting.php | 42 +++ .../CRM/Admin/Form/Preferences/Display.hlp | 45 +++ .../CRM/Admin/Form/Preferences/Display.tpl | 16 + .../test.extension.uitest/files/foo.css | 1 + .../test.extension.uitest/files/ignoreme.css | 1 + .../extensions/test.extension.uitest/info.xml | 4 + .../test.extension.uitest/uitest.php | 1 + .../Civi/Core/Theme/judy/css/bootstrap.css | 1 + .../Civi/Core/Theme/judy/css/civicrm.css | 1 + .../Civi/Core/Theme/liza/css/bootstrap.css | 1 + .../Civi/Core/Theme/liza/css/civicrm.css | 1 + .../Civi/Core/Theme/liza/css/civicrm.min.css | 1 + .../liza/test.extension.uitest-files/foo.css | 1 + tests/phpunit/Civi/Core/ThemesTest.php | 218 ++++++++++++++ 20 files changed, 799 insertions(+), 13 deletions(-) create mode 100644 Civi/Core/Themes.php create mode 100644 Civi/Core/Themes/Resolvers.php create mode 100644 tests/extensions/test.extension.uitest/files/foo.css create mode 100644 tests/extensions/test.extension.uitest/files/ignoreme.css create mode 100644 tests/extensions/test.extension.uitest/info.xml create mode 100644 tests/extensions/test.extension.uitest/uitest.php create mode 100644 tests/phpunit/Civi/Core/Theme/judy/css/bootstrap.css create mode 100644 tests/phpunit/Civi/Core/Theme/judy/css/civicrm.css create mode 100644 tests/phpunit/Civi/Core/Theme/liza/css/bootstrap.css create mode 100644 tests/phpunit/Civi/Core/Theme/liza/css/civicrm.css create mode 100644 tests/phpunit/Civi/Core/Theme/liza/css/civicrm.min.css create mode 100644 tests/phpunit/Civi/Core/Theme/liza/test.extension.uitest-files/foo.css create mode 100644 tests/phpunit/Civi/Core/ThemesTest.php diff --git a/CRM/Admin/Form/Preferences/Display.php b/CRM/Admin/Form/Preferences/Display.php index 9ba36825cd..3e7b87a825 100644 --- a/CRM/Admin/Form/Preferences/Display.php +++ b/CRM/Admin/Form/Preferences/Display.php @@ -53,6 +53,8 @@ class CRM_Admin_Form_Preferences_Display extends CRM_Admin_Form_Preferences { 'sort_name_format' => CRM_Core_BAO_Setting::SYSTEM_PREFERENCES_NAME, 'menubar_position' => CRM_Core_BAO_Setting::SYSTEM_PREFERENCES_NAME, 'menubar_color' => CRM_Core_BAO_Setting::SYSTEM_PREFERENCES_NAME, + 'theme_backend' => CRM_Core_BAO_Setting::SYSTEM_PREFERENCES_NAME, + 'theme_frontend' => CRM_Core_BAO_Setting::SYSTEM_PREFERENCES_NAME, ]; /** diff --git a/CRM/Core/Resources.php b/CRM/Core/Resources.php index 3066d3331c..ec84377baa 100644 --- a/CRM/Core/Resources.php +++ b/CRM/Core/Resources.php @@ -211,8 +211,8 @@ class CRM_Core_Resources { $domain = ($translate === TRUE) ? $ext : $translate; $this->addString($this->strings->get($domain, $this->getPath($ext, $file), 'text/javascript'), $domain); } - $this->resolveFileName($file, $ext); - return $this->addScriptUrl($this->getUrl($ext, $file, TRUE), $weight, $region); + $url = $this->getUrl($ext, $this->filterMinify($ext, $file), TRUE); + return $this->addScriptUrl($url, $weight, $region); } /** @@ -427,8 +427,12 @@ class CRM_Core_Resources { * @return CRM_Core_Resources */ public function addStyleFile($ext, $file, $weight = self::DEFAULT_WEIGHT, $region = self::DEFAULT_REGION) { - $this->resolveFileName($file, $ext); - return $this->addStyleUrl($this->getUrl($ext, $file, TRUE), $weight, $region); + /** @var Civi\Core\Themes $theme */ + $theme = Civi::service('themes'); + foreach ($theme->resolveUrls($theme->getActiveThemeKey(), $ext, $file) as $url) { + $this->addStyleUrl($url, $weight, $region); + } + return $this; } /** @@ -937,18 +941,22 @@ class CRM_Core_Resources { } /** - * In debug mode, look for a non-minified version of this file + * Determine the minified file name. * - * @param string $fileName - * @param string $extName - */ - private function resolveFileName(&$fileName, $extName) { - if (CRM_Core_Config::singleton()->debug && strpos($fileName, '.min.') !== FALSE) { - $nonMiniFile = str_replace('.min.', '.', $fileName); - if ($this->getPath($extName, $nonMiniFile)) { - $fileName = $nonMiniFile; + * @param string $ext + * @param string $file + * @return string + * An updated $fileName. If a minified version exists and is supported by + * system policy, the minified version will be returned. Otherwise, the original. + */ + public function filterMinify($ext, $file) { + if (CRM_Core_Config::singleton()->debug && strpos($file, '.min.') !== FALSE) { + $nonMiniFile = str_replace('.min.', '.', $file); + if ($this->getPath($ext, $nonMiniFile)) { + $file = $nonMiniFile; } } + return $file; } /** diff --git a/CRM/Utils/Hook.php b/CRM/Utils/Hook.php index e2abd5a79f..2595679938 100644 --- a/CRM/Utils/Hook.php +++ b/CRM/Utils/Hook.php @@ -602,6 +602,63 @@ abstract class CRM_Utils_Hook { ); } + /** + * A theme is a set of CSS files which are loaded on CiviCRM pages. To register a new + * theme, add it to the $themes array. Use these properties: + * + * - ext: string (required) + * The full name of the extension which defines the theme. + * Ex: "org.civicrm.themes.greenwich". + * - title: string (required) + * Visible title. + * - help: string (optional) + * Description of the theme's appearance. + * - url_callback: mixed (optional) + * A function ($themes, $themeKey, $cssExt, $cssFile) which returns the URL(s) for a CSS resource. + * Returns either an array of URLs or PASSTHRU. + * Ex: \Civi\Core\Themes\Resolvers::simple (default) + * Ex: \Civi\Core\Themes\Resolvers::none + * - prefix: string (optional) + * A prefix within the extension folder to prepend to the file name. + * - search_order: array (optional) + * A list of themes to search. + * Generally, the last theme should be "*fallback*" (Civi\Core\Themes::FALLBACK). + * - excludes: array (optional) + * A list of files (eg "civicrm:css/bootstrap.css" or "$ext:$file") which should never + * be returned (they are excluded from display). + * + * @param array $themes + * List of themes, keyed by name. + * @return null + * the return value is ignored + */ + public static function themes(&$themes) { + return self::singleton()->invoke(1, $themes, + self::$_nullObject, self::$_nullObject, self::$_nullObject, self::$_nullObject, self::$_nullObject, + 'civicrm_themes' + ); + } + + /** + * The activeTheme hook determines which theme is active. + * + * @param string $theme + * The identifier for the theme. Alterable. + * Ex: 'greenwich'. + * @param array $context + * Information about the current page-request. Includes some mix of: + * - page: the relative path of the current Civi page (Ex: 'civicrm/dashboard'). + * - themes: an instance of the Civi\Core\Themes service. + * @return null + * the return value is ignored + */ + public static function activeTheme(&$theme, $context) { + return self::singleton()->invoke(array('theme', 'context'), $theme, $context, + self::$_nullObject, self::$_nullObject, self::$_nullObject, self::$_nullObject, + 'civicrm_activeTheme' + ); + } + /** * This hook is called for declaring managed entities via API. * diff --git a/Civi/Core/Container.php b/Civi/Core/Container.php index 9ce531d1bb..e2d8411a4f 100644 --- a/Civi/Core/Container.php +++ b/Civi/Core/Container.php @@ -179,6 +179,11 @@ class Container { [] )); + $container->setDefinition('themes', new Definition( + 'Civi\Core\Themes', + [] + )); + $container->setDefinition('pear_mail', new Definition('Mail')) ->setFactory('CRM_Utils_Mail::createMailer'); diff --git a/Civi/Core/Themes.php b/Civi/Core/Themes.php new file mode 100644 index 0000000000..0413fde1a9 --- /dev/null +++ b/Civi/Core/Themes.php @@ -0,0 +1,283 @@ + array $themeSpec). + */ + private $themes = NULL; + + /** + * @var \CRM_Utils_Cache_Interface + */ + private $cache = NULL; + + /** + * Theme constructor. + * @param \CRM_Utils_Cache_Interface $cache + */ + public function __construct($cache = NULL) { + $this->cache = $cache ? $cache : Civi::cache('long'); + } + + /** + * Determine the name of active theme. + * + * @return string + * Ex: "greenwich". + */ + public function getActiveThemeKey() { + if ($this->activeThemeKey === NULL) { + // Ambivalent: is it better to use $config->userFrameworkFrontend or $template->get('urlIsPublic')? + $config = \CRM_Core_Config::singleton(); + $settingKey = $config->userFrameworkFrontend ? 'theme_frontend' : 'theme_backend'; + + $themeKey = Civi::settings()->get($settingKey); + if ($themeKey === 'default') { + $themeKey = self::DEFAULT_THEME; + } + + \CRM_Utils_Hook::activeTheme($themeKey, [ + 'themes' => $this, + 'page' => \CRM_Utils_Array::value(\CRM_Core_Config::singleton()->userFrameworkURLVar, $_GET), + ]); + + $themes = $this->getAll(); + $this->activeThemeKey = isset($themes[$themeKey]) ? $themeKey : self::DEFAULT_THEME; + } + return $this->activeThemeKey; + } + + /** + * Get the definition of the theme. + * + * @param string $themeKey + * Ex: 'greenwich', 'shoreditch'. + * @return array|NULL + * @see CRM_Utils_Hook::themes + */ + public function get($themeKey) { + $all = $this->getAll(); + return isset($all[$themeKey]) ? $all[$themeKey] : NULL; + } + + /** + * Get a list of all known themes, including hidden base themes. + * + * @return array + * List of themes, keyed by name. Same format as CRM_Utils_Hook::themes(), + * but any default values are filled in. + * @see CRM_Utils_Hook::themes + */ + public function getAll() { + if ($this->themes === NULL) { + // Cache includes URLs/paths, which change with runtime. + $cacheKey = 'theme_list_' . \CRM_Core_Config_Runtime::getId(); + $this->themes = $this->cache->get($cacheKey); + if ($this->themes === NULL) { + $this->themes = $this->buildAll(); + $this->cache->set($cacheKey, $this->themes); + } + } + return $this->themes; + } + + /** + * Get a list of available themes, excluding hidden base themes. + * + * This is the same as getAll(), but abstract themes like "_fallback_" + * or "_newyork_base_" are omitted. + * + * @return array + * List of themes. + * Ex: ['greenwich' => 'Greenwich', 'shoreditch' => 'Shoreditch']. + * @see CRM_Utils_Hook::themes + */ + public function getAvailable() { + $result = array(); + foreach ($this->getAll() as $key => $theme) { + if ($key{0} !== '_') { + $result[$key] = $theme['title']; + } + } + return $result; + } + + /** + * Get the URL(s) for a themed CSS file. + * + * This implements a prioritized search, in order: + * - Check for the specified theme. + * - If that doesn't exist, check for the default theme. + * - If that doesn't exist, use the 'none' theme. + * + * @param string $active + * Active theme key. + * Ex: 'greenwich'. + * @param string $cssExt + * Ex: 'civicrm'. + * @param string $cssFile + * Ex: 'css/bootstrap.css' or 'css/civicrm.css'. + * @return array + * List of URLs to display. + * Ex: array(string $url) + */ + public function resolveUrls($active, $cssExt, $cssFile) { + $all = $this->getAll(); + if (!isset($all[$active])) { + return array(); + } + + $cssId = $this->cssId($cssExt, $cssFile); + + foreach ($all[$active]['search_order'] as $themeKey) { + if (isset($all[$themeKey]['excludes']) && in_array($cssId, $all[$themeKey]['excludes'])) { + $result = array(); + } + else { + $result = Civi\Core\Resolver::singleton() + ->call($all[$themeKey]['url_callback'], array($this, $themeKey, $cssExt, $cssFile)); + } + + if ($result !== self::PASSTHRU) { + return $result; + } + } + + throw new \RuntimeException("Failed to resolve URL. Theme metadata may be incomplete."); + } + + /** + * Construct the list of available themes. + * + * @return array + * List of themes, keyed by name. + * @see CRM_Utils_Hook::themes + */ + protected function buildAll() { + $themes = array( + 'default' => array( + 'ext' => 'civicrm', + 'title' => ts('Automatic'), + 'help' => ts('Determine a system default automatically'), + // This is an alias. url_callback, search_order don't matter. + ), + 'greenwich' => array( + 'ext' => 'civicrm', + 'title' => 'Greenwich', + 'help' => ts('CiviCRM 4.x look-and-feel'), + ), + 'none' => array( + 'ext' => 'civicrm', + 'title' => ts('None (Unstyled)'), + 'help' => ts('Disable CiviCRM\'s built-in CSS files.'), + 'search_order' => array('none', self::FALLBACK_THEME), + 'excludes' => array( + "css/civicrm.css", + "css/bootstrap.css", + ), + ), + self::FALLBACK_THEME => array( + 'ext' => 'civicrm', + 'title' => 'Fallback (Abstract Base Theme)', + 'url_callback' => '\Civi\Core\Themes\Resolvers::fallback', + 'search_order' => array(self::FALLBACK_THEME), + ), + ); + + \CRM_Utils_Hook::themes($themes); + + foreach (array_keys($themes) as $themeKey) { + $themes[$themeKey] = $this->build($themeKey, $themes[$themeKey]); + } + + return $themes; + } + + /** + * Apply defaults for a given them. + * + * @param string $themeKey + * The name of the theme. Ex: 'greenwich'. + * @param array $theme + * The original theme definition of the theme (per CRM_Utils_Hook::themes). + * @return array + * The full theme definition of the theme (per CRM_Utils_Hook::themes). + * @see CRM_Utils_Hook::themes + */ + protected function build($themeKey, $theme) { + $defaults = array( + 'name' => $themeKey, + 'url_callback' => '\Civi\Core\Themes\Resolvers::simple', + 'search_order' => array($themeKey, self::FALLBACK_THEME), + ); + $theme = array_merge($defaults, $theme); + + return $theme; + } + + /** + * @param string $cssExt + * @param string $cssFile + * @return string + */ + public function cssId($cssExt, $cssFile) { + return ($cssExt === 'civicrm') ? $cssFile : "$cssExt-$cssFile"; + } + +} diff --git a/Civi/Core/Themes/Resolvers.php b/Civi/Core/Themes/Resolvers.php new file mode 100644 index 0000000000..4671b92002 --- /dev/null +++ b/Civi/Core/Themes/Resolvers.php @@ -0,0 +1,97 @@ +get($themeKey); + $file = ''; + if (isset($theme['prefix'])) { + $file .= $theme['prefix']; + } + $file .= $themes->cssId($cssExt, $cssFile); + $file = $res->filterMinify($theme['ext'], $file); + + if ($res->getPath($theme['ext'], $file)) { + return array($res->getUrl($theme['ext'], $file, TRUE)); + } + else { + return Civi\Core\Themes::PASSTHRU; + } + } + + /** + * The base handler falls back to loading files from the main application (rather than + * using the theme). + * + * @param \Civi\Core\Themes $themes + * The theming subsystem. + * @param string $themeKey + * The active/desired theme key. + * @param string $cssExt + * The extension for which we want a themed CSS file (e.g. "civicrm"). + * @param string $cssFile + * File name (e.g. "css/bootstrap.css"). + * @return array|string + * List of CSS URLs, or PASSTHRU. + */ + public static function fallback($themes, $themeKey, $cssExt, $cssFile) { + $res = Civi::resources(); + return array($res->getUrl($cssExt, $cssFile, TRUE)); + } + +} diff --git a/settings/Core.setting.php b/settings/Core.setting.php index 77dc4cce90..f79f52ba4e 100644 --- a/settings/Core.setting.php +++ b/settings/Core.setting.php @@ -1062,4 +1062,46 @@ return [ 'description' => ts('Acceptable Mime Types that can be used as part of file urls'), 'help_text' => NULL, ], + 'theme_frontend' => [ + 'group_name' => 'CiviCRM Preferences', + 'group' => 'core', + 'name' => 'theme_frontend', + 'type' => 'String', + 'quick_form_type' => 'Select', + 'html_type' => 'Select', + 'html_attributes' => array( + 'class' => 'crm-select2', + ), + 'pseudoconstant' => array( + 'callback' => 'call://themes/getAvailable', + ), + 'default' => 'default', + 'add' => '4.7', + 'title' => ts('Frontend Theme'), + 'is_domain' => 1, + 'is_contact' => 0, + 'description' => ts('Theme to use on frontend pages'), + 'help_text' => NULL, + ], + 'theme_backend' => [ + 'group_name' => 'CiviCRM Preferences', + 'group' => 'core', + 'name' => 'theme_backend', + 'type' => 'String', + 'quick_form_type' => 'Select', + 'html_type' => 'Select', + 'html_attributes' => array( + 'class' => 'crm-select2', + ), + 'pseudoconstant' => array( + 'callback' => 'call://themes/getAvailable', + ), + 'default' => 'default', + 'add' => '4.7', + 'title' => ts('Backend Theme'), + 'is_domain' => 1, + 'is_contact' => 0, + 'description' => ts('Theme to use on backend pages'), + 'help_text' => NULL, + ], ]; diff --git a/templates/CRM/Admin/Form/Preferences/Display.hlp b/templates/CRM/Admin/Form/Preferences/Display.hlp index ae1dde2df3..42e406c5f1 100644 --- a/templates/CRM/Admin/Form/Preferences/Display.hlp +++ b/templates/CRM/Admin/Form/Preferences/Display.hlp @@ -44,3 +44,48 @@ {capture assign=invoiceURL}{crmURL p='civicrm/admin/setting/preferences/contribute' q="reset=1"}{/capture} {ts 1=$invoiceURL}In order to enable logged in users to download invoices and credit notes from the dashboard, please first enable CiviCRM invoicing functionality Administer > CiviContribute > CiviContribute Component Settings{/ts} {/htxt} + +{* ***** Theme options ***** *} + +{capture assign=themeDefn} +

+ {ts}The theme system allows you to change CiviCRM's appearance by replacing important CSS files.{/ts} +

+{/capture} +{capture assign=themeAdv} + {* TODO: Update when there is a page to link to: +

+ {ts 1='http://fixme.example.com'}For more advanced theming options, consult the theme documentation.{/ts} +

+ *} +{/capture} + +{htxt id="theme-title"} + {ts}Theme{/ts} +{/htxt} +{htxt id="theme"} + {$themeDefn} + {$themeAdv} +{/htxt} + +{htxt id="theme_backend-title"} + {ts}Backend Theme{/ts} +{/htxt} +{htxt id="theme_backend"} + {$themeDefn} +

+ {ts}The backend theme determines the appearance on administrative screens, such as the "Manage Event" screen.{/ts} +

+ {$themeAdv} +{/htxt} + +{htxt id="theme_frontend-title"} + {ts}Frontend Theme{/ts} +{/htxt} +{htxt id="theme_frontend"} + {$themeDefn} +

+ {ts}On WordPress, Joomla, or a similar CMS, the frontend theme determines the appearance on user-facing screens, such as the "Event Registration" screen.{/ts} +

+ {$themeAdv} +{/htxt} diff --git a/templates/CRM/Admin/Form/Preferences/Display.tpl b/templates/CRM/Admin/Form/Preferences/Display.tpl index 0aa794a4a4..336ae914d1 100644 --- a/templates/CRM/Admin/Form/Preferences/Display.tpl +++ b/templates/CRM/Admin/Form/Preferences/Display.tpl @@ -222,6 +222,22 @@ {$form.menubar_color.html} + + {if $config->userSystem->is_drupal EQ '1'} + + {ts}Theme{/ts} {help id="theme"} + {$form.theme_backend.html} + + {else} + + {$form.theme_backend.label} {help id="theme_backend"} + {$form.theme_backend.html} + + + {$form.theme_frontend.label} {help id="theme_frontend"} + {$form.theme_frontend.html} + + {/if}
{include file="CRM/common/formButtons.tpl" location="bottom"}
diff --git a/tests/extensions/test.extension.uitest/files/foo.css b/tests/extensions/test.extension.uitest/files/foo.css new file mode 100644 index 0000000000..2b7c87fd6d --- /dev/null +++ b/tests/extensions/test.extension.uitest/files/foo.css @@ -0,0 +1 @@ +/* Placeholder */ \ No newline at end of file diff --git a/tests/extensions/test.extension.uitest/files/ignoreme.css b/tests/extensions/test.extension.uitest/files/ignoreme.css new file mode 100644 index 0000000000..2b7c87fd6d --- /dev/null +++ b/tests/extensions/test.extension.uitest/files/ignoreme.css @@ -0,0 +1 @@ +/* Placeholder */ \ No newline at end of file diff --git a/tests/extensions/test.extension.uitest/info.xml b/tests/extensions/test.extension.uitest/info.xml new file mode 100644 index 0000000000..58921f5ff2 --- /dev/null +++ b/tests/extensions/test.extension.uitest/info.xml @@ -0,0 +1,4 @@ + + uitest + test_extension_uitest + diff --git a/tests/extensions/test.extension.uitest/uitest.php b/tests/extensions/test.extension.uitest/uitest.php new file mode 100644 index 0000000000..b3d9bbc7f3 --- /dev/null +++ b/tests/extensions/test.extension.uitest/uitest.php @@ -0,0 +1 @@ +useTransaction(); + parent::setUp(); + } + + public function getThemeExamples() { + $cases = []; + + // --- Library of example themes which we can include in tests. --- + + $hookJudy = [ + 'judy' => [ + 'title' => 'Judy Garland', + 'ext' => 'civicrm', + 'prefix' => 'tests/phpunit/Civi/Core/Theme/judy/', + 'excludes' => ['test.extension.uitest-files/ignoreme.css'], + ], + ]; + $hookLiza = [ + 'liza' => [ + 'title' => 'Liza Minnelli', + 'prefix' => 'tests/phpunit/Civi/Core/Theme/liza/', + 'ext' => 'civicrm', + ], + ]; + $hookBlueMarine = [ + 'bluemarine' => [ + 'title' => 'Blue Marine', + 'url_callback' => [__CLASS__, 'fakeCallback'], + 'ext' => 'civicrm', + ], + ]; + $hookAquaMarine = [ + 'aquamarine' => [ + 'title' => 'Aqua Marine', + 'url_callback' => [__CLASS__, 'fakeCallback'], + 'ext' => 'civicrm', + 'search_order' => ['aquamarine', 'bluemarine', '_fallback_'], + ], + ]; + + $civicrmBaseUrl = ""; + + // --- Library of tests --- + + // Use the default theme, Greenwich. + $cases[] = [ + [], + 'default', + 'Greenwich', + [ + 'civicrm-css/civicrm.css' => ["$civicrmBaseUrl/css/civicrm.css"], + 'civicrm-css/joomla.css' => ["$civicrmBaseUrl/css/joomla.css"], + 'test.extension.uitest-files/foo.css' => ["/tests/extensions/test.extension.uitest/files/foo.css"], + ], + ]; + + // judy is defined. Let's use judy. + $cases[] = [ + // Example hook data + $hookJudy, + 'judy', + // Example theme to inspect + 'Judy Garland', + [ + 'civicrm-css/civicrm.css' => ["$civicrmBaseUrl/tests/phpunit/Civi/Core/Theme/judy/css/civicrm.css"], + 'civicrm-css/joomla.css' => ["$civicrmBaseUrl/css/joomla.css"], + 'test.extension.uitest-files/foo.css' => ["/tests/extensions/test.extension.uitest/files/foo.css"], + // excluded + 'test.extension.uitest-files/ignoreme.css' => [], + ], + ]; + + // Misconfiguration: liza was previously used but then disappeared. Fallback to default, Greenwich. + $cases[] = [ + $hookJudy, + 'liza', + 'Greenwich', + [ + 'civicrm-css/civicrm.css' => ["$civicrmBaseUrl/css/civicrm.css"], + 'civicrm-css/joomla.css' => ["$civicrmBaseUrl/css/joomla.css"], + 'test.extension.uitest-files/foo.css' => ["/tests/extensions/test.extension.uitest/files/foo.css"], + ], + ]; + + // We have some themes available, but the admin opted out. + $cases[] = [ + $hookJudy, + 'none', + 'None (Unstyled)', + [ + 'civicrm-css/civicrm.css' => [], + 'civicrm-css/joomla.css' => ["$civicrmBaseUrl/css/joomla.css"], + 'test.extension.uitest-files/foo.css' => ["/tests/extensions/test.extension.uitest/files/foo.css"], + ], + ]; + + // Theme which overrides an extension's CSS file. + $cases[] = [ + $hookJudy + $hookLiza, + 'liza', + 'Liza Minnelli', + [ + // Warning: If your local system has overrides for the `debug_enabled`, these results may vary. + 'civicrm-css/civicrm.css' => ["$civicrmBaseUrl/tests/phpunit/Civi/Core/Theme/liza/css/civicrm.css"], + 'civicrm-css/civicrm.min.css' => ["$civicrmBaseUrl/tests/phpunit/Civi/Core/Theme/liza/css/civicrm.min.css"], + 'civicrm-css/joomla.css' => ["$civicrmBaseUrl/css/joomla.css"], + 'test.extension.uitest-files/foo.css' => ["/tests/phpunit/Civi/Core/Theme/liza/test.extension.uitest-files/foo.css"], + ], + ]; + + // Theme has a custom URL-lookup function. + $cases[] = [ + $hookBlueMarine + $hookAquaMarine, + 'bluemarine', + 'Blue Marine', + [ + 'civicrm-css/civicrm.css' => ['http://example.com/blue/civicrm.css'], + 'civicrm-css/joomla.css' => ["$civicrmBaseUrl/css/joomla.css"], + 'test.extension.uitest-files/foo.css' => ['http://example.com/blue/foobar/foo.css'], + ], + ]; + + // Theme is derived from another. + $cases[] = [ + $hookBlueMarine + $hookAquaMarine, + 'aquamarine', + 'Aqua Marine', + [ + 'civicrm-css/civicrm.css' => ['http://example.com/aqua/civicrm.css'], + 'civicrm-css/joomla.css' => ["$civicrmBaseUrl/css/joomla.css"], + 'test.extension.uitest-files/foo.css' => ['http://example.com/blue/foobar/foo.css'], + ], + ]; + + return $cases; + } + + /** + * Test theme. + * + * @param array $inputtedHook + * @param string $themeKey + * @param string $expectedTitle + * @param array $expectedUrls + * List of files to lookup plus the expected URLs. + * Array("{$extName}-{$fileName}" => "{$expectUrl}"). + * + * @dataProvider getThemeExamples + */ + public function testTheme($inputtedHook, $themeKey, $expectedTitle, $expectedUrls) { + $this->hookClass->setHook('civicrm_themes', function (&$themes) use ($inputtedHook) { + foreach ($inputtedHook as $key => $value) { + $themes[$key] = $value; + } + }); + + \Civi::settings()->set('theme_frontend', $themeKey); + \Civi::settings()->set('theme_backend', $themeKey); + + /** @var \Civi\Core\Themes $themeSvc */ + $themeSvc = \Civi::service('themes'); + $theme = $themeSvc->get($themeSvc->getActiveThemeKey()); + if ($expectedTitle) { + $this->assertEquals($expectedTitle, $theme['title']); + } + + foreach ($expectedUrls as $inputFile => $expectedUrl) { + list ($ext, $file) = explode('-', $inputFile, 2); + $actualUrl = $themeSvc->resolveUrls($themeSvc->getActiveThemeKey(), $ext, $file); + foreach (array_keys($actualUrl) as $k) { + // Ignore cache revision key (`?r=abcd1234`). + list ($actualUrl[$k]) = explode('?', $actualUrl[$k], 2); + } + $this->assertEquals($expectedUrl, $actualUrl, "Check URL for $inputFile"); + } + } + + public static function fakeCallback($themes, $themeKey, $cssExt, $cssFile) { + $map['bluemarine']['civicrm']['css/bootstrap.css'] = ['http://example.com/blue/bootstrap.css']; + $map['bluemarine']['civicrm']['css/civicrm.css'] = ['http://example.com/blue/civicrm.css']; + $map['bluemarine']['test.extension.uitest']['files/foo.css'] = ['http://example.com/blue/foobar/foo.css']; + $map['aquamarine']['civicrm']['css/civicrm.css'] = ['http://example.com/aqua/civicrm.css']; + return isset($map[$themeKey][$cssExt][$cssFile]) ? $map[$themeKey][$cssExt][$cssFile] : Themes::PASSTHRU; + } + + public function testGetAll() { + $all = \Civi::service('themes')->getAll(); + $this->assertTrue(isset($all['greenwich'])); + $this->assertTrue(isset($all['_fallback_'])); + } + + public function testGetAvailable() { + $all = \Civi::service('themes')->getAvailable(); + $this->assertTrue(isset($all['greenwich'])); + $this->assertFalse(isset($all['_fallback_'])); + } + + public function testApiOptions() { + $result = $this->callAPISuccess('Setting', 'getoptions', [ + 'field' => 'theme_backend', + ]); + $this->assertTrue(isset($result['values']['greenwich'])); + $this->assertFalse(isset($result['values']['_fallback_'])); + } + +} -- 2.25.1