Commit 8b1e918c authored by totten's avatar totten Committed by GitHub

Merge pull request #10085 from totten/master-assets

CRM-20600 - AngularJS - Allowing hooking and static-caching
parents 73c8d0c5 e7b8261d
......@@ -40,6 +40,7 @@ class CRM_Admin_Form_Setting_Debugging extends CRM_Admin_Form_Setting {
'debug_enabled' => CRM_Core_BAO_Setting::DEVELOPER_PREFERENCES_NAME,
'backtrace' => CRM_Core_BAO_Setting::DEVELOPER_PREFERENCES_NAME,
'fatalErrorHandler' => CRM_Core_BAO_Setting::DEVELOPER_PREFERENCES_NAME,
'assetCache' => CRM_Core_BAO_Setting::DEVELOPER_PREFERENCES_NAME,
);
/**
......
......@@ -59,19 +59,10 @@ class CRM_Case_Info extends CRM_Core_Component_Info {
* @inheritDoc
*/
public function getAngularModules() {
$result = array();
$result['crmCaseType'] = array(
'ext' => 'civicrm',
'js' => array('ang/crmCaseType.js'),
'css' => array('ang/crmCaseType.css'),
'partials' => array('ang/crmCaseType'),
);
global $civicrm_root;
CRM_Core_Resources::singleton()->addSetting(array(
'crmCaseType' => array(
'REL_TYPE_CNAME' => CRM_Case_XMLProcessor::REL_TYPE_CNAME,
),
));
$result = array();
$result['crmCaseType'] = include "$civicrm_root/ang/crmCaseType.ang.php";
return $result;
}
......
......@@ -311,6 +311,7 @@ class CRM_Core_Resources {
foreach ($this->settingsFactories as $callable) {
$result = $this->mergeSettings($result, $callable());
}
CRM_Utils_Hook::alterResourceSettings($result);
return $result;
}
......
......@@ -121,6 +121,11 @@
<page_callback>CRM_Contribute_Form_ContributionCharts</page_callback>
<access_arguments>access CiviCRM</access_arguments>
</item>
<item>
<path>civicrm/asset/builder</path>
<page_callback>\Civi\Core\AssetBuilder::pageRun</page_callback>
<access_arguments>*always allow*</access_arguments>
</item>
<item>
<path>civicrm/contribute/ajax/tableview</path>
<page_callback>CRM_Contribute_Page_DashBoard</page_callback>
......
......@@ -71,6 +71,7 @@ class CRM_Mailing_Info extends CRM_Core_Component_Info {
) {
return array();
}
global $civicrm_root;
$reportIds = array();
$reportTypes = array('detail', 'opened', 'bounce', 'clicks');
......@@ -81,32 +82,9 @@ class CRM_Mailing_Info extends CRM_Core_Component_Info {
$reportIds[$report] = $result['values'][0]['id'];
}
$result = array();
$result['crmMailing'] = array(
'ext' => 'civicrm',
'js' => array(
'ang/crmMailing.js',
'ang/crmMailing/*.js',
),
'css' => array('ang/crmMailing.css'),
'partials' => array('ang/crmMailing'),
);
$result['crmMailingAB'] = array(
'ext' => 'civicrm',
'js' => array(
'ang/crmMailingAB.js',
'ang/crmMailingAB/*.js',
'ang/crmMailingAB/*/*.js',
),
'css' => array('ang/crmMailingAB.css'),
'partials' => array('ang/crmMailingAB'),
);
$result['crmD3'] = array(
'ext' => 'civicrm',
'js' => array(
'ang/crmD3.js',
'bower_components/d3/d3.min.js',
),
);
$result['crmMailing'] = include "$civicrm_root/ang/crmMailing.ang.php";
$result['crmMailingAB'] = include "$civicrm_root/ang/crmMailingAB.ang.php";
$result['crmD3'] = include "$civicrm_root/ang/crmD3.ang.php";
$config = CRM_Core_Config::singleton();
$session = CRM_Core_Session::singleton();
......
......@@ -2071,11 +2071,33 @@ abstract class CRM_Utils_Hook {
);
}
/**
* Modify the CRM_Core_Resources settings data.
*
* @param array $data
* @see CRM_Core_Resources::addSetting
*/
public static function alterResourceSettings(&$data) {
$event = \Civi\Core\Event\GenericHookEvent::create(array(
'data' => &$data,
));
Civi::dispatcher()->dispatch('hook_civicrm_alterResourceSettings', $event);
}
/**
* EXPERIMENTAL: This hook allows one to register additional Angular modules
*
* @param array $angularModules
* List of modules.
* List of modules. Each module defines:
* - ext: string, the CiviCRM extension which hosts the files.
* - js: array, list of JS files or globs.
* - css: array, list of CSS files or globs.
* - partials: array, list of base-dirs containing HTML.
* - requires: array, list of required Angular modules.
* - basePages: array, uncondtionally load this module onto the given Angular pages. [v4.7.21+]
* If omitted, default to "array('civicrm/a')" for backward compat.
* For a utility that should only be loaded on-demand, use "array()".
* For a utility that should be loaded in all pages use, "array('*')".
* @return null
* the return value is ignored
*
......@@ -2090,6 +2112,8 @@ abstract class CRM_Utils_Hook {
* 'js' => array('js/part1.js', 'js/part2.js'),
* 'css' => array('css/myAngularModule.css'),
* 'partials' => array('partials/myBigAngularModule'),
* 'requires' => array('otherModuleA', 'otherModuleB'),
* 'basePages' => array('civicrm/a'),
* );
* }
* @endcode
......@@ -2101,6 +2125,52 @@ abstract class CRM_Utils_Hook {
);
}
/**
* Alter the definition of some Angular HTML partials.
*
* @param \Civi\Angular\Manager $angular
*
* @code
* function example_civicrm_alterAngular($angular) {
* $angular->add(ChangeSet::create('mychanges')
* ->alterHtml('~/crmMailing/EditMailingCtrl/2step.html', function(phpQueryObject $doc) {
* $doc->find('[ng-form="crmMailingSubform"]')->attr('cat-stevens', 'ts(\'wild world\')');
* })
* );
* }
* @endCode
*/
public static function alterAngular($angular) {
$event = \Civi\Core\Event\GenericHookEvent::create(array(
'angular' => $angular,
));
Civi::dispatcher()->dispatch('hook_civicrm_alterAngular', $event);
}
/**
* This hook is called whenever the system builds a new copy of
* semi-static asset.
*
* @param string $asset
* The name of the asset.
* Ex: 'angular.json'
* @param array $params
* List of optional arguments which influence the content.
* Note: Params are immutable because they are part of the cache-key.
* @param string $mimeType
* Initially, NULL. Modify to specify the mime-type.
* @param string $content
* Initially, NULL. Modify to specify the rendered content.
* @return null
* the return value is ignored
*/
public static function buildAsset($asset, $params, &$mimeType, &$content) {
return self::singleton()->invoke(array('asset', 'params', 'mimeType', 'content'),
$asset, $params, $mimeType, $content, self::$_nullObject, self::$_nullObject,
'civicrm_buildAsset'
);
}
/**
* This hook fires whenever a record in a case changes.
*
......
<?php
namespace Civi\Angular;
/**
* The AngularLoader loads any JS/CSS/JSON resources
* required for setting up AngularJS.
*
* The AngularLoader stops short of bootstrapping AngularJS. You may
* need to `<div ng-app="..."></div>` or `angular.bootstrap(...)`.
*
* @code
* $loader = new AngularLoader();
* $loader->setPageName('civicrm/case/a');
* $loader->setModules(array('crmApp'));
* $loader->load();
* @endCode
*
* @link https://docs.angularjs.org/guide/bootstrap
*/
class AngularLoader {
/**
* The weight to assign to any Angular JS module files.
*/
const DEFAULT_MODULE_WEIGHT = 200;
/**
* The resource manager.
*
* Do not use publicly. Inject your own copy!
*
* @var \CRM_Core_Resources
*/
protected $res;
/**
* The Angular module manager.
*
* Do not use publicly. Inject your own copy!
*
* @var \Civi\Angular\Manager
*/
protected $angular;
/**
* The region of the page into which JavaScript will be loaded.
*
* @var string
*/
protected $region;
/**
* @var string
* Ex: 'civicrm/a'.
*/
protected $pageName;
/**
* @var array
* A list of modules to load.
*/
protected $modules;
/**
* AngularLoader constructor.
*/
public function __construct() {
$this->res = \CRM_Core_Resources::singleton();
$this->angular = \Civi::service('angular');
$this->region = \CRM_Utils_Request::retrieve('snippet', 'String') ? 'ajax-snippet' : 'html-header';
$this->pageName = isset($_GET['q']) ? $_GET['q'] : NULL;
$this->modules = array();
}
/**
* Register resources required by Angular.
*/
public function load() {
$angular = $this->getAngular();
$res = $this->getRes();
$moduleNames = $this->findActiveModules();
if (!$this->isAllModules($moduleNames)) {
$assetParams = array('modules' => implode(',', $moduleNames));
}
else {
// The module list will be "all modules that the user can see".
$assetParams = array('nonce' => md5(implode(',', $moduleNames)));
}
$res->addSettingsFactory(function () use (&$moduleNames, $angular, $res, $assetParams) {
// TODO optimization; client-side caching
$result = array_merge($angular->getResources($moduleNames, 'settings', 'settings'), array(
'resourceUrls' => \CRM_Extension_System::singleton()->getMapper()->getActiveModuleUrls(),
'angular' => array(
'modules' => $moduleNames,
'requires' => $angular->getResources($moduleNames, 'requires', 'requires'),
'cacheCode' => $res->getCacheCode(),
'bundleUrl' => \Civi::service('asset_builder')->getUrl('angular-modules.json', $assetParams),
),
));
return $result;
});
$res->addScriptFile('civicrm', 'bower_components/angular/angular.min.js', 100, $this->getRegion(), FALSE);
$res->addScriptFile('civicrm', 'js/crm.angular.js', 101, $this->getRegion(), FALSE);
$headOffset = 0;
$config = \CRM_Core_Config::singleton();
if ($config->debug) {
foreach ($moduleNames as $moduleName) {
foreach ($this->angular->getResources($moduleName, 'css', 'cacheUrl') as $url) {
$res->addStyleUrl($url, self::DEFAULT_MODULE_WEIGHT + (++$headOffset), $this->getRegion());
}
foreach ($this->angular->getResources($moduleName, 'js', 'cacheUrl') as $url) {
$res->addScriptUrl($url, self::DEFAULT_MODULE_WEIGHT + (++$headOffset), $this->getRegion());
// addScriptUrl() bypasses the normal string-localization of addScriptFile(),
// but that's OK because all Angular strings (JS+HTML) will load via crmResource.
}
}
}
else {
// Note: addScriptUrl() bypasses the normal string-localization of addScriptFile(),
// but that's OK because all Angular strings (JS+HTML) will load via crmResource.
// $aggScriptUrl = \CRM_Utils_System::url('civicrm/ajax/angular-modules', 'format=js&r=' . $res->getCacheCode(), FALSE, NULL, FALSE);
$aggScriptUrl = \Civi::service('asset_builder')->getUrl('angular-modules.js', $assetParams);
$res->addScriptUrl($aggScriptUrl, 120, $this->getRegion());
// FIXME: The following CSS aggregator doesn't currently handle path-adjustments - which can break icons.
//$aggStyleUrl = \CRM_Utils_System::url('civicrm/ajax/angular-modules', 'format=css&r=' . $res->getCacheCode(), FALSE, NULL, FALSE);
//$aggStyleUrl = \Civi::service('asset_builder')->getUrl('angular-modules.css', $assetParams);
//$res->addStyleUrl($aggStyleUrl, 120, $this->getRegion());
foreach ($this->angular->getResources($moduleNames, 'css', 'cacheUrl') as $url) {
$res->addStyleUrl($url, self::DEFAULT_MODULE_WEIGHT + (++$headOffset), $this->getRegion());
}
}
}
/**
* Get a list of all Angular modules which should be activated on this
* page.
*
* @return array
* List of module names.
* Ex: array('angularFileUpload', 'crmUi', 'crmUtil').
*/
public function findActiveModules() {
return $this->angular->resolveDependencies(array_merge(
$this->getModules(),
$this->angular->resolveDefaultModules($this->getPageName())
));
}
/**
* @param $moduleNames
* @return int
*/
private function isAllModules($moduleNames) {
$allModuleNames = array_keys($this->angular->getModules());
return count(array_diff($allModuleNames, $moduleNames)) === 0;
}
/**
* @return \CRM_Core_Resources
*/
public function getRes() {
return $this->res;
}
/**
* @param \CRM_Core_Resources $res
*/
public function setRes($res) {
$this->res = $res;
}
/**
* @return \Civi\Angular\Manager
*/
public function getAngular() {
return $this->angular;
}
/**
* @param \Civi\Angular\Manager $angular
*/
public function setAngular($angular) {
$this->angular = $angular;
}
/**
* @return string
*/
public function getRegion() {
return $this->region;
}
/**
* @param string $region
*/
public function setRegion($region) {
$this->region = $region;
}
/**
* @return string
* Ex: 'civicrm/a'.
*/
public function getPageName() {
return $this->pageName;
}
/**
* @param string $pageName
* Ex: 'civicrm/a'.
*/
public function setPageName($pageName) {
$this->pageName = $pageName;
}
/**
* @return array
*/
public function getModules() {
return $this->modules;
}
/**
* @param array $modules
*/
public function setModules($modules) {
$this->modules = $modules;
}
}
<?php
namespace Civi\Angular;
class ChangeSet implements ChangeSetInterface {
/**
* Update a listing of resources.
*
* @param array $changeSets
* Array(ChangeSet).
* @param string $resourceType
* Ex: 'requires', 'settings'
* @param array $resources
* The list of resources.
* @return mixed
*/
public static function applyResourceFilters($changeSets, $resourceType, $resources) {
if ($resourceType === 'partials') {
return self::applyHtmlFilters($changeSets, $resources);
}
foreach ($changeSets as $changeSet) {
/** @var ChangeSet $changeSet */
foreach ($changeSet->resFilters as $filter) {
if ($filter['resourceType'] === $resourceType) {
$resources = call_user_func($filter['callback'], $resources);
}
}
}
return $resources;
}
/**
* Update a set of HTML snippets.
*
* @param array $changeSets
* Array(ChangeSet).
* @param array $strings
* Array(string $path => string $html).
* @return array
* Updated list of $strings.
* @throws \CRM_Core_Exception
*/
private static function applyHtmlFilters($changeSets, $strings) {
$coder = new Coder();
foreach ($strings as $path => $html) {
/** @var \phpQueryObject $doc */
$doc = NULL;
// Most docs don't need phpQueryObject. Initialize phpQuery on first match.
foreach ($changeSets as $changeSet) {
/** @var ChangeSet $changeSet */
foreach ($changeSet->htmlFilters as $filter) {
if (preg_match($filter['regex'], $path)) {
if ($doc === NULL) {
$doc = \phpQuery::newDocument($html, 'text/html');
if (\CRM_Core_Config::singleton()->debug && !$coder->checkConsistentHtml($html)) {
throw new \CRM_Core_Exception("Cannot process $path: inconsistent markup. Use check-angular.php to investigate.");
}
}
call_user_func($filter['callback'], $doc, $path);
}
}
}
if ($doc !== NULL) {
$strings[$path] = $coder->encode($doc);
}
}
return $strings;
}
/**
* @var string
*/
protected $name;
/**
* @var array
* Each item is an array with keys:
* - resourceType: string
* - callback: function
*/
protected $resFilters = array();
/**
* @var array
* Each item is an array with keys:
* - regex: string
* - callback: function
*/
protected $htmlFilters = array();
/**
* @param string $name
* Symbolic name for this changeset.
* @return \Civi\Angular\ChangeSetInterface
*/
public static function create($name) {
$changeSet = new ChangeSet();
$changeSet->name = $name;
return $changeSet;
}
/**
* Declare that $module requires additional dependencies.
*
* @param string $module
* @param string|array $dependencies
* @return ChangeSet
*/
public function requires($module, $dependencies) {
$dependencies = (array) $dependencies;
return $this->alterResource('requires',
function ($values) use ($module, $dependencies) {
if (!isset($values[$module])) {
$values[$module] = array();
}
$values[$module] = array_unique(array_merge($values[$module], $dependencies));
return $values;
});
}
/**
* Declare a change to a resource.
*
* @param string $resourceType
* @param callable $callback
* @return ChangeSet
*/
public function alterResource($resourceType, $callback) {
$this->resFilters[] = array(
'resourceType' => $resourceType,
'callback' => $callback,
);
return $this;
}
/**
* Declare a change to HTML.
*
* @param string $file
* A file name, wildcard, or regex.
* Ex: '~/crmHello/intro.html' (filename)
* Ex: '~/crmHello/*.html' (wildcard)
* Ex: ';(Edit|List)Ctrl\.html$;' (regex)
* @param callable $callback
* Function which accepts up to two parameters:
* - phpQueryObject $doc
* - string $path
* @return ChangeSet
*/
public function alterHtml($file, $callback) {
$this->htmlFilters[] = array(
'regex' => ($file{0} === ';') ? $file : $this->createRegex($file),
'callback' => $callback,
);
return $this;
}
/**
* Convert a string with a wildcard (*) to a regex.
*
* @param string $filterExpr
* Ex: "/foo/*.bar"
* @return string
* Ex: ";^/foo/[^/]*\.bar$;"
*/
protected function createRegex($filterExpr) {
$regex = preg_quote($filterExpr, ';');
$regex = str_replace('\\*', '[^/]*', $regex);
$regex = ";^$regex$;";
return $regex;
}
/**
* @return string
*/
public function getName() {
return $this->name;
}
/**
* @param string $name
*/
public function setName($name) {
$this->name = $name;
}
}
<?php
namespace Civi\Angular;
interface ChangeSetInterface {
/**
* Get the symbolic name of the changeset.
*
* @return string
*/
public function getName();
/**
* Declare that $module requires additional dependencies.
*
* @param string $module
* @param string|array $dependencies
* @return ChangeSet
*/
public function requires($module, $dependencies);
/**
* Declare a change to HTML.
*
* @param string $file
* A file name, wildcard, or regex.
* Ex: '~/crmHello/intro.html' (filename)
* Ex: '~/crmHello/*.html' (wildcard)
* Ex: ';(Edit|List)Ctrl\.html$;' (regex)
* @param callable $callback
* Function which accepts up to two parameters:
* - phpQueryObject $doc
* - string $path
* @return ChangeSet
*/
public function alterHtml($file, $callback);
}
<?php
namespace Civi\Angular;
class Coder {
/**
*
* Determine whether an HTML snippet remains consistent (through an
* decode/encode loop).
*
* Note: Variations in whitespace are permitted.
*
* @param string $html
* @return bool
*/
public function checkConsistentHtml($html) {
try {
$recodedHtml = $this->recode($html);
}
catch (\Exception $e) {
return FALSE;
}
$htmlSig = preg_replace('/[ \t\r\n\/]+/', '', $this->cleanup($html));
$docSig = preg_replace('/[ \t\r\n\/]+/', '', $recodedHtml);
if ($htmlSig !== $docSig || empty($html) != empty($htmlSig)) {
return FALSE;
}
return TRUE;
}
/**
* Parse an HTML snippet and re-encode is as HTML.
*
* This is useful for detecting cases where the parser or encoder
* have quirks/bugs.
*
* @param string $html
* @return string
*/
public function recode($html) {
$doc = \phpQuery::newDocument("$html", 'text/html');
return $this->encode($doc);
}
/**
* Encode a phpQueryObject as HTML.
*
* @param \phpQueryObject $doc
* @return string
* HTML
*/
public function encode($doc) {
$doc->document->formatOutput = TRUE;
return $this->cleanup($doc->markupOuter());
}
protected function cleanup($html) {
$html = preg_replace_callback("/([\\-a-zA-Z0-9]+)=(')([^']*)(')/", array($this, 'cleanupAttribute'), $html);
$html = preg_replace_callback('/([\-a-zA-Z0-9]+)=(")([^"]*)(")/', array($this, 'cleanupAttribute'), $html);
return $html;
}