This commit is contained in:
Rich Lott / Artful Robot 2024-02-28 13:00:56 +00:00
parent 3b393be4f4
commit 0b96026602
17 changed files with 458 additions and 38 deletions

View file

@ -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,
],

View file

@ -0,0 +1,17 @@
<?php
use CRM_Contactcats_ExtensionUtil as E;
class CRM_Contactcats_Page_Settings extends CRM_Core_Page {
public function run() {
// Example: Set the page-title dynamically; alternatively, declare a static title in xml/Menu/*.xml
CRM_Utils_System::setTitle(E::ts('Contact Category Settings'));
// Example: Assign a variable for use in a template
// $this->assign('currentTime', date('Y-m-d H:i:s'));
Civi::service('angularjs.loader')->addModules('crmContactcats');
parent::run();
}
}

View file

@ -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(<<<SQL
INSERT IGNORE INTO civicrm_contactcategory
SELECT id, 1 as category, 2 next_category
INSERT IGNORE INTO civicrm_contact_category
SELECT id, 0 as category, 0 next_category
FROM civicrm_contact
SQL);
foreach ($map as $row) {
// Get contacts in group.
$cat = (int) $row['categoryID'];
$row['groupID'] = (int) $row['groupID'];
$group = Group::get(FALSE)->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 = <<<SQL
UPDATE civicrm_contactcategory cc
INNER JOIN civicrm_group_contact gc ON gc.contact_id = cc.id AND gc.status = 'Added' AND group_id = $row[groupID]
SET next_category = $cat
WHERE next_category = 1
UPDATE civicrm_contact_category cc
INNER JOIN civicrm_group_contact gc ON gc.contact_id = cc.id AND gc.status = 'Added' AND group_id = $groupID
SET next_category = $groupID
WHERE next_category = 0
SQL;
}
else {
// @todo refresh cache
$sql = <<<SQL
UPDATE civicrm_contactcategory cc
INNER JOIN civicrm_group_contact_cache gcc ON gcc.contact_id = cc.id AND group_id = $row[groupID]
SET next_category = $cat
WHERE next_category = 1
UPDATE civicrm_contact_category cc
INNER JOIN civicrm_group_contact_cache gcc ON gcc.contact_id = cc.id AND group_id = $groupID
SET next_category = $groupID
WHERE next_category = 0
SQL;
}
CRM_Core_DAO::executeQuery($sql);
}
$changes = CRM_Core_DAO::executeQuery(<<<SQL
SELECT id, category, next_category
FROM civicrm_contact_category
WHERE next_category <> 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);
}
}

View file

