First commit
This commit is contained in:
commit
c6e2478c40
13918 changed files with 2303184 additions and 0 deletions
236
sites/all/modules/civicrm/Civi/Angular/AngularLoader.php
Normal file
236
sites/all/modules/civicrm/Civi/Angular/AngularLoader.php
Normal file
|
@ -0,0 +1,236 @@
|
|||
<?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;
|
||||
}
|
||||
|
||||
}
|
191
sites/all/modules/civicrm/Civi/Angular/ChangeSet.php
Normal file
191
sites/all/modules/civicrm/Civi/Angular/ChangeSet.php
Normal file
|
@ -0,0 +1,191 @@
|
|||
<?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;
|
||||
}
|
||||
|
||||
}
|
|
@ -0,0 +1,38 @@
|
|||
<?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);
|
||||
|
||||
}
|
82
sites/all/modules/civicrm/Civi/Angular/Coder.php
Normal file
82
sites/all/modules/civicrm/Civi/Angular/Coder.php
Normal file
|
@ -0,0 +1,82 @@
|
|||
<?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;
|
||||
}
|
||||
|
||||
protected function cleanupAttribute($matches) {
|
||||
list ($full, $attr, $lquote, $value, $rquote) = $matches;
|
||||
|
||||
switch ($attr) {
|
||||
case 'href':
|
||||
if (strpos($value, '%7B%7B') !== FALSE && strpos($value, '%7D%7D') !== FALSE) {
|
||||
$value = urldecode($value);
|
||||
}
|
||||
break;
|
||||
|
||||
default:
|
||||
$value = html_entity_decode($value);
|
||||
break;
|
||||
}
|
||||
|
||||
return "$attr=$lquote$value$rquote";
|
||||
}
|
||||
|
||||
}
|
413
sites/all/modules/civicrm/Civi/Angular/Manager.php
Normal file
413
sites/all/modules/civicrm/Civi/Angular/Manager.php
Normal file
|
@ -0,0 +1,413 @@
|
|||
<?php
|
||||
namespace Civi\Angular;
|
||||
|
||||
/**
|
||||
* Manage Angular resources.
|
||||
*
|
||||
* @package Civi\Angular
|
||||
*/
|
||||
class Manager {
|
||||
|
||||
/**
|
||||
* @var \CRM_Core_Resources
|
||||
*/
|
||||
protected $res = NULL;
|
||||
|
||||
/**
|
||||
* @var array|NULL
|
||||
* Each item has some combination of these keys:
|
||||
* - ext: string
|
||||
* The Civi extension which defines the Angular module.
|
||||
* - js: array(string $relativeFilePath)
|
||||
* List of JS files (relative to the extension).
|
||||
* - css: array(string $relativeFilePath)
|
||||
* List of CSS files (relative to the extension).
|
||||
* - partials: array(string $relativeFilePath)
|
||||
* A list of partial-HTML folders (relative to the extension).
|
||||
* This will be mapped to "~/moduleName" by crmResource.
|
||||
* - settings: array(string $key => mixed $value)
|
||||
* List of settings to preload.
|
||||
*/
|
||||
protected $modules = NULL;
|
||||
|
||||
/**
|
||||
* @var \CRM_Utils_Cache_Interface
|
||||
*/
|
||||
protected $cache;
|
||||
|
||||
/**
|
||||
* @var array
|
||||
* Array(string $name => ChangeSet $change).
|
||||
*/
|
||||
protected $changeSets = NULL;
|
||||
|
||||
/**
|
||||
* @param \CRM_Core_Resources $res
|
||||
* The resource manager.
|
||||
*/
|
||||
public function __construct($res, \CRM_Utils_Cache_Interface $cache = NULL) {
|
||||
$this->res = $res;
|
||||
$this->cache = $cache ? $cache : new \CRM_Utils_Cache_Arraycache(array());
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a list of AngularJS modules which should be autoloaded.
|
||||
*
|
||||
* @return array
|
||||
* Each item has some combination of these keys:
|
||||
* - ext: string
|
||||
* The Civi extension which defines the Angular module.
|
||||
* - js: array(string $relativeFilePath)
|
||||
* List of JS files (relative to the extension).
|
||||
* - css: array(string $relativeFilePath)
|
||||
* List of CSS files (relative to the extension).
|
||||
* - partials: array(string $relativeFilePath)
|
||||
* A list of partial-HTML folders (relative to the extension).
|
||||
* This will be mapped to "~/moduleName" by crmResource.
|
||||
* - settings: array(string $key => mixed $value)
|
||||
* List of settings to preload.
|
||||
*/
|
||||
public function getModules() {
|
||||
if ($this->modules === NULL) {
|
||||
$config = \CRM_Core_Config::singleton();
|
||||
global $civicrm_root;
|
||||
|
||||
// Note: It would be nice to just glob("$civicrm_root/ang/*.ang.php"), but at time
|
||||
// of writing CiviMail and CiviCase have special conditionals.
|
||||
|
||||
$angularModules = array();
|
||||
$angularModules['angularFileUpload'] = include "$civicrm_root/ang/angularFileUpload.ang.php";
|
||||
$angularModules['crmApp'] = include "$civicrm_root/ang/crmApp.ang.php";
|
||||
$angularModules['crmAttachment'] = include "$civicrm_root/ang/crmAttachment.ang.php";
|
||||
$angularModules['crmAutosave'] = include "$civicrm_root/ang/crmAutosave.ang.php";
|
||||
$angularModules['crmCxn'] = include "$civicrm_root/ang/crmCxn.ang.php";
|
||||
// $angularModules['crmExample'] = include "$civicrm_root/ang/crmExample.ang.php";
|
||||
$angularModules['crmResource'] = include "$civicrm_root/ang/crmResource.ang.php";
|
||||
$angularModules['crmUi'] = include "$civicrm_root/ang/crmUi.ang.php";
|
||||
$angularModules['crmUtil'] = include "$civicrm_root/ang/crmUtil.ang.php";
|
||||
$angularModules['dialogService'] = include "$civicrm_root/ang/dialogService.ang.php";
|
||||
$angularModules['ngRoute'] = include "$civicrm_root/ang/ngRoute.ang.php";
|
||||
$angularModules['ngSanitize'] = include "$civicrm_root/ang/ngSanitize.ang.php";
|
||||
$angularModules['ui.utils'] = include "$civicrm_root/ang/ui.utils.ang.php";
|
||||
$angularModules['ui.bootstrap'] = include "$civicrm_root/ang/ui.bootstrap.ang.php";
|
||||
$angularModules['ui.sortable'] = include "$civicrm_root/ang/ui.sortable.ang.php";
|
||||
$angularModules['unsavedChanges'] = include "$civicrm_root/ang/unsavedChanges.ang.php";
|
||||
$angularModules['statuspage'] = include "$civicrm_root/ang/crmStatusPage.ang.php";
|
||||
|
||||
foreach (\CRM_Core_Component::getEnabledComponents() as $component) {
|
||||
$angularModules = array_merge($angularModules, $component->getAngularModules());
|
||||
}
|
||||
\CRM_Utils_Hook::angularModules($angularModules);
|
||||
foreach (array_keys($angularModules) as $module) {
|
||||
if (!isset($angularModules[$module]['basePages'])) {
|
||||
$angularModules[$module]['basePages'] = array('civicrm/a');
|
||||
}
|
||||
}
|
||||
$this->modules = $this->resolvePatterns($angularModules);
|
||||
}
|
||||
|
||||
return $this->modules;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the descriptor for an Angular module.
|
||||
*
|
||||
* @param string $name
|
||||
* Module name.
|
||||
* @return array
|
||||
* Details about the module:
|
||||
* - ext: string, the name of the Civi extension which defines the module
|
||||
* - js: array(string $relativeFilePath).
|
||||
* - css: array(string $relativeFilePath).
|
||||
* - partials: array(string $relativeFilePath).
|
||||
* @throws \Exception
|
||||
*/
|
||||
public function getModule($name) {
|
||||
$modules = $this->getModules();
|
||||
if (!isset($modules[$name])) {
|
||||
throw new \Exception("Unrecognized Angular module");
|
||||
}
|
||||
return $modules[$name];
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a full list of Angular dependencies.
|
||||
*
|
||||
* @param array $names
|
||||
* List of Angular modules.
|
||||
* Ex: array('crmMailing').
|
||||
* @return array
|
||||
* List of Angular modules, include all dependencies.
|
||||
* Ex: array('crmMailing', 'crmUi', 'crmUtil', 'ngRoute').
|
||||
*/
|
||||
public function resolveDependencies($names) {
|
||||
$allModules = $this->getModules();
|
||||
$visited = array();
|
||||
$result = $names;
|
||||
while (($missingModules = array_diff($result, array_keys($visited))) && !empty($missingModules)) {
|
||||
foreach ($missingModules as $module) {
|
||||
$visited[$module] = 1;
|
||||
if (!isset($allModules[$module])) {
|
||||
\Civi::log()->warning('Unrecognized Angular module {name}. Please ensure that all Angular modules are declared.', array(
|
||||
'name' => $module,
|
||||
'civi.tag' => 'deprecated',
|
||||
));
|
||||
}
|
||||
elseif (isset($allModules[$module]['requires'])) {
|
||||
$result = array_unique(array_merge($result, $allModules[$module]['requires']));
|
||||
}
|
||||
}
|
||||
}
|
||||
sort($result);
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a list of Angular modules that should be loaded on the given
|
||||
* base-page.
|
||||
*
|
||||
* @param string $basePage
|
||||
* The name of the base-page for which we want a list of moudles.
|
||||
* @return array
|
||||
* List of Angular modules.
|
||||
* Ex: array('crmMailing', 'crmUi', 'crmUtil', 'ngRoute').
|
||||
*/
|
||||
public function resolveDefaultModules($basePage) {
|
||||
$modules = $this->getModules();
|
||||
$result = array();
|
||||
foreach ($modules as $moduleName => $module) {
|
||||
if (in_array($basePage, $module['basePages']) || in_array('*', $module['basePages'])) {
|
||||
$result[] = $moduleName;
|
||||
}
|
||||
}
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert any globs in an Angular module to file names.
|
||||
*
|
||||
* @param array $modules
|
||||
* List of Angular modules.
|
||||
* @return array
|
||||
* Updated list of Angular modules
|
||||
*/
|
||||
protected function resolvePatterns($modules) {
|
||||
$newModules = array();
|
||||
|
||||
foreach ($modules as $moduleKey => $module) {
|
||||
foreach (array('js', 'css', 'partials') as $fileset) {
|
||||
if (!isset($module[$fileset])) {
|
||||
continue;
|
||||
}
|
||||
$module[$fileset] = $this->res->glob($module['ext'], $module[$fileset]);
|
||||
}
|
||||
$newModules[$moduleKey] = $module;
|
||||
}
|
||||
|
||||
return $newModules;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the partial HTML documents for a module (unfiltered).
|
||||
*
|
||||
* @param string $name
|
||||
* Angular module name.
|
||||
* @return array
|
||||
* Array(string $extFilePath => string $html)
|
||||
* @throws \Exception
|
||||
* Invalid partials configuration.
|
||||
*/
|
||||
public function getRawPartials($name) {
|
||||
$module = $this->getModule($name);
|
||||
$result = array();
|
||||
if (isset($module['partials'])) {
|
||||
foreach ($module['partials'] as $partialDir) {
|
||||
$partialDir = $this->res->getPath($module['ext']) . '/' . $partialDir;
|
||||
$files = \CRM_Utils_File::findFiles($partialDir, '*.html', TRUE);
|
||||
foreach ($files as $file) {
|
||||
$filename = '~/' . $name . '/' . $file;
|
||||
$result[$filename] = file_get_contents($partialDir . '/' . $file);
|
||||
}
|
||||
}
|
||||
return $result;
|
||||
}
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the partial HTML documents for a module.
|
||||
*
|
||||
* @param string $name
|
||||
* Angular module name.
|
||||
* @return array
|
||||
* Array(string $extFilePath => string $html)
|
||||
* @throws \Exception
|
||||
* Invalid partials configuration.
|
||||
*/
|
||||
public function getPartials($name) {
|
||||
$cacheKey = "angular-partials::$name";
|
||||
$cacheValue = $this->cache->get($cacheKey);
|
||||
if ($cacheValue === NULL) {
|
||||
$cacheValue = ChangeSet::applyResourceFilters($this->getChangeSets(), 'partials', $this->getRawPartials($name));
|
||||
$this->cache->set($cacheKey, $cacheValue);
|
||||
}
|
||||
return $cacheValue;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get list of translated strings for a module.
|
||||
*
|
||||
* @param string $name
|
||||
* Angular module name.
|
||||
* @return array
|
||||
* Translated strings: array(string $orig => string $translated).
|
||||
*/
|
||||
public function getTranslatedStrings($name) {
|
||||
$module = $this->getModule($name);
|
||||
$result = array();
|
||||
$strings = $this->getStrings($name);
|
||||
foreach ($strings as $string) {
|
||||
// TODO: should we pass translation domain based on $module[ext] or $module[tsDomain]?
|
||||
// It doesn't look like client side really supports the domain right now...
|
||||
$translated = ts($string, array(
|
||||
'domain' => array($module['ext'], NULL),
|
||||
));
|
||||
if ($translated != $string) {
|
||||
$result[$string] = $translated;
|
||||
}
|
||||
}
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get list of translatable strings for a module.
|
||||
*
|
||||
* @param string $name
|
||||
* Angular module name.
|
||||
* @return array
|
||||
* Translatable strings.
|
||||
*/
|
||||
public function getStrings($name) {
|
||||
$module = $this->getModule($name);
|
||||
$result = array();
|
||||
if (isset($module['js'])) {
|
||||
foreach ($module['js'] as $file) {
|
||||
$strings = $this->res->getStrings()->get(
|
||||
$module['ext'],
|
||||
$this->res->getPath($module['ext'], $file),
|
||||
'text/javascript'
|
||||
);
|
||||
$result = array_unique(array_merge($result, $strings));
|
||||
}
|
||||
}
|
||||
$partials = $this->getPartials($name);
|
||||
foreach ($partials as $partial) {
|
||||
$result = array_unique(array_merge($result, \CRM_Utils_JS::parseStrings($partial)));
|
||||
}
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get resources for one or more modules.
|
||||
*
|
||||
* @param string|array $moduleNames
|
||||
* List of module names.
|
||||
* @param string $resType
|
||||
* Type of resource ('js', 'css', 'settings').
|
||||
* @param string $refType
|
||||
* Type of reference to the resource ('cacheUrl', 'rawUrl', 'path', 'settings').
|
||||
* @return array
|
||||
* List of URLs or paths.
|
||||
* @throws \CRM_Core_Exception
|
||||
*/
|
||||
public function getResources($moduleNames, $resType, $refType) {
|
||||
$result = array();
|
||||
$moduleNames = (array) $moduleNames;
|
||||
foreach ($moduleNames as $moduleName) {
|
||||
$module = $this->getModule($moduleName);
|
||||
if (isset($module[$resType])) {
|
||||
foreach ($module[$resType] as $file) {
|
||||
$refTypeSuffix = '';
|
||||
if (is_string($file) && preg_match(';^(assetBuilder|ext)://;', $file)) {
|
||||
$refTypeSuffix = '-' . parse_url($file, PHP_URL_SCHEME);
|
||||
}
|
||||
|
||||
switch ($refType . $refTypeSuffix) {
|
||||
case 'path':
|
||||
$result[] = $this->res->getPath($module['ext'], $file);
|
||||
break;
|
||||
|
||||
case 'rawUrl':
|
||||
$result[] = $this->res->getUrl($module['ext'], $file);
|
||||
break;
|
||||
|
||||
case 'cacheUrl':
|
||||
$result[] = $this->res->getUrl($module['ext'], $file, TRUE);
|
||||
break;
|
||||
|
||||
case 'path-assetBuilder':
|
||||
$assetName = parse_url($file, PHP_URL_HOST) . parse_url($file, PHP_URL_PATH);
|
||||
$assetParams = array();
|
||||
parse_str('' . parse_url($file, PHP_URL_QUERY), $assetParams);
|
||||
$result[] = \Civi::service('asset_builder')->getPath($assetName, $assetParams);
|
||||
break;
|
||||
|
||||
case 'rawUrl-assetBuilder':
|
||||
case 'cacheUrl-assetBuilder':
|
||||
$assetName = parse_url($file, PHP_URL_HOST) . parse_url($file, PHP_URL_PATH);
|
||||
$assetParams = array();
|
||||
parse_str('' . parse_url($file, PHP_URL_QUERY), $assetParams);
|
||||
$result[] = \Civi::service('asset_builder')->getUrl($assetName, $assetParams);
|
||||
break;
|
||||
|
||||
case 'path-ext':
|
||||
$result[] = $this->res->getPath(parse_url($file, PHP_URL_HOST), ltrim(parse_url($file, PHP_URL_PATH), '/'));
|
||||
break;
|
||||
|
||||
case 'rawUrl-ext':
|
||||
$result[] = $this->res->getUrl(parse_url($file, PHP_URL_HOST), ltrim(parse_url($file, PHP_URL_PATH), '/'));
|
||||
break;
|
||||
|
||||
case 'cacheUrl-ext':
|
||||
$result[] = $this->res->getUrl(parse_url($file, PHP_URL_HOST), ltrim(parse_url($file, PHP_URL_PATH), '/'), TRUE);
|
||||
break;
|
||||
|
||||
case 'settings':
|
||||
case 'requires':
|
||||
if (!empty($module[$resType])) {
|
||||
$result[$moduleName] = $module[$resType];
|
||||
}
|
||||
break;
|
||||
|
||||
default:
|
||||
throw new \CRM_Core_Exception("Unrecognized resource format");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return ChangeSet::applyResourceFilters($this->getChangeSets(), $resType, $result);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array
|
||||
* Array(string $name => ChangeSet $changeSet).
|
||||
*/
|
||||
public function getChangeSets() {
|
||||
if ($this->changeSets === NULL) {
|
||||
$this->changeSets = array();
|
||||
\CRM_Utils_Hook::alterAngular($this);
|
||||
}
|
||||
return $this->changeSets;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param ChangeSet $changeSet
|
||||
* @return \Civi\Angular\Manager
|
||||
*/
|
||||
public function add($changeSet) {
|
||||
$this->changeSets[$changeSet->getName()] = $changeSet;
|
||||
return $this;
|
||||
}
|
||||
|
||||
}
|
100
sites/all/modules/civicrm/Civi/Angular/Page/Main.php
Normal file
100
sites/all/modules/civicrm/Civi/Angular/Page/Main.php
Normal file
|
@ -0,0 +1,100 @@
|
|||
<?php
|
||||
namespace Civi\Angular\Page;
|
||||
|
||||
/**
|
||||
* This page is simply a container; any Angular modules defined by CiviCRM (or by CiviCRM extensions)
|
||||
* will be activated on this page.
|
||||
*
|
||||
* @link https://issues.civicrm.org/jira/browse/CRM-14479
|
||||
*/
|
||||
class Main extends \CRM_Core_Page {
|
||||
|
||||
/**
|
||||
* 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
|
||||
* @deprecated
|
||||
*/
|
||||
public $res;
|
||||
|
||||
/**
|
||||
* The Angular module manager.
|
||||
*
|
||||
* Do not use publicly. Inject your own copy!
|
||||
*
|
||||
* @var \Civi\Angular\Manager
|
||||
* @deprecated
|
||||
*/
|
||||
public $angular;
|
||||
|
||||
/**
|
||||
* The region of the page into which JavaScript will be loaded.
|
||||
*
|
||||
* @var String
|
||||
* @deprecated
|
||||
*/
|
||||
public $region;
|
||||
|
||||
/**
|
||||
* @param string $title
|
||||
* Title of the page.
|
||||
* @param int $mode
|
||||
* Mode of the page.
|
||||
* @param \CRM_Core_Resources|null $res
|
||||
* Resource manager.
|
||||
*/
|
||||
public function __construct($title = NULL, $mode = NULL, $res = NULL) {
|
||||
parent::__construct($title, $mode);
|
||||
$this->res = \CRM_Core_Resources::singleton();
|
||||
$this->angular = \Civi::service('angular');
|
||||
$this->region = \CRM_Utils_Request::retrieve('snippet', 'String') ? 'ajax-snippet' : 'html-header';
|
||||
}
|
||||
|
||||
/**
|
||||
* This function takes care of all the things common to all
|
||||
* pages. This typically involves assigning the appropriate
|
||||
* smarty variable :)
|
||||
*
|
||||
* @return string
|
||||
* The content generated by running this page
|
||||
*/
|
||||
public function run() {
|
||||
$this->registerResources();
|
||||
return parent::run();
|
||||
}
|
||||
|
||||
/**
|
||||
* Register resources required by Angular.
|
||||
*/
|
||||
public function registerResources() {
|
||||
$loader = new \Civi\Angular\AngularLoader();
|
||||
$loader->setPageName('civicrm/a');
|
||||
$loader->setModules(array('crmApp'));
|
||||
$loader->load();
|
||||
|
||||
// If trying to load an Angular page via AJAX, the route must be passed as a
|
||||
// URL parameter, since the server doesn't receive information about
|
||||
// URL fragments (i.e, what comes after the #).
|
||||
\CRM_Core_Resources::singleton()->addSetting(array(
|
||||
'crmApp' => array(
|
||||
'defaultRoute' => NULL,
|
||||
),
|
||||
'angularRoute' => \CRM_Utils_Request::retrieve('route', 'String'),
|
||||
));
|
||||
}
|
||||
|
||||
/**
|
||||
* @inheritdoc
|
||||
*/
|
||||
public function getTemplateFileName() {
|
||||
return 'Civi/Angular/Page/Main.tpl';
|
||||
}
|
||||
|
||||
}
|
178
sites/all/modules/civicrm/Civi/Angular/Page/Modules.php
Normal file
178
sites/all/modules/civicrm/Civi/Angular/Page/Modules.php
Normal file
|
@ -0,0 +1,178 @@
|
|||
<?php
|
||||
|
||||
namespace Civi\Angular\Page;
|
||||
|
||||
/**
|
||||
* This page aggregates data from Angular modules.
|
||||
*
|
||||
* Example: Aggregate metadata about all modules in JSON format.
|
||||
* civicrm/ajax/angular-modules?format=json
|
||||
*
|
||||
* Example: Aggregate metadata for crmUi and crmUtil modules.
|
||||
* civicrm/ajax/angular-modules?format=json&modules=crmUi,crmUtil
|
||||
*
|
||||
* Example: Aggregate *.js files for all modules.
|
||||
* civicrm/ajax/angular-modules?format=js
|
||||
*
|
||||
* Example: Aggregate *.css files for all modules.
|
||||
* civicrm/ajax/angular-modules?format=css
|
||||
*/
|
||||
class Modules extends \CRM_Core_Page {
|
||||
|
||||
/**
|
||||
* Generate asset content (when accessed via older, custom
|
||||
* "civicrm/ajax/anulgar-modules" route).
|
||||
*
|
||||
* @deprecated
|
||||
*/
|
||||
public function run() {
|
||||
/**
|
||||
* @var \Civi\Angular\Manager $angular
|
||||
*/
|
||||
$angular = \Civi::service('angular');
|
||||
$moduleNames = $this->parseModuleNames(\CRM_Utils_Request::retrieve('modules', 'String'), $angular);
|
||||
|
||||
switch (\CRM_Utils_Request::retrieve('format', 'String')) {
|
||||
case 'json':
|
||||
case '':
|
||||
$this->send(
|
||||
'application/javascript',
|
||||
json_encode($this->getMetadata($moduleNames, $angular))
|
||||
);
|
||||
break;
|
||||
|
||||
case 'js':
|
||||
$this->send(
|
||||
'application/javascript',
|
||||
$this->digestJs($angular->getResources($moduleNames, 'js', 'path'))
|
||||
);
|
||||
break;
|
||||
|
||||
case 'css':
|
||||
$this->send(
|
||||
'text/css',
|
||||
\CRM_Utils_File::concat($angular->getResources($moduleNames, 'css', 'path'), "\n")
|
||||
);
|
||||
break;
|
||||
|
||||
default:
|
||||
\CRM_Core_Error::fatal("Unrecognized format");
|
||||
}
|
||||
|
||||
\CRM_Utils_System::civiExit();
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate asset content (when accessed via AssetBuilder).
|
||||
*
|
||||
* @param \Civi\Core\Event\GenericHookEvent $event
|
||||
* @see CRM_Utils_hook::buildAsset()
|
||||
* @see \Civi\Core\AssetBuilder
|
||||
*/
|
||||
public static function buildAngularModules($event) {
|
||||
$page = new Modules();
|
||||
$angular = \Civi::service('angular');
|
||||
|
||||
switch ($event->asset) {
|
||||
case 'angular-modules.json':
|
||||
$moduleNames = $page->parseModuleNames(\CRM_Utils_Array::value('modules', $event->params), $angular);
|
||||
$event->mimeType = 'application/json';
|
||||
$event->content = json_encode($page->getMetadata($moduleNames, $angular));
|
||||
break;
|
||||
|
||||
case 'angular-modules.js':
|
||||
$moduleNames = $page->parseModuleNames(\CRM_Utils_Array::value('modules', $event->params), $angular);
|
||||
$event->mimeType = 'application/javascript';
|
||||
$event->content = $page->digestJs($angular->getResources($moduleNames, 'js', 'path'));
|
||||
break;
|
||||
|
||||
case 'angular-modules.css':
|
||||
$moduleNames = $page->parseModuleNames(\CRM_Utils_Array::value('modules', $event->params), $angular);
|
||||
$event->mimeType = 'text/css';
|
||||
$event->content = \CRM_Utils_File::concat($angular->getResources($moduleNames, 'css', 'path'), "\n");
|
||||
|
||||
default:
|
||||
// Not our problem.
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array $files
|
||||
* File paths.
|
||||
* @return string
|
||||
*/
|
||||
public function digestJs($files) {
|
||||
$scripts = array();
|
||||
foreach ($files as $file) {
|
||||
$scripts[] = file_get_contents($file);
|
||||
}
|
||||
$scripts = \CRM_Utils_JS::dedupeClosures(
|
||||
$scripts,
|
||||
array('angular', '$', '_'),
|
||||
array('angular', 'CRM.$', 'CRM._')
|
||||
);
|
||||
// This impl of stripComments currently adds 10-20ms and cuts ~7%
|
||||
return \CRM_Utils_JS::stripComments(implode("\n", $scripts));
|
||||
}
|
||||
|
||||
/**
|
||||
* @param string $modulesExpr
|
||||
* Comma-separated list of module names.
|
||||
* @param \Civi\Angular\Manager $angular
|
||||
* @return array
|
||||
* Any well-formed module names. All if moduleExpr is blank.
|
||||
*/
|
||||
public function parseModuleNames($modulesExpr, $angular) {
|
||||
if ($modulesExpr) {
|
||||
$moduleNames = preg_grep(
|
||||
'/^[a-zA-Z0-9\-_\.]+$/',
|
||||
explode(',', $modulesExpr)
|
||||
);
|
||||
return $moduleNames;
|
||||
}
|
||||
else {
|
||||
$moduleNames = array_keys($angular->getModules());
|
||||
return $moduleNames;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array $moduleNames
|
||||
* List of module names.
|
||||
* @param \Civi\Angular\Manager $angular
|
||||
* @return array
|
||||
*/
|
||||
public function getMetadata($moduleNames, $angular) {
|
||||
$modules = $angular->getModules();
|
||||
$result = array();
|
||||
foreach ($moduleNames as $moduleName) {
|
||||
if (isset($modules[$moduleName])) {
|
||||
$result[$moduleName] = array();
|
||||
$result[$moduleName]['domain'] = $modules[$moduleName]['ext'];
|
||||
$result[$moduleName]['js'] = $angular->getResources($moduleName, 'js', 'rawUrl');
|
||||
$result[$moduleName]['css'] = $angular->getResources($moduleName, 'css', 'rawUrl');
|
||||
$result[$moduleName]['partials'] = $angular->getPartials($moduleName);
|
||||
$result[$moduleName]['strings'] = $angular->getTranslatedStrings($moduleName);
|
||||
}
|
||||
}
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a response.
|
||||
*
|
||||
* @param string $type
|
||||
* Content type.
|
||||
* @param string $data
|
||||
* Content.
|
||||
*/
|
||||
public function send($type, $data) {
|
||||
// Encourage browsers to cache for a long time - 1 year
|
||||
$ttl = 60 * 60 * 24 * 364;
|
||||
\CRM_Utils_System::setHttpHeader('Expires', gmdate('D, d M Y H:i:s \G\M\T', time() + $ttl));
|
||||
\CRM_Utils_System::setHttpHeader("Content-Type", $type);
|
||||
\CRM_Utils_System::setHttpHeader("Cache-Control", "max-age=$ttl, public");
|
||||
echo $data;
|
||||
}
|
||||
|
||||
}
|
Loading…
Add table
Add a link
Reference in a new issue