From 0b960266025fffc13a6f090bd7ed98d3da0f9b8a Mon Sep 17 00:00:00 2001 From: Rich Lott / Artful Robot Date: Wed, 28 Feb 2024 13:00:56 +0000 Subject: [PATCH] lots --- CRM/Contactcats/DAO/ContactCategory.php | 14 +- CRM/Contactcats/Page/Settings.php | 17 +++ Civi/Api4/Action/ContactCategory/Sync.php | 118 +++++++++++---- Civi/Api4/ContactCategory.php | 2 +- ang/crmContactcats.ang.php | 23 +++ ang/crmContactcats.css | 18 +++ ang/crmContactcats.js | 134 ++++++++++++++++++ .../crmContactCategorySettings.html | 35 +++++ contactcats.php | 17 +++ info.xml | 2 + managed/activity-type-changed-cc.mgd.php | 27 ++++ managed/options.mgd.php | 50 +++++++ settings/contactcategory.setting.php | 15 ++ sql/auto_install.sql | 3 +- templates/CRM/Contactcats/Page/Settings.tpl | 3 + xml/Menu/contactcats.xml | 9 ++ .../CRM/Contactcats/ContactCategory.xml | 9 +- 17 files changed, 458 insertions(+), 38 deletions(-) create mode 100644 CRM/Contactcats/Page/Settings.php create mode 100644 ang/crmContactcats.ang.php create mode 100644 ang/crmContactcats.css create mode 100644 ang/crmContactcats.js create mode 100644 ang/crmContactcats/crmContactCategorySettings.html create mode 100644 managed/activity-type-changed-cc.mgd.php create mode 100644 managed/options.mgd.php create mode 100644 settings/contactcategory.setting.php create mode 100644 templates/CRM/Contactcats/Page/Settings.tpl create mode 100644 xml/Menu/contactcats.xml diff --git a/CRM/Contactcats/DAO/ContactCategory.php b/CRM/Contactcats/DAO/ContactCategory.php index 292a953..2d29e2e 100644 --- a/CRM/Contactcats/DAO/ContactCategory.php +++ b/CRM/Contactcats/DAO/ContactCategory.php @@ -6,7 +6,7 @@ * * Generated from contactcats/xml/schema/CRM/Contactcats/ContactCategory.xml * DO NOT EDIT. Generated by CRM_Core_CodeGen - * (GenCodeChecksum:c8054b52817309c4eb67221623e1c29e) + * (GenCodeChecksum:01a6344ef3d86c080e4362dafa008d13) */ use CRM_Contactcats_ExtensionUtil as E; @@ -97,8 +97,10 @@ class CRM_Contactcats_DAO_ContactCategory extends CRM_Core_DAO { 'entity' => 'ContactCategory', 'bao' => 'CRM_Contactcats_DAO_ContactCategory', 'localizable' => 0, + 'FKClassName' => 'CRM_Contact_DAO_Contact', 'html' => [ - 'type' => 'Number', + 'type' => 'EntityRef', + 'label' => E::ts("Contact"), ], 'readonly' => TRUE, 'add' => NULL, @@ -124,8 +126,8 @@ class CRM_Contactcats_DAO_ContactCategory extends CRM_Core_DAO { 'type' => 'Select', ], 'pseudoconstant' => [ - 'optionGroupName' => 'ContactCategories', - 'optionEditPath' => 'civicrm/admin/options/ContactCategories', + 'optionGroupName' => 'contact_categories', + 'optionEditPath' => 'civicrm/admin/options/contact_categories', ], 'add' => NULL, ], @@ -147,8 +149,8 @@ class CRM_Contactcats_DAO_ContactCategory extends CRM_Core_DAO { 'bao' => 'CRM_Contactcats_DAO_ContactCategory', 'localizable' => 0, 'pseudoconstant' => [ - 'optionGroupName' => 'ContactCategories', - 'optionEditPath' => 'civicrm/admin/options/ContactCategories', + 'optionGroupName' => 'contact_categories', + 'optionEditPath' => 'civicrm/admin/options/contact_categories', ], 'add' => NULL, ], diff --git a/CRM/Contactcats/Page/Settings.php b/CRM/Contactcats/Page/Settings.php new file mode 100644 index 0000000..2069dfc --- /dev/null +++ b/CRM/Contactcats/Page/Settings.php @@ -0,0 +1,17 @@ +assign('currentTime', date('Y-m-d H:i:s')); + + Civi::service('angularjs.loader')->addModules('crmContactcats'); + parent::run(); + } + +} diff --git a/Civi/Api4/Action/ContactCategory/Sync.php b/Civi/Api4/Action/ContactCategory/Sync.php index f0d0da9..0fde849 100644 --- a/Civi/Api4/Action/ContactCategory/Sync.php +++ b/Civi/Api4/Action/ContactCategory/Sync.php @@ -2,9 +2,11 @@ namespace Civi\Api4\Action\ContactCategory; use Civi; -use Civi\Api4\Generic\AbstractAction; +use Civi\Api4\Activity; use Civi\Api4\Generic\Result; use Civi\Api4\Group; +use Civi\Api4\OptionValue; +use Civi\Api4\Setting; use CRM_Core_DAO; /** @@ -12,56 +14,120 @@ use CRM_Core_DAO; * * @see \Civi\Api4\Generic\AbstractAction * - * @package ContactCategory */ class Sync extends \Civi\Api4\Generic\AbstractAction { public function _run(Result $result) { - $map = [ - ['groupID' => 5, 'name' => 'Nice', 'categoryID' => 2], - ]; + $settings = Civi::settings()->get('contact_categories'); + $settings = Setting::get(FALSE) + ->addSelect('contact_categories') + ->execute()->first()['value'] ?? NULL; + if (empty($settings['groupIDs'])) { + throw new \API_Exception('Unconfigured'); + } + if ($settings['updateAfter'] > time()) { + // not needed yet. + return; + } + + // Load category names + $catNames = OptionValue::get(FALSE) + ->addWhere('option_group_id:name', '=', 'contact_categories') + ->addSelect('value', 'label') + ->execute()->indexBy('value')->column('label'); + + $groups = Group::get(FALSE) + ->addWhere('id', 'IN', $settings['groupIDs']) + ->execute()->indexBy('id')->getArrayCopy(); + + $smartGroups = []; + foreach ($groups as $groupID => $group) { + if (!empty($group['saved_search_id'])) { + $smartGroups[] = $groupID; + } + } + if ($smartGroups) { + \CRM_Contact_BAO_GroupContactCache::loadAll($smartGroups); + } // clear out temp space. - CRM_Core_DAO::executeQuery("UPDATE civicrm_contactcategory SET next_category = 1;"); + CRM_Core_DAO::executeQuery("UPDATE civicrm_contact_category SET next_category = 0;"); // ensure we have all our contacts covered. CRM_Core_DAO::executeQuery(<<addWhere('id', '=', $row['groupID'])->execute()->first(); - if (!$group) { - Civi::warning("Group $row[groupID] used for category $row[name] no longer exists."); + foreach ($settings['groupIDs'] as $groupID) { + if ($groupID == 0) { + // The default 'group' isn't a ... group. continue; } - $isSmart = !empty($group['saved_search_id']); - + // Get contacts in group. + if (!$groups[$groupID]) { + Civi::log()->warning("Group $groupID no longer exists."); + continue; + } + $isSmart = !empty($groups[$groupID]['saved_search_id']); if (!$isSmart) { $sql = << category + ORDER BY category, next_category + SQL); + $lastChange = [0, 0]; + $batch = []; + $n = 0; + $writeBatch = function() use (&$lastChange, &$batch, $catNames) { + if (empty($batch)) { + return; + } + // Create activity + Activity::create(FALSE) + ->addValue('target_contact_id', $batch) + ->addValue('activity_type_id:name', 'changed_contact_category') + ->addValue('source_contact_id', \CRM_Core_BAO_Domain::getDomain()->contact_id) + ->addValue('status_id:name', 'Completed') + ->addValue('subject', $catNames[$lastChange[0] ?? 0] . ' → ' . $catNames[$lastChange[0] ?? 0]) + ->execute(); + }; + while ($changes->fetch()) { + $n++; + if ($lastChange[0] !== $changes->category || $lastChange[1] !== $changes->next_category) { + $writeBatch(); + $lastChange = [$changes->category, $changes->next_category]; + $batch = []; + } + $batch[] = $changes->id; + } + $writeBatch(); + CRM_Core_DAO::executeQuery("UPDATE civicrm_contact_category + SET category = next_category WHERE category <> next_category"); + + $summary = CRM_Core_DAO::executeQuery("SELECT next_category, count(*) from civicrm_contact_category group by next_category")->fetchAll(); + $summary['changes'] = $n; + $result->exchangeArray($summary); } } diff --git a/Civi/Api4/ContactCategory.php b/Civi/Api4/ContactCategory.php index c7fbc2c..46c3bde 100644 --- a/Civi/Api4/ContactCategory.php +++ b/Civi/Api4/ContactCategory.php @@ -15,7 +15,7 @@ class ContactCategory extends Generic\DAOEntity { /** * */ - public function sync(): Sync { + public static function sync(): Sync { return new Sync('ContactCategory', 'sync'); } diff --git a/ang/crmContactcats.ang.php b/ang/crmContactcats.ang.php new file mode 100644 index 0000000..b35d1e7 --- /dev/null +++ b/ang/crmContactcats.ang.php @@ -0,0 +1,23 @@ + [ + 'ang/crmContactcats.js', + 'ang/crmContactcats/*.js', + 'ang/crmContactcats/*/*.js', + ], + 'css' => [ + 'ang/crmContactcats.css', + ], + 'partials' => [ + 'ang/crmContactcats', + ], + 'requires' => [ + 'crmUi', + 'crmUtil', + 'ngRoute', + 'api4', + ], + 'settings' => [], +]; diff --git a/ang/crmContactcats.css b/ang/crmContactcats.css new file mode 100644 index 0000000..c96a34f --- /dev/null +++ b/ang/crmContactcats.css @@ -0,0 +1,18 @@ +/* Add any CSS rules for Angular module "crmContactcats" */ +crm-contact-category-settings ol { + padding:0; +} +crm-contact-category-settings ol li { + display: flex; + padding:0.5em 0; + gap: 1em; +} +crm-contact-category-settings .name-input-wrapper { + flex: 0 1 29ch; +} +crm-contact-category-settings .hint { + display:none; +} +crm-contact-category-settings .name-input-wrapper:focus-within .hint { + display:block; +} diff --git a/ang/crmContactcats.js b/ang/crmContactcats.js new file mode 100644 index 0000000..5297715 --- /dev/null +++ b/ang/crmContactcats.js @@ -0,0 +1,134 @@ +(function(angular, $, _) { + // Declare a list of dependencies. + angular.module('crmContactcats', CRM.angRequires('crmContactcats')); + angular.module('crmContactcats').component('crmContactCategorySettings', { + templateUrl: '~/crmContactcats/crmContactCategorySettings.html', + bindings: { + // things listed here become properties on the controller using + // values from attributes. @ means a fixed string is passed + // e.g. + // token: '@', + // & is special. In the child, call ctrl.onMyAction with an + // Object whose keys provide parameter names for the code in the parent. + // onMyAction: '&', + // '<' means one-way binding (parent»child) and I think is what you are + // supposed to use for components. + // v: '<' + }, + controller: function($scope, $timeout, crmApi4, $document) { + var ts = $scope.ts = CRM.ts(null), + ctrl = this; + + // this.$onInit gets run after the this controller is called, and after the bindings have been applied. + this.$onInit = async function() { + ctrl.saved = false; + const various = await crmApi4({ + settings: ['Setting', 'get', { select: ["contact_categories"] }, 0], + groups: ['Group', 'get', { + where: [['is_active', '=', 1], ['is_hidden', '=', 0]], + orderBy: { "name": "ASC" } + }], + cats: ['OptionValue', 'get', { + select: ['value', "label"], + where: [["option_group_id:name", "=", "contact_categories"]], + }] + }); + ctrl.catmap = []; + if (!various.settings.value || !various.settings.value.groupIDs) { + various.settings.value = { + groupIDs: ["0"], + updateAfter: 0, + }; + }; + various.settings.value.groupIDs.forEach(groupID => { + let cat = various.cats.find(c => c.value == groupID); + ctrl.catmap.push({ + groupID, + name: cat ? cat.label : '', + }); + }); + console.log({ various, catmap: ctrl.catmap }); + ctrl.groups = various.groups; + ctrl.nameKeydown = (keyEvt, idx) => { + if (keyEvt.key === 'ArrowUp' || keyEvt.key === 'ArrowDown') { + keyEvt.preventDefault(); + keyEvt.stopPropagation(); + if (keyEvt.key === 'ArrowUp' && idx > 0 && idx < ctrl.catmap.length - 1) { + ctrl.catmap.splice(idx, 0, ...ctrl.catmap.splice(idx - 1, 1)); + console.log('up', { keyEvt }); + $timeout(() => keyEvt.target.focus(), 10); + } + else if (keyEvt.key === 'ArrowDown' && idx < ctrl.catmap.length - 2) { + ctrl.catmap.splice(idx + 1, 0, ...ctrl.catmap.splice(idx, 1)); + console.log('down', ctrl.catmap.map(e => e.name)); + } + } + }; + $scope.$digest(); + ctrl.deleteRow = (idx) => { + ctrl.catmap.splice(idx, 1); + }; + ctrl.getGroupsFor = (idx) => { + let groupsInUse = ctrl.catmap.map(c => c.groupID); + groupsInUse.splice(idx, 1); + return ctrl.groups.filter(g => !groupsInUse.includes(g.id.toString())); + }; + + ctrl.save = async () => { + console.log("save"); + // reconstruct everything. + + const optValsRecords = []; + let isInvalid = false; + ctrl.catmap.forEach(r => { + if (!(r.name) || !r.groupID) { + return; + } + // Do we have an option value for this group ID? + let c = various.cats.find(cat => cat.value == r.groupID); + if (c) { + if (c.label != r.name) { + optValsRecords.push({ + id: c.id, + label: r.name, + }); + } + } + else { + optValsRecords.push({ + label: r.name, + value: r.groupID, + "option_group_id:name": "contact_categories" + }); + } + }); + console.log("optionValue updates", optValsRecords, ctrl.catmap); + const updates = { + saveSetting: ['Setting', 'set', { + values: { + contact_categories: + { + groupIDs: ctrl.catmap.map(i => i.groupID), + updateAfter: 0 + } + } + }], + }; + if (optValsRecords.length) { + updates.saveOptions = ['OptionValue', 'save', { records: optValsRecords }]; + } + await crmApi4(updates); + console.log("saved", updates); + ctrl.saved = true; + $scope.$digest(); + }; + }; + // this.$onChange = function(changes) { + // // changes is object keyed by name what '<' binding changed in + // // the parent (e.g. 'v'), and value is another obj with keys + // // something like previous, new, ...?... + // }; + + } + }); +})(angular, CRM.$, CRM._); diff --git a/ang/crmContactcats/crmContactCategorySettings.html b/ang/crmContactcats/crmContactCategorySettings.html new file mode 100644 index 0000000..b4c596c --- /dev/null +++ b/ang/crmContactcats/crmContactCategorySettings.html @@ -0,0 +1,35 @@ +
+ Loading... +
+
+ +
    +
  1. + +
    + Default +
    +
    + + +
    + {{ts('Use the Up/Down arrow keys to re-order')}} +
    +
    +
    + +
    +
  2. +
+

+ +

+
Categories saved. Contacts will be updated shortly (when the next Scheduled + Job run + happens).
+
diff --git a/contactcats.php b/contactcats.php index c7c9b69..abcb002 100644 --- a/contactcats.php +++ b/contactcats.php @@ -30,3 +30,20 @@ function contactcats_civicrm_install(): void { function contactcats_civicrm_enable(): void { _contactcats_civix_civicrm_enable(); } + +/** + * Implements hook_civicrm_navigationMenu(). + * + * @link https://docs.civicrm.org/dev/en/latest/hooks/hook_civicrm_navigationMenu + */ +function contactcats_civicrm_navigationMenu(&$menu) { + _contactcats_civix_insert_navigation_menu($menu, 'Contacts', [ + 'label' => E::ts('Categories'), + 'name' => 'contact_categories', + 'url' => 'civicrm/admin/contactcategory', + 'permission' => 'edit all contacts', + 'operator' => 'OR', + 'separator' => 0, + ]); + _contactcats_civix_navigationMenu($menu); +} diff --git a/info.xml b/info.xml index 1104129..6cf0d24 100644 --- a/info.xml +++ b/info.xml @@ -37,6 +37,8 @@ smarty-v2@1.0.1 entity-types-php@1.0.0 mgd-php@1.0.0 + ang-php@1.0.0 + menu-xml@1.0.0 CRM_Contactcats_Upgrader diff --git a/managed/activity-type-changed-cc.mgd.php b/managed/activity-type-changed-cc.mgd.php new file mode 100644 index 0000000..083fa08 --- /dev/null +++ b/managed/activity-type-changed-cc.mgd.php @@ -0,0 +1,27 @@ + 'OptionValue_Changed_Contact_Category', + 'entity' => 'OptionValue', + 'cleanup' => 'unused', + 'update' => 'unmodified', + 'params' => [ + 'version' => 4, + 'values' => [ + 'option_group_id.name' => 'activity_type', + 'label' => E::ts('Changed Contact Category'), + 'value' => '57', + 'name' => 'changed_contact_category', + 'weight' => 57, + 'icon' => 'fa-tags', + ], + 'match' => [ + 'option_group_id', + 'name', + 'value', + ], + ], + ], +]; diff --git a/managed/options.mgd.php b/managed/options.mgd.php new file mode 100644 index 0000000..c026092 --- /dev/null +++ b/managed/options.mgd.php @@ -0,0 +1,50 @@ + 'OptionGroup_contact_categories', + 'entity' => 'OptionGroup', + 'cleanup' => 'unused', + 'update' => 'unmodified', + 'params' => [ + 'version' => 4, + 'values' => [ + 'name' => 'contact_categories', + 'title' => E::ts('Contact Categories'), + 'data_type' => 'Integer', + 'is_reserved' => FALSE, + 'is_active' => TRUE, + 'option_value_fields' => [ + 'name', + 'label', + 'description', + ], + ], + 'match' => [ + 'name', + ], + ], + ], + [ + 'name' => 'OptionGroup_contact_categories_OptionValue_Default', + 'entity' => 'OptionValue', + 'cleanup' => 'unused', + 'update' => 'unmodified', + 'params' => [ + 'version' => 4, + 'values' => [ + 'option_group_id.name' => 'contact_categories', + 'label' => E::ts('Default'), + 'is_active' => TRUE, + 'value' => '0', + 'name' => 'default', + ], + 'match' => [ + 'option_group_id', + 'name', + 'value', + ], + ], + ], +]; diff --git a/settings/contactcategory.setting.php b/settings/contactcategory.setting.php new file mode 100644 index 0000000..65ddf91 --- /dev/null +++ b/settings/contactcategory.setting.php @@ -0,0 +1,15 @@ + [ + 'name' => 'contact_categories', + 'title' => ts('Contact Category settings'), + 'description' => ts('JSON encoded settings.'), + 'group_name' => 'domain', + 'type' => 'String', + 'serialize' => CRM_Core_DAO::SERIALIZE_JSON, + 'default' => FALSE, + 'add' => '5.70', + 'is_domain' => 1, + 'is_contact' => 0, + ], +]; diff --git a/sql/auto_install.sql b/sql/auto_install.sql index e4a36d6..cd3b36e 100644 --- a/sql/auto_install.sql +++ b/sql/auto_install.sql @@ -37,6 +37,7 @@ CREATE TABLE `civicrm_contact_category` ( `id` int unsigned NOT NULL AUTO_INCREMENT COMMENT 'Unique ID, corresponds to contact id', `category` int unsigned NOT NULL DEFAULT 0, `next_category` int unsigned NOT NULL DEFAULT 0, - PRIMARY KEY (`id`) + PRIMARY KEY (`id`), + CONSTRAINT FK_civicrm_contact_category_id FOREIGN KEY (`id`) REFERENCES `civicrm_contact`(`id`) ON DELETE CASCADE ) ENGINE=InnoDB; diff --git a/templates/CRM/Contactcats/Page/Settings.tpl b/templates/CRM/Contactcats/Page/Settings.tpl new file mode 100644 index 0000000..79e0a56 --- /dev/null +++ b/templates/CRM/Contactcats/Page/Settings.tpl @@ -0,0 +1,3 @@ + + + diff --git a/xml/Menu/contactcats.xml b/xml/Menu/contactcats.xml new file mode 100644 index 0000000..9507849 --- /dev/null +++ b/xml/Menu/contactcats.xml @@ -0,0 +1,9 @@ + + + + civicrm/admin/contactcategory + CRM_Contactcats_Page_Settings + Settings + access CiviCRM + + diff --git a/xml/schema/CRM/Contactcats/ContactCategory.xml b/xml/schema/CRM/Contactcats/ContactCategory.xml index 05e9a9e..e2a4b8a 100644 --- a/xml/schema/CRM/Contactcats/ContactCategory.xml +++ b/xml/schema/CRM/Contactcats/ContactCategory.xml @@ -13,7 +13,8 @@ true Unique ID, corresponds to contact id - Number + EntityRef + @@ -21,7 +22,7 @@ true - contact_id + id civicrm_contact
id CASCADE @@ -33,7 +34,7 @@ true 0 - ContactCategories + contact_categories Select @@ -46,7 +47,7 @@ true 0 - ContactCategories + contact_categories