@ -15,7 +15,7 @@ class ContactCategory extends Generic\DAOEntity {
/**
*
*/
public function sync(): Sync {
public static function sync(): Sync {
return new Sync('ContactCategory', 'sync');
}

View file

@ -0,0 +1,23 @@
<?php
// Angular module crmContactcats.
// @see https://docs.civicrm.org/dev/en/latest/hooks/hook_civicrm_angularModules
return [
'js' => [
'ang/crmContactcats.js',
'ang/crmContactcats/*.js',
'ang/crmContactcats/*/*.js',
],
'css' => [
'ang/crmContactcats.css',
],
'partials' => [
'ang/crmContactcats',
],
'requires' => [
'crmUi',
'crmUtil',
'ngRoute',
'api4',
],
'settings' => [],
];

18
ang/crmContactcats.css Normal file
View file

@ -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;
}

134
ang/crmContactcats.js Normal file
View file

@ -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. <crm-reset-password token='foo' />
// 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._);

View file

@ -0,0 +1,35 @@
<div ng-if="!$ctrl.catmap">
Loading...
</div>
<form ng-if="$ctrl.catmap" crm-ui-id-scope>
<button ng-click="$ctrl.catmap.unshift({groupID: '', name:''})"><i class="crm-i fa-plus"></i> Add category</button>
<ol class="crm-catmap">
<li ng-repeat="(idx, row) in $ctrl.catmap">
<select ng-if="row.groupID === '' || row.groupID > 0"
crm-ui-select="{placeholder:'Select group',allowClear:false}" ng-model="$ctrl.catmap[idx].groupID"
style="width: 18rem">
<option ng-repeat="(grpIdx, grp) in $ctrl.getGroupsFor(idx)" value="{{grp.id}}">{{grp.title}}</option>
</select>
<div ng-if="row.groupID !== '' && row.groupID == 0" style="width: 18rem;display: inline-block;">
Default
</div>
<div class=name-input-wrapper>
<label crm-ui-for="name{{idx}}">Label</label>
<input crm-ui-id="name{{idx}}" type=text ng-model="$ctrl.catmap[idx].name"
ng-keydown="$ctrl.nameKeydown($event, idx)" />
<div class="hint description">
{{ts('Use the Up/Down arrow keys to re-order')}}
</div>
</div>
<div>
<button ng-click="$ctrl.deleteRow(idx)"><i class="fa-trash crm-i"></i> Delete</button>
</div>
</li>
</ol>
<p>
<button ng-click="$ctrl.save()"><i class="crm-i fa-save"></i> Save</button>
</p>
<div ng-if="$ctrl.saved" class="help">Categories saved. Contacts will be updated shortly (when the next Scheduled
Job run
happens).</div>
</form>

View file

@ -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);
}

View file

@ -37,6 +37,8 @@
<mixin>smarty-v2@1.0.1</mixin>
<mixin>entity-types-php@1.0.0</mixin>
<mixin>mgd-php@1.0.0</mixin>
<mixin>ang-php@1.0.0</mixin>
<mixin>menu-xml@1.0.0</mixin>
</mixins>
<upgrader>CRM_Contactcats_Upgrader</upgrader>
</extension>

View file

@ -0,0 +1,27 @@
<?php
use CRM_Contactcats_ExtensionUtil as E;
return [
[
'name' => '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',
],
],
],
];

50
managed/options.mgd.php Normal file
View file

@ -0,0 +1,50 @@
<?php
use CRM_Contactcats_ExtensionUtil as E;
return [
[
'name' => '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',
],
],
],
];

View file

@ -0,0 +1,15 @@
<?php
return [
'contact_categories' => [
'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,
],
];

View file

@ -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;

View file

@ -0,0 +1,3 @@
<crm-angular-js modules="crmContactcats">
<crm-contact-category-settings ></crm-contact-category-settings>
</crm-angular-js>

9
xml/Menu/contactcats.xml Normal file
View file

@ -0,0 +1,9 @@
<?xml version="1.0"?>
<menu>
<item>
<path>civicrm/admin/contactcategory</path>
<page_callback>CRM_Contactcats_Page_Settings</page_callback>
<title>Settings</title>
<access_arguments>access CiviCRM</access_arguments>
</item>
</menu>

View file

@ -13,7 +13,8 @@
<required>true</required>
<comment>Unique ID, corresponds to contact id</comment>
<html>
<type>Number</type>
<type>EntityRef</type>
<label>Contact</label>
</html>
</field>
<primaryKey>
@ -21,7 +22,7 @@
<autoincrement>true</autoincrement>
</primaryKey>
<foreignKey>
<name>contact_id</name>
<name>id</name>
<table>civicrm_contact</table>
<key>id</key>
<onDelete>CASCADE</onDelete>
@ -33,7 +34,7 @@
<required>true</required>
<default>0</default>
<pseudoconstant>
<optionGroupName>ContactCategories</optionGroupName>
<optionGroupName>contact_categories</optionGroupName>
</pseudoconstant>
<html>
<type>Select</type>
@ -46,7 +47,7 @@
<required>true</required>
<default>0</default>
<pseudoconstant>
<optionGroupName>ContactCategories</optionGroupName>
<optionGroupName>contact_categories</optionGroupName>
</pseudoconstant>
</field>