CRM-18792 - CRM_Core_Theme - Add helper for loading CSS files from themes
authorTim Otten <totten@civicrm.org>
Tue, 7 Jun 2016 00:23:27 +0000 (18:23 -0600)
committereileen <emcnaughton@wikimedia.org>
Sat, 15 Jun 2019 00:11:59 +0000 (20:11 -0400)
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

20 files changed:
CRM/Admin/Form/Preferences/Display.php
CRM/Core/Resources.php
CRM/Utils/Hook.php
Civi/Core/Container.php
Civi/Core/Themes.php [new file with mode: 0644]
Civi/Core/Themes/Resolvers.php [new file with mode: 0644]
settings/Core.setting.php
templates/CRM/Admin/Form/Preferences/Display.hlp
templates/CRM/Admin/Form/Preferences/Display.tpl
tests/extensions/test.extension.uitest/files/foo.css [new file with mode: 0644]
tests/extensions/test.extension.uitest/files/ignoreme.css [new file with mode: 0644]
tests/extensions/test.extension.uitest/info.xml [new file with mode: 0644]
tests/extensions/test.extension.uitest/uitest.php [new file with mode: 0644]
tests/phpunit/Civi/Core/Theme/judy/css/bootstrap.css [new file with mode: 0644]
tests/phpunit/Civi/Core/Theme/judy/css/civicrm.css [new file with mode: 0644]
tests/phpunit/Civi/Core/Theme/liza/css/bootstrap.css [new file with mode: 0644]
tests/phpunit/Civi/Core/Theme/liza/css/civicrm.css [new file with mode: 0644]
tests/phpunit/Civi/Core/Theme/liza/css/civicrm.min.css [new file with mode: 0644]
tests/phpunit/Civi/Core/Theme/liza/test.extension.uitest-files/foo.css [new file with mode: 0644]
tests/phpunit/Civi/Core/ThemesTest.php [new file with mode: 0644]

