From 6048818503ae8d051f6291d5fe114ca9b3a81ad0 Mon Sep 17 00:00:00 2001 From: Manoj K Date: Fri, 5 Sep 2014 18:17:09 +0530 Subject: [PATCH] #29521 - Skeleton structure generated using civix tool. --- CRM/Wci/Form/CreateWidget.php | 76 ++++++ CRM/Wci/Page/WCIDashboard.php | 15 ++ CRM/Wci/Upgrader.php | 115 +++++++++ CRM/Wci/Upgrader/Base.php | 298 ++++++++++++++++++++++++ info.xml | 21 ++ sql/install.sql | 1 + sql/uninstall.sql | 1 + templates/CRM/Wci/Form/CreateWidget.tpl | 27 +++ templates/CRM/Wci/Page/WCIDashboard.tpl | 7 + wci.civix.php | 278 ++++++++++++++++++++++ wci.php | 108 +++++++++ xml/Menu/wci.xml | 15 ++ 12 files changed, 962 insertions(+) create mode 100644 CRM/Wci/Form/CreateWidget.php create mode 100644 CRM/Wci/Page/WCIDashboard.php create mode 100644 CRM/Wci/Upgrader.php create mode 100644 CRM/Wci/Upgrader/Base.php create mode 100644 info.xml create mode 100644 sql/install.sql create mode 100644 sql/uninstall.sql create mode 100644 templates/CRM/Wci/Form/CreateWidget.tpl create mode 100644 templates/CRM/Wci/Page/WCIDashboard.tpl create mode 100644 wci.civix.php create mode 100644 wci.php create mode 100644 xml/Menu/wci.xml diff --git a/CRM/Wci/Form/CreateWidget.php b/CRM/Wci/Form/CreateWidget.php new file mode 100644 index 0000000..98a6ec4 --- /dev/null +++ b/CRM/Wci/Form/CreateWidget.php @@ -0,0 +1,76 @@ +add( + 'select', // field type + 'favorite_color', // field name + 'Favorite Color', // field label + $this->getColorOptions(), // list of options + true // is required + ); + $this->addButtons(array( + array( + 'type' => 'submit', + 'name' => ts('Submit'), + 'isDefault' => TRUE, + ), + )); + + // export form elements + $this->assign('elementNames', $this->getRenderableElementNames()); + parent::buildQuickForm(); + } + + function postProcess() { + $values = $this->exportValues(); + $options = $this->getColorOptions(); + CRM_Core_Session::setStatus(ts('You picked color "%1"', array( + 1 => $options[$values['favorite_color']] + ))); + parent::postProcess(); + } + + function getColorOptions() { + $options = array( + '' => ts('- select -'), + '#f00' => ts('Red'), + '#0f0' => ts('Green'), + '#00f' => ts('Blue'), + '#f0f' => ts('Purple'), + ); + foreach (array('1','2','3','4','5','6','7','8','9','a','b','c','d','e') as $f) { + $options["#{$f}{$f}{$f}"] = ts('Grey (%1)', array(1 => $f)); + } + return $options; + } + + /** + * Get the fields/elements defined in this form. + * + * @return array (string) + */ + function getRenderableElementNames() { + // The _elements list includes some items which should not be + // auto-rendered in the loop -- such as "qfKey" and "buttons". These + // items don't have labels. We'll identify renderable by filtering on + // the 'label'. + $elementNames = array(); + foreach ($this->_elements as $element) { + $label = $element->getLabel(); + if (!empty($label)) { + $elementNames[] = $element->getName(); + } + } + return $elementNames; + } +} diff --git a/CRM/Wci/Page/WCIDashboard.php b/CRM/Wci/Page/WCIDashboard.php new file mode 100644 index 0000000..2aab981 --- /dev/null +++ b/CRM/Wci/Page/WCIDashboard.php @@ -0,0 +1,15 @@ +assign('currentTime', date('Y-m-d H:i:s')); + + parent::run(); + } +} diff --git a/CRM/Wci/Upgrader.php b/CRM/Wci/Upgrader.php new file mode 100644 index 0000000..a0afc6b --- /dev/null +++ b/CRM/Wci/Upgrader.php @@ -0,0 +1,115 @@ +executeSqlFile('sql/install.sql'); + } + + /** + * Example: Run an external SQL script when the module is uninstalled + */ + public function uninstall() { + $this->executeSqlFile('sql/uninstall.sql'); + } + + /** + * Example: Run a simple query when a module is enabled + * + public function enable() { + CRM_Core_DAO::executeQuery('UPDATE foo SET is_active = 1 WHERE bar = "whiz"'); + } + + /** + * Example: Run a simple query when a module is disabled + * + public function disable() { + CRM_Core_DAO::executeQuery('UPDATE foo SET is_active = 0 WHERE bar = "whiz"'); + } + + /** + * Example: Run a couple simple queries + * + * @return TRUE on success + * @throws Exception + * + public function upgrade_4200() { + $this->ctx->log->info('Applying update 4200'); + CRM_Core_DAO::executeQuery('UPDATE foo SET bar = "whiz"'); + CRM_Core_DAO::executeQuery('DELETE FROM bang WHERE willy = wonka(2)'); + return TRUE; + } // */ + + + /** + * Example: Run an external SQL script + * + * @return TRUE on success + * @throws Exception + public function upgrade_4201() { + $this->ctx->log->info('Applying update 4201'); + // this path is relative to the extension base dir + $this->executeSqlFile('sql/upgrade_4201.sql'); + return TRUE; + } // */ + + + /** + * Example: Run a slow upgrade process by breaking it up into smaller chunk + * + * @return TRUE on success + * @throws Exception + public function upgrade_4202() { + $this->ctx->log->info('Planning update 4202'); // PEAR Log interface + + $this->addTask(ts('Process first step'), 'processPart1', $arg1, $arg2); + $this->addTask(ts('Process second step'), 'processPart2', $arg3, $arg4); + $this->addTask(ts('Process second step'), 'processPart3', $arg5); + return TRUE; + } + public function processPart1($arg1, $arg2) { sleep(10); return TRUE; } + public function processPart2($arg3, $arg4) { sleep(10); return TRUE; } + public function processPart3($arg5) { sleep(10); return TRUE; } + // */ + + + /** + * Example: Run an upgrade with a query that touches many (potentially + * millions) of records by breaking it up into smaller chunks. + * + * @return TRUE on success + * @throws Exception + public function upgrade_4203() { + $this->ctx->log->info('Planning update 4203'); // PEAR Log interface + + $minId = CRM_Core_DAO::singleValueQuery('SELECT coalesce(min(id),0) FROM civicrm_contribution'); + $maxId = CRM_Core_DAO::singleValueQuery('SELECT coalesce(max(id),0) FROM civicrm_contribution'); + for ($startId = $minId; $startId <= $maxId; $startId += self::BATCH_SIZE) { + $endId = $startId + self::BATCH_SIZE - 1; + $title = ts('Upgrade Batch (%1 => %2)', array( + 1 => $startId, + 2 => $endId, + )); + $sql = ' + UPDATE civicrm_contribution SET foobar = whiz(wonky()+wanker) + WHERE id BETWEEN %1 and %2 + '; + $params = array( + 1 => array($startId, 'Integer'), + 2 => array($endId, 'Integer'), + ); + $this->addTask($title, 'executeSql', $sql, $params); + } + return TRUE; + } // */ + +} diff --git a/CRM/Wci/Upgrader/Base.php b/CRM/Wci/Upgrader/Base.php new file mode 100644 index 0000000..fa1544b --- /dev/null +++ b/CRM/Wci/Upgrader/Base.php @@ -0,0 +1,298 @@ +ctx = array_shift($args); + $instance->queue = $instance->ctx->queue; + $method = array_shift($args); + return call_user_func_array(array($instance, $method), $args); + } + + public function __construct($extensionName, $extensionDir) { + $this->extensionName = $extensionName; + $this->extensionDir = $extensionDir; + } + + // ******** Task helpers ******** + + /** + * Run a CustomData file + * + * @param string $relativePath the CustomData XML file path (relative to this extension's dir) + * @return bool + */ + public function executeCustomDataFile($relativePath) { + $xml_file = $this->extensionDir . '/' . $relativePath; + return $this->executeCustomDataFileByAbsPath($xml_file); + } + + /** + * Run a CustomData file + * + * @param string $xml_file the CustomData XML file path (absolute path) + * @return bool + */ + protected static function executeCustomDataFileByAbsPath($xml_file) { + require_once 'CRM/Utils/Migrate/Import.php'; + $import = new CRM_Utils_Migrate_Import(); + $import->run($xml_file); + return TRUE; + } + + /** + * Run a SQL file + * + * @param string $relativePath the SQL file path (relative to this extension's dir) + * @return bool + */ + public function executeSqlFile($relativePath) { + CRM_Utils_File::sourceSQLFile( + CIVICRM_DSN, + $this->extensionDir . '/' . $relativePath + ); + return TRUE; + } + + /** + * Run one SQL query + * + * This is just a wrapper for CRM_Core_DAO::executeSql, but it + * provides syntatic sugar for queueing several tasks that + * run different queries + */ + public function executeSql($query, $params = array()) { + // FIXME verify that we raise an exception on error + CRM_Core_DAO::executeSql($query, $params); + return TRUE; + } + + /** + * Syntatic sugar for enqueuing a task which calls a function + * in this class. The task is weighted so that it is processed + * as part of the currently-pending revision. + * + * After passing the $funcName, you can also pass parameters that will go to + * the function. Note that all params must be serializable. + */ + public function addTask($title) { + $args = func_get_args(); + $title = array_shift($args); + $task = new CRM_Queue_Task( + array(get_class($this), '_queueAdapter'), + $args, + $title + ); + return $this->queue->createItem($task, array('weight' => -1)); + } + + // ******** Revision-tracking helpers ******** + + /** + * Determine if there are any pending revisions + * + * @return bool + */ + public function hasPendingRevisions() { + $revisions = $this->getRevisions(); + $currentRevision = $this->getCurrentRevision(); + + if (empty($revisions)) { + return FALSE; + } + if (empty($currentRevision)) { + return TRUE; + } + + return ($currentRevision < max($revisions)); + } + + /** + * Add any pending revisions to the queue + */ + public function enqueuePendingRevisions(CRM_Queue_Queue $queue) { + $this->queue = $queue; + + $currentRevision = $this->getCurrentRevision(); + foreach ($this->getRevisions() as $revision) { + if ($revision > $currentRevision) { + $title = ts('Upgrade %1 to revision %2', array( + 1 => $this->extensionName, + 2 => $revision, + )); + + // note: don't use addTask() because it sets weight=-1 + + $task = new CRM_Queue_Task( + array(get_class($this), '_queueAdapter'), + array('upgrade_' . $revision), + $title + ); + $this->queue->createItem($task); + + $task = new CRM_Queue_Task( + array(get_class($this), '_queueAdapter'), + array('setCurrentRevision', $revision), + $title + ); + $this->queue->createItem($task); + } + } + } + + /** + * Get a list of revisions + * + * @return array(revisionNumbers) sorted numerically + */ + public function getRevisions() { + if (! is_array($this->revisions)) { + $this->revisions = array(); + + $clazz = new ReflectionClass(get_class($this)); + $methods = $clazz->getMethods(); + foreach ($methods as $method) { + if (preg_match('/^upgrade_(.*)/', $method->name, $matches)) { + $this->revisions[] = $matches[1]; + } + } + sort($this->revisions, SORT_NUMERIC); + } + + return $this->revisions; + } + + public function getCurrentRevision() { + // return CRM_Core_BAO_Extension::getSchemaVersion($this->extensionName); + $key = $this->extensionName . ':version'; + return CRM_Core_BAO_Setting::getItem('Extension', $key); + } + + public function setCurrentRevision($revision) { + // We call this during hook_civicrm_install, but the underlying SQL + // UPDATE fails because the extension record hasn't been INSERTed yet. + // Instead, track revisions in our own namespace. + // CRM_Core_BAO_Extension::setSchemaVersion($this->extensionName, $revision); + + $key = $this->extensionName . ':version'; + CRM_Core_BAO_Setting::setItem($revision, 'Extension', $key); + return TRUE; + } + + // ******** Hook delegates ******** + + public function onInstall() { + $files = glob($this->extensionDir . '/sql/*_install.sql'); + if (is_array($files)) { + foreach ($files as $file) { + CRM_Utils_File::sourceSQLFile(CIVICRM_DSN, $file); + } + } + $files = glob($this->extensionDir . '/xml/*_install.xml'); + if (is_array($files)) { + foreach ($files as $file) { + $this->executeCustomDataFileByAbsPath($file); + } + } + if (is_callable(array($this, 'install'))) { + $this->install(); + } + $revisions = $this->getRevisions(); + if (!empty($revisions)) { + $this->setCurrentRevision(max($revisions)); + } + } + + public function onUninstall() { + if (is_callable(array($this, 'uninstall'))) { + $this->uninstall(); + } + $files = glob($this->extensionDir . '/sql/*_uninstall.sql'); + if (is_array($files)) { + foreach ($files as $file) { + CRM_Utils_File::sourceSQLFile(CIVICRM_DSN, $file); + } + } + $this->setCurrentRevision(NULL); + } + + public function onEnable() { + // stub for possible future use + if (is_callable(array($this, 'enable'))) { + $this->enable(); + } + } + + public function onDisable() { + // stub for possible future use + if (is_callable(array($this, 'disable'))) { + $this->disable(); + } + } + + public function onUpgrade($op, CRM_Queue_Queue $queue = NULL) { + switch($op) { + case 'check': + return array($this->hasPendingRevisions()); + case 'enqueue': + return $this->enqueuePendingRevisions($queue); + default: + } + } +} diff --git a/info.xml b/info.xml new file mode 100644 index 0000000..e6d4ea8 --- /dev/null +++ b/info.xml @@ -0,0 +1,21 @@ + + + wci + Widget Creation Interface + CiviCRM CMS independent Widget Creation Interface. + AGPL-3.0 + + Manoj K + manoj.k@zyxware.com + + 2014-09-04 + 1.0-alpha1 + alpha + + 4.2 + + This is a new, undeveloped module + + CRM/Wci + + diff --git a/sql/install.sql b/sql/install.sql new file mode 100644 index 0000000..28ca3b9 --- /dev/null +++ b/sql/install.sql @@ -0,0 +1 @@ +-- Installation mysql script \ No newline at end of file diff --git a/sql/uninstall.sql b/sql/uninstall.sql new file mode 100644 index 0000000..59ac57f --- /dev/null +++ b/sql/uninstall.sql @@ -0,0 +1 @@ +-- Un installation mysql script \ No newline at end of file diff --git a/templates/CRM/Wci/Form/CreateWidget.tpl b/templates/CRM/Wci/Form/CreateWidget.tpl new file mode 100644 index 0000000..c99bb1f --- /dev/null +++ b/templates/CRM/Wci/Form/CreateWidget.tpl @@ -0,0 +1,27 @@ +{* HEADER *} + +
+{include file="CRM/common/formButtons.tpl" location="top"} +
+ +{* FIELD EXAMPLE: OPTION 1 (AUTOMATIC LAYOUT) *} + +{foreach from=$elementNames item=elementName} +
+
{$form.$elementName.label}
+
{$form.$elementName.html}
+
+
+{/foreach} + +{* FIELD EXAMPLE: OPTION 2 (MANUAL LAYOUT) + +
+ {$form.favorite_color.label} + {$form.favorite_color.html} +
+ +{* FOOTER *} +
+{include file="CRM/common/formButtons.tpl" location="bottom"} +
diff --git a/templates/CRM/Wci/Page/WCIDashboard.tpl b/templates/CRM/Wci/Page/WCIDashboard.tpl new file mode 100644 index 0000000..d1ab2d0 --- /dev/null +++ b/templates/CRM/Wci/Page/WCIDashboard.tpl @@ -0,0 +1,7 @@ +

This new page is generated by CRM/Wci/Page/WCIDashboard.php

+ +{* Example: Display a variable directly *} +

The current time is {$currentTime}

+ +{* Example: Display a translated string -- which happens to include a variable *} +

{ts 1=$currentTime}(In your native language) The current time is %1.{/ts}

diff --git a/wci.civix.php b/wci.civix.php new file mode 100644 index 0000000..17361db --- /dev/null +++ b/wci.civix.php @@ -0,0 +1,278 @@ +template_dir ) ) { + array_unshift( $template->template_dir, $extDir ); + } else { + $template->template_dir = array( $extDir, $template->template_dir ); + } + + $include_path = $extRoot . PATH_SEPARATOR . get_include_path( ); + set_include_path( $include_path ); +} + +/** + * (Delegated) Implementation of hook_civicrm_xmlMenu + * + * @param $files array(string) + * @link http://wiki.civicrm.org/confluence/display/CRMDOC/hook_civicrm_xmlMenu + */ +function _wci_civix_civicrm_xmlMenu(&$files) { + foreach (_wci_civix_glob(__DIR__ . '/xml/Menu/*.xml') as $file) { + $files[] = $file; + } +} + +/** + * Implementation of hook_civicrm_install + * + * @link http://wiki.civicrm.org/confluence/display/CRMDOC/hook_civicrm_install + */ +function _wci_civix_civicrm_install() { + _wci_civix_civicrm_config(); + if ($upgrader = _wci_civix_upgrader()) { + return $upgrader->onInstall(); + } +} + +/** + * Implementation of hook_civicrm_uninstall + * + * @link http://wiki.civicrm.org/confluence/display/CRMDOC/hook_civicrm_uninstall + */ +function _wci_civix_civicrm_uninstall() { + _wci_civix_civicrm_config(); + if ($upgrader = _wci_civix_upgrader()) { + return $upgrader->onUninstall(); + } +} + +/** + * (Delegated) Implementation of hook_civicrm_enable + * + * @link http://wiki.civicrm.org/confluence/display/CRMDOC/hook_civicrm_enable + */ +function _wci_civix_civicrm_enable() { + _wci_civix_civicrm_config(); + if ($upgrader = _wci_civix_upgrader()) { + if (is_callable(array($upgrader, 'onEnable'))) { + return $upgrader->onEnable(); + } + } +} + +/** + * (Delegated) Implementation of hook_civicrm_disable + * + * @link http://wiki.civicrm.org/confluence/display/CRMDOC/hook_civicrm_disable + */ +function _wci_civix_civicrm_disable() { + _wci_civix_civicrm_config(); + if ($upgrader = _wci_civix_upgrader()) { + if (is_callable(array($upgrader, 'onDisable'))) { + return $upgrader->onDisable(); + } + } +} + +/** + * (Delegated) Implementation of hook_civicrm_upgrade + * + * @param $op string, the type of operation being performed; 'check' or 'enqueue' + * @param $queue CRM_Queue_Queue, (for 'enqueue') the modifiable list of pending up upgrade tasks + * + * @return mixed based on op. for 'check', returns array(boolean) (TRUE if upgrades are pending) + * for 'enqueue', returns void + * + * @link http://wiki.civicrm.org/confluence/display/CRMDOC/hook_civicrm_upgrade + */ +function _wci_civix_civicrm_upgrade($op, CRM_Queue_Queue $queue = NULL) { + if ($upgrader = _wci_civix_upgrader()) { + return $upgrader->onUpgrade($op, $queue); + } +} + +/** + * @return CRM_Wci_Upgrader + */ +function _wci_civix_upgrader() { + if (!file_exists(__DIR__.'/CRM/Wci/Upgrader.php')) { + return NULL; + } else { + return CRM_Wci_Upgrader_Base::instance(); + } +} + +/** + * Search directory tree for files which match a glob pattern + * + * Note: Dot-directories (like "..", ".git", or ".svn") will be ignored. + * Note: In Civi 4.3+, delegate to CRM_Utils_File::findFiles() + * + * @param $dir string, base dir + * @param $pattern string, glob pattern, eg "*.txt" + * @return array(string) + */ +function _wci_civix_find_files($dir, $pattern) { + if (is_callable(array('CRM_Utils_File', 'findFiles'))) { + return CRM_Utils_File::findFiles($dir, $pattern); + } + + $todos = array($dir); + $result = array(); + while (!empty($todos)) { + $subdir = array_shift($todos); + foreach (_wci_civix_glob("$subdir/$pattern") as $match) { + if (!is_dir($match)) { + $result[] = $match; + } + } + if ($dh = opendir($subdir)) { + while (FALSE !== ($entry = readdir($dh))) { + $path = $subdir . DIRECTORY_SEPARATOR . $entry; + if ($entry{0} == '.') { + } elseif (is_dir($path)) { + $todos[] = $path; + } + } + closedir($dh); + } + } + return $result; +} +/** + * (Delegated) Implementation of hook_civicrm_managed + * + * Find any *.mgd.php files, merge their content, and return. + * + * @link http://wiki.civicrm.org/confluence/display/CRMDOC/hook_civicrm_managed + */ +function _wci_civix_civicrm_managed(&$entities) { + $mgdFiles = _wci_civix_find_files(__DIR__, '*.mgd.php'); + foreach ($mgdFiles as $file) { + $es = include $file; + foreach ($es as $e) { + if (empty($e['module'])) { + $e['module'] = 'civicrm-wci'; + } + $entities[] = $e; + } + } +} + +/** + * (Delegated) Implementation of hook_civicrm_caseTypes + * + * Find any and return any files matching "xml/case/*.xml" + * + * Note: This hook only runs in CiviCRM 4.4+. + * + * @link http://wiki.civicrm.org/confluence/display/CRMDOC/hook_civicrm_caseTypes + */ +function _wci_civix_civicrm_caseTypes(&$caseTypes) { + if (!is_dir(__DIR__ . '/xml/case')) { + return; + } + + foreach (_wci_civix_glob(__DIR__ . '/xml/case/*.xml') as $file) { + $name = preg_replace('/\.xml$/', '', basename($file)); + if ($name != CRM_Case_XMLProcessor::mungeCaseType($name)) { + $errorMessage = sprintf("Case-type file name is malformed (%s vs %s)", $name, CRM_Case_XMLProcessor::mungeCaseType($name)); + CRM_Core_Error::fatal($errorMessage); + // throw new CRM_Core_Exception($errorMessage); + } + $caseTypes[$name] = array( + 'module' => 'civicrm-wci', + 'name' => $name, + 'file' => $file, + ); + } +} + +/** + * Glob wrapper which is guaranteed to return an array. + * + * The documentation for glob() says, "On some systems it is impossible to + * distinguish between empty match and an error." Anecdotally, the return + * result for an empty match is sometimes array() and sometimes FALSE. + * This wrapper provides consistency. + * + * @link http://php.net/glob + * @param string $pattern + * @return array, possibly empty + */ +function _wci_civix_glob($pattern) { + $result = glob($pattern); + return is_array($result) ? $result : array(); +} + +/** + * Inserts a navigation menu item at a given place in the hierarchy + * + * $menu - menu hierarchy + * $path - path where insertion should happen (ie. Administer/System Settings) + * $item - menu you need to insert (parent/child attributes will be filled for you) + * $parentId - used internally to recurse in the menu structure + */ +function _wci_civix_insert_navigation_menu(&$menu, $path, $item, $parentId = NULL) { + static $navId; + + // If we are done going down the path, insert menu + if (empty($path)) { + if (!$navId) $navId = CRM_Core_DAO::singleValueQuery("SELECT max(id) FROM civicrm_navigation"); + $navId ++; + $menu[$navId] = array ( + 'attributes' => array_merge($item, array( + 'label' => CRM_Utils_Array::value('name', $item), + 'active' => 1, + 'parentID' => $parentId, + 'navID' => $navId, + )) + ); + return true; + } else { + // Find an recurse into the next level down + $found = false; + $path = explode('/', $path); + $first = array_shift($path); + foreach ($menu as $key => &$entry) { + if ($entry['attributes']['name'] == $first) { + if (!$entry['child']) $entry['child'] = array(); + $found = _wci_civix_insert_navigation_menu($entry['child'], implode('/', $path), $item, $key); + } + } + return $found; + } +} + +/** + * (Delegated) Implementation of hook_civicrm_alterSettingsFolders + * + * @link http://wiki.civicrm.org/confluence/display/CRMDOC/hook_civicrm_alterSettingsFolders + */ +function _wci_civix_civicrm_alterSettingsFolders(&$metaDataFolders = NULL) { + static $configured = FALSE; + if ($configured) return; + $configured = TRUE; + + $settingsDir = __DIR__ . DIRECTORY_SEPARATOR . 'settings'; + if(is_dir($settingsDir) && !in_array($settingsDir, $metaDataFolders)) { + $metaDataFolders[] = $settingsDir; + } +} \ No newline at end of file diff --git a/wci.php b/wci.php new file mode 100644 index 0000000..c7cf938 --- /dev/null +++ b/wci.php @@ -0,0 +1,108 @@ + + + + civicrm/wci + CRM_Wci_Page_WCIDashboard + WCIDashboard + access CiviCRM + + + civicrm/wci/create-widget + CRM_Wci_Form_CreateWidget + CreateWidget + access CiviCRM + + -- 2.25.1