index 9ba36825cdf95a5b513ca380b16a64049c03cd68..3e7b87a82509a14494b82a62fb1e31b19492cadc 100644 (file)
@@ -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,
   ];
 
   /**
index 3066d3331ca3ffd568b1ef59e20fcf60ba9823c7..ec84377baa6a79bd51bfaa20e98d7642fd70406e 100644 (file)
@@ -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;
   }
 
   /**
index e2abd5a79ffa43cbe1f4efb7aef21e78f30d9741..2595679938fcdc3a6a06ca56d7168363c22feb2d 100644 (file)
@@ -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.
    *
index 9ce531d1bb776fc3ad3994afe6e9c98a2435afdf..e2d8411a4ff4da9e915469c80695fa24917c6210 100644 (file)
@@ -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 (file)
index 0000000..0413fde
--- /dev/null
@@ -0,0 +1,283 @@
+<?php
+/*
+ +--------------------------------------------------------------------+
+ | CiviCRM version 4.7                                                |
+ +--------------------------------------------------------------------+
+ | Copyright CiviCRM LLC (c) 2004-2016                                |
+ +--------------------------------------------------------------------+
+ | This file is a part of CiviCRM.                                    |
+ |                                                                    |
+ | CiviCRM is free software; you can copy, modify, and distribute it  |
+ | under the terms of the GNU Affero General Public License           |
+ | Version 3, 19 November 2007 and the CiviCRM Licensing Exception.   |
+ |                                                                    |
+ | CiviCRM is distributed in the hope that it will be useful, but     |
+ | WITHOUT ANY WARRANTY; without even the implied warranty of         |
+ | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.               |
+ | See the GNU Affero General Public License for more details.        |
+ |                                                                    |
+ | You should have received a copy of the GNU Affero General Public   |
+ | License and the CiviCRM Licensing Exception along                  |
+ | with this program; if not, contact CiviCRM LLC                     |
+ | at info[AT]civicrm[DOT]org. If you have questions about the        |
+ | GNU Affero General Public License or the licensing of CiviCRM,     |
+ | see the CiviCRM license FAQ at http://civicrm.org/licensing        |
+ +--------------------------------------------------------------------+
+ */
+
+namespace Civi\Core;
+
+use Civi;
+
+/**
+ *
+ * @package CiviCRM_Hook
+ * @copyright CiviCRM LLC (c) 2004-2016
+ */
+class Themes {
+
+  /**
+   * The "default" theme adapts based on the latest recommendation from civicrm.org
+   * by switching to DEFAULT_THEME at runtime.
+   */
+  const DEFAULT_THEME = 'greenwich';
+
+  /**
+   * Fallback is a pseudotheme which can be included in "search_order".
+   * It locates files in the core/extension (non-theme) codebase.
+   */
+  const FALLBACK_THEME = '_fallback_';
+
+  const PASSTHRU = 'PASSTHRU';
+
+  /**
+   * @var string
+   *   Ex: 'judy', 'liza'.
+   */
+  private $activeThemeKey = NULL;
+
+  /**
+   * @var array
+   *   Array(string $themeKey => 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 (file)
index 0000000..4671b92
--- /dev/null
@@ -0,0 +1,97 @@
+<?php
+
+/*
+ +--------------------------------------------------------------------+
+ | CiviCRM version 4.7                                                |
+ +--------------------------------------------------------------------+
+ | Copyright CiviCRM LLC (c) 2004-2016                                |
+ +--------------------------------------------------------------------+
+ | This file is a part of CiviCRM.                                    |
+ |                                                                    |
+ | CiviCRM is free software; you can copy, modify, and distribute it  |
+ | under the terms of the GNU Affero General Public License           |
+ | Version 3, 19 November 2007 and the CiviCRM Licensing Exception.   |
+ |                                                                    |
+ | CiviCRM is distributed in the hope that it will be useful, but     |
+ | WITHOUT ANY WARRANTY; without even the implied warranty of         |
+ | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.               |
+ | See the GNU Affero General Public License for more details.        |
+ |                                                                    |
+ | You should have received a copy of the GNU Affero General Public   |
+ | License and the CiviCRM Licensing Exception along                  |
+ | with this program; if not, contact CiviCRM LLC                     |
+ | at info[AT]civicrm[DOT]org. If you have questions about the        |
+ | GNU Affero General Public License or the licensing of CiviCRM,     |
+ | see the CiviCRM license FAQ at http://civicrm.org/licensing        |
+ +--------------------------------------------------------------------+
+ */
+
+namespace Civi\Core\Themes;
+
+use Civi;
+
+/**
+ *
+ * @package CiviCRM_Hook
+ * @copyright CiviCRM LLC (c) 2004-2016
+ */
+class Resolvers {
+
+  /**
+   * In the simple format, the CSS file is loaded from the extension's "css" subdir;
+   * if it's missing, then it searches the parents.
+   *
+   * To use an alternate subdir, override "prefix".
+   *
+   * Simple themes may use the "search_order" to assimilate content from other themes.
+   *
+   * @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 simple($themes, $themeKey, $cssExt, $cssFile) {
+    $res = Civi::resources();
+    $theme = $themes->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));
+  }
+
+}
index 77dc4cce90ef31229eca1d4869907fb0de31149c..f79f52ba4ec7469c399300ad589202a08803e214 100644 (file)
@@ -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,
+  ],
 ];
index ae1dde2df3475e5db3d63f68e238b28ea0f109a8..42e406c5f1c7cae52f4228f11975a172516bb900 100644 (file)
   {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 <a href='%1'>Administer > CiviContribute > CiviContribute Component Settings</a>{/ts}
 {/htxt}
+
+{* ***** Theme options ***** *}
+
+{capture assign=themeDefn}
+  <p>
+    {ts}The theme system allows you to change CiviCRM's appearance by replacing important CSS files.{/ts}
+  </p>
+{/capture}
+{capture assign=themeAdv}
+  {* TODO: Update when there is a page to link to:
+  <p>
+    {ts 1='http://fixme.example.com'}For more advanced theming options, consult the <a href="%1">theme documentation</a>.{/ts}
+  </p>
+  *}
+{/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}
+  <p>
+    {ts}The backend theme determines the appearance on administrative screens, such as the "Manage Event" screen.{/ts}
+  </p>
+  {$themeAdv}
+{/htxt}
+
+{htxt id="theme_frontend-title"}
+  {ts}Frontend Theme{/ts}
+{/htxt}
+{htxt id="theme_frontend"}
+  {$themeDefn}
+  <p>
+    {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}
+  </p>
+  {$themeAdv}
+{/htxt}
index 0aa794a4a46577a8f765ed4a4564e8d19fb85159..336ae914d1d199d151fb13d8b6053c0bfa6abd2e 100644 (file)
         {$form.menubar_color.html}
       </td>
     </tr>
+
+    {if $config->userSystem->is_drupal EQ '1'}
+      <tr class="crm-preferences-display-form-block-theme">
+        <td class="label">{ts}Theme{/ts} {help id="theme"}</td>
+        <td>{$form.theme_backend.html}</td>
+      </tr>
+    {else}
+      <tr class="crm-preferences-display-form-block-theme_backend">
+        <td class="label">{$form.theme_backend.label} {help id="theme_backend"}</td>
+        <td>{$form.theme_backend.html}</td>
+      </tr>
+      <tr class="crm-preferences-display-form-block-theme_frontend">
+        <td class="label">{$form.theme_frontend.label} {help id="theme_frontend"}</td>
+        <td>{$form.theme_frontend.html}</td>
+      </tr>
+      {/if}
   </table>
   <div class="crm-submit-buttons">{include file="CRM/common/formButtons.tpl" location="bottom"}</div>
 </div>
diff --git a/tests/extensions/test.extension.uitest/files/foo.css b/tests/extensions/test.extension.uitest/files/foo.css
new file mode 100644 (file)
index 0000000..2b7c87f
--- /dev/null
@@ -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 (file)
index 0000000..2b7c87f
--- /dev/null
@@ -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 (file)
index 0000000..58921f5
--- /dev/null
@@ -0,0 +1,4 @@
+<extension key='test.extension.uitest' type='module'>
+  <file>uitest</file>
+  <name>test_extension_uitest</name>
+</extension>
diff --git a/tests/extensions/test.extension.uitest/uitest.php b/tests/extensions/test.extension.uitest/uitest.php
new file mode 100644 (file)
index 0000000..b3d9bbc
--- /dev/null
@@ -0,0 +1 @@
+<?php
diff --git a/tests/phpunit/Civi/Core/Theme/judy/css/bootstrap.css b/tests/phpunit/Civi/Core/Theme/judy/css/bootstrap.css
new file mode 100644 (file)
index 0000000..2adfa13
--- /dev/null
@@ -0,0 +1 @@
+/* Placeholder: judy/css/bootstrap.css */
\ No newline at end of file
diff --git a/tests/phpunit/Civi/Core/Theme/judy/css/civicrm.css b/tests/phpunit/Civi/Core/Theme/judy/css/civicrm.css
new file mode 100644 (file)
index 0000000..bbc1619
--- /dev/null
@@ -0,0 +1 @@
+/* Placeholder: judy/css/civicrm.css */
\ No newline at end of file
diff --git a/tests/phpunit/Civi/Core/Theme/liza/css/bootstrap.css b/tests/phpunit/Civi/Core/Theme/liza/css/bootstrap.css
new file mode 100644 (file)
index 0000000..2c2c952
--- /dev/null
@@ -0,0 +1 @@
+/* Placeholder: liz/css/bootstrap.css */
\ No newline at end of file
diff --git a/tests/phpunit/Civi/Core/Theme/liza/css/civicrm.css b/tests/phpunit/Civi/Core/Theme/liza/css/civicrm.css
new file mode 100644 (file)
index 0000000..9b30d85
--- /dev/null
@@ -0,0 +1 @@
+/* Placeholder: liza/css/civicrm.css */
\ No newline at end of file
diff --git a/tests/phpunit/Civi/Core/Theme/liza/css/civicrm.min.css b/tests/phpunit/Civi/Core/Theme/liza/css/civicrm.min.css
new file mode 100644 (file)
index 0000000..0b303f4
--- /dev/null
@@ -0,0 +1 @@
+/* Placeholder: liza/css/civicrm.min.css */
\ No newline at end of file
diff --git a/tests/phpunit/Civi/Core/Theme/liza/test.extension.uitest-files/foo.css b/tests/phpunit/Civi/Core/Theme/liza/test.extension.uitest-files/foo.css
new file mode 100644 (file)
index 0000000..14a29fd
--- /dev/null
@@ -0,0 +1 @@
+/* Placeholder for liza/org.example.foobar:files/foo.css */
\ No newline at end of file
diff --git a/tests/phpunit/Civi/Core/ThemesTest.php b/tests/phpunit/Civi/Core/ThemesTest.php
new file mode 100644 (file)
index 0000000..80bc502
--- /dev/null
@@ -0,0 +1,218 @@
+<?php
+
+namespace Civi\Core;
+
+/**
+ * Class CRM_Core_RegionTest
+ *
+ * @group headless
+ */
+class ThemesTest extends \CiviUnitTestCase {
+
+  protected function setUp() {
+    $this->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_']));
+  }
+
+}