Various UI improvements

This commit is contained in:
Rich Lott / Artful Robot 2025-02-28 12:37:58 +00:00
parent bcbf5de9c4
commit 8d0a9d0926
7 changed files with 253 additions and 123 deletions

View file

@ -7,6 +7,36 @@ use CRM_Contactcats_ExtensionUtil as E;
*/
class CRM_Contactcats_Upgrader extends CRM_Extension_Upgrader_Base {
/**
* Add presentation_order
*
* @return TRUE on success
* @throws CRM_Core_Exception
*/
public function upgrade_0001(): bool {
$this->ctx->log->info('Applying update 0001: add presentation_order');
CRM_Core_DAO::executeQuery(<<<SQL
ALTER TABLE civicrm_contact_category_definition
ADD `presentation_order` int(1) unsigned NOT NULL DEFAULT 10 COMMENT 'The order to present categories in, lowest number first.'
SQL);
return TRUE;
}
/**
* Add description field
*
* @return TRUE on success
* @throws CRM_Core_Exception
*/
public function upgrade_0002(): bool {
$this->ctx->log->info('Applying update 0001: add presentation_order');
CRM_Core_DAO::executeQuery(<<<SQL
ALTER TABLE civicrm_contact_category_definition
ADD `description` text DEFAULT NULL
SQL);
return TRUE;
}
// By convention, functions that look like "function upgrade_NNNN()" are
// upgrade tasks. They are executed in order (like Drupal's hook_update_N).

View file

@ -36,13 +36,30 @@ crm-contact-category-settings .panel {
margin-bottom: 0;
}
crm-contact-category-settings .panel-body {
display: flex;
box-sizing: border-box;
padding: 0.5em 0;
align-items: center;
justify-content: space-between;
gap: 1em;
display: grid;
gap: 1rem;
grid-template-columns:1fr;
grid-template-rows: auto auto auto;
}
.crm-container crm-contact-category-settings .panel-body {
padding: var(--crm-padding-small) var(--crm-padding-reg);
}
@media screen and (min-width: 800px) {
crm-contact-category-settings .panel-body {
grid-template-columns: minmax(16ch,max-content) 1fr auto;
grid-template-rows: 1fr;
align-items: center;
}
}
crm-contact-category-settings .contactcats-label {
}
crm-contact-category-settings .contactcats-description {
}
crm-contact-category-settings .contactcats-actions {
}
crm-contact-category-settings li label {
display: block;
}

View file

@ -23,54 +23,85 @@
this.$onInit = async function() {
ctrl.dirty = 'pristine'; //pristine|dirty
ctrl.view = 'list';
ctrl.uxOrderField = 'execution_order';
ctrl.moveIdx = null;
ctrl.categoryToEdit = null;
ctrl.categoryDefinitions = null;
ctrl.categoryDefinitions = await crmApi4("ContactCategoryDefinition", 'get', { orderBy: { execution_order: 'ASC' }, withLabels: true });
if (ctrl.categoryDefinitions.count === 0) {
// First time.
ctrl.categoryDefinitions = [];
}
// Ensure we have the minimum of what we need.
if (!ctrl.categoryDefinitions.find(c => c.search_type === 'default')) {
// There's no default one.
ctrl.categoryDefinitions.push({
ctrl.presentation_order = [];
ctrl.execution_order = [];
const blankDefn = {
id: 0,
execution_order: ctrl.categoryDefinitions.length,
label: '9999 ' + ts('Other'),
search_type: 'default',
execution_order: 10,
presentation_order: 10,
label:'',
search_type: 'search',
search_data: {},
color: '#666666',
icon: 'fa-circle-half-stroke',
description: '',
};
catDefs = await crmApi4("ContactCategoryDefinition", 'get', { orderBy: { execution_order: 'ASC' }, withLabels: true });
if (catDefs.count === 0) {
// First time.
catDefs = [];
}
// Ensure we have the minimum of what we need.
if (!catDefs.find(c => c.search_type === 'default')) {
// There's no default one.
catDefs.push(Object.assign({}, blankDefn, {
execution_order: catDefs.length,
presentation_order: catDefs.length,
label: ts('Default'),
search_type: 'default',
icon: 'fa-person-circle-question',
description: ts('Any contact not matching any of the categories is assigned this category.'),
})
}));
}
updateOrders();
console.log("Got", ctrl.categoryDefinitions);
// Ensure the fields are all present in the data.
catDefs.forEach((item, idx) => {
catDefs[idx] = Object.assign({}, blankDefn, item);
});
// Create ordered arrays.
['presentation_order', 'execution_order'].forEach(orderField => {
ctrl[orderField] = catDefs.slice();
ctrl[orderField].sort((a, b) => {
// console.log({orderField, aST: a.search_type, bST: b.search_type, aOrd:a[orderField], bOrd:b[orderField], aLab:a.label, bLab:b.label});
if (a.search_type === 'default' && b.search_type !== 'default') return 1;
if (b.search_type === 'default' && a.search_type !== 'default') return -1;
if (a[orderField] > b[orderField]) return 1;
if (a[orderField] < b[orderField]) return -1;
if (a.label < b.label) return 1;
if (a.label > b.label) return -1;
return 0;
});
});
updateOrders();
$scope.$digest();
// Functions follow.
/**
* Copy execution to presenation or vice versa.
*/
ctrl.copyOrder = (from) => {
ctrl[ctrl.uxOrderField] = ctrl[from].slice();
updateOrders();
};
/**
* Presentation order is by label. Promoting "0123 label" type labels.
*
* We set execution_order to the array index to keep things simpler.
*/
function updateOrders() {
// re-set execution_order.
ctrl.categoryDefinitions.forEach((item, idx) => {
item.execution_order = idx;
// Update execution_order or presentation_order field to match the specified order.
ctrl[ctrl.uxOrderField].forEach((item, idx) => {
item[ctrl.uxOrderField] = idx;
});
const po = ctrl.categoryDefinitions.slice();
po.sort((a, b) => {
if (a.label < b.label) return -1;
if (a.label > b.label) return 1;
return (a.execution_order ?? 1) - (b.execution_order ?? 1);
});
ctrl.presentationOrder = po;
}
/**
* Begin editing something
*/
ctrl.edit = idx => {
if (idx === -1) {
// New item.
@ -82,74 +113,47 @@
conv = (x) => ('0' + Math.ceil((x-min)/range * 128).toString(16)).replace(/^.*(..)$/, '$1');
// Create a blank
ctrl.categoryToEdit = {
id: 0,
ctrl.categoryToEdit = Object.assign({}, blankDefn, {
execution_order: -1,
label: '',
presentation_order: -1,
search_type: 'search',
search_data: { saved_search_id: null },
color: '#' + [r, g, b].map(conv).join(''),
icon: '',
description: '',
};
});
}
else {
ctrl.categoryToEdit = Object.assign({}, JSON.parse(JSON.stringify(ctrl.categoryDefinitions[idx])));
ctrl.categoryToEdit = Object.assign({}, JSON.parse(JSON.stringify(catDefs[idx])));
}
ctrl.view = 'edit';
};
ctrl.moveTo = idx => {
let item = ctrl.categoryDefinitions.splice(ctrl.moveIdx, 1)[0];
if (idx > ctrl.moveIdx) {
idx--;
}
ctrl.categoryDefinitions.splice(idx, 0, item);
ctrl.moveIdx = null;
updateOrders();
ctrl.dirty = 'dirty';
};
ctrl.deleteCategory = idx => {
if (!confirm(ts(
'Confirm deleting category %1. You will lose history related to this category. Sure?',
{1: ctrl.categoryDefinitions[idx].label}
))) {
return;
}
ctrl.categoryDefinitions[idx].deleted = true;
ctrl.categoryToEdit = null;
updateOrders();
ctrl.view = 'list';
ctrl.dirty = 'dirty';
};
/**
* End editing something
*/
ctrl.updateEditedThing = () => {
const edited = ctrl.categoryToEdit;
// @todo validate, e.g.
if (!edited.label) {
alert("No name");
return;
}
const search_data = edited.search_data;
if (edited.search_type === 'group') {
// Only store what we need.
const {group_id} = search_data;
edited.search_data = {group_id};
}
else if (edited.search_type === 'search') {
const {saved_search_id} = search_data;
edited.search_data = {saved_search_id};
}
if (edited.execution_order === -1) {
// This is a new category, we need to insert it before the default one.
const newIdx = ctrl.categoryDefinitions.length - 1;
ctrl.categoryDefinitions.splice(newIdx, 0, edited);
const newIdx = ctrl.execution_order.length - 1;
edited.presentation_order = newIdx;
edited.execution_order = newIdx;
ctrl.execution_order.splice(newIdx, 0, edited);
ctrl.presentation_order.splice(newIdx, 0, edited);
}
else {
ctrl.categoryDefinitions[edited.execution_order] = edited;
console.log("Update:", ctrl.uxOrderField);
const targetCat = ctrl[ctrl.uxOrderField][edited[ctrl.uxOrderField]];
Object.keys(blankDefn).forEach(k => {
console.log("Setting", k, "to ", edited[k], 'on item', targetCat);
targetCat[k] = edited[k];
});
}
ctrl.categoryToEdit = null;
updateOrders();
@ -157,38 +161,76 @@
ctrl.view = 'list';
}
// We need to ensure the search_data object contains the fields required for the selected search_type
/**
* Move a category within the currently selected order.
*/
ctrl.moveTo = idx => {
let item = ctrl[ctrl.uxOrderField].splice(ctrl.moveIdx, 1)[0];
if (idx > ctrl.moveIdx) {
idx--;
}
ctrl[ctrl.uxOrderField].splice(idx, 0, item);
ctrl.moveIdx = null;
updateOrders();
ctrl.dirty = 'dirty';
};
/**
* Mark a category for deletion.
*/
ctrl.deleteCategory = idx => {
if (!confirm(ts(
'Confirm deleting category %1. You will lose history related to this category. Sure?',
{1: catDefs[idx].label}
))) {
return;
}
ctrl[ctrl.uxOrderField][idx].deleted = true;
ctrl.categoryToEdit = null;
updateOrders();
ctrl.view = 'list';
ctrl.dirty = 'dirty';
};
/**
* We need to ensure the search_data object contains the
* expected fields and no more.
*/
ctrl.fixSearchData = () => {
const search_data = ctrl.categoryToEdit.search_data;
if (ctrl.categoryToEdit.search_type === 'group') {
if (!('group_id' in search_data)) {
search_data.group_id = null;
}
Object.assign(search_data, {group_id: null});
let {group_id} = search_data;
ctrl.categoryToEdit.search_data = {group_id};
}
else if (ctrl.categoryToEdit.search_type === 'search') {
if (!('saved_search_id' in search_data)) {
search_data.saved_search_id = null;
}
Object.assign(search_data, {saved_search_id: null});
let {saved_search_id} = search_data;
ctrl.categoryToEdit.search_data = {saved_search_id};
}
};
ctrl.save = () => {
if (!confirm(ts("Confirm saving changes to categories? Note that categories will not be fully applied until tomorrow."))) { return; }
const records = ctrl[ctrl.uxOrderField];
// Handle deletions first.
const deletedIds = ctrl.categoryDefinitions.filter(d => d.deleted && d.id > 0).map(d => d.id);
const deletedIds = records.filter(d => d.deleted && d.id > 0).map(d => d.id);
const chain = Promise.resolve();
if (deletedIds.length) {
chain.then(() => crmApi4('ContactCategoryDefinition', 'delete', { where: [ ['id', 'IN', deletedIds] ] }));
}
// Now enact the deletions on our local model.
ctrl.categoryDefinitions = ctrl.categoryDefinitions.filter(d => !d.deleted);
// Tidy execution_order
// Now enact the deletions on our local models
ctrl.presentation_order = ctrl.presentation_order.filter(d => !d.deleted);
ctrl.execution_order = ctrl.execution_order.filter(d => !d.deleted);
updateOrders();
if (ctrl.categoryDefinitions.length) {
chain.then(() => crmApi4('ContactCategoryDefinition', 'save', {records:ctrl.categoryDefinitions}));
if (ctrl.presentation_order.length) {
// Save if anything to save.
chain.then(() => crmApi4('ContactCategoryDefinition', 'save', {records}));
}
chain.then(() => {
ctrl.dirty = 'pristine';

View file

@ -1,8 +1,8 @@
<div id=bootstrap-theme>
<div ng-if="$ctrl.categoryDefinitions === null">
<div ng-if="$ctrl.presentation_order === null">
Loading...
</div>
<form ng-if="$ctrl.categoryDefinitions"
<form ng-if="$ctrl.presentation_order"
ng-show="$ctrl.view === 'list'"
crm-ui-id-scope
>
@ -13,20 +13,50 @@
longer title/description. "regular givers who..."
-->
<h2>{{ts('Execution order')}}</h2>
<p>{{ts('A contact is assigned the first category that matches according to the order defined here. Categories are presented in order of their label - see list below - so you may want to consider this when naming your categories.')}}</p>
<div class=help >{{ts('Each contact is assigned the first category that matches, using the assignment order. When categories are listed, the presentation order is used.')}}</div>
<div crm-ui-field="{name: 'cc.label', title: ts('Show')}" >
<input
crm-ui-id="ci.showOrder-execution"
type=radio
class=crm-form-radio
name="useOrder"
ng-model="$ctrl.uxOrderField"
value="execution_order"
ng-disabled="$ctrl.moveIdx !== null"
/>
<label crm-ui-for="ci.showOrder-execution">{{ts('Assignment order')}}</label>
<input
crm-ui-id="ci.showOrder-presentation"
type=radio
class=crm-form-radio
name="useOrder"
ng-model="$ctrl.uxOrderField"
value="presentation_order"
ng-disabled="$ctrl.moveIdx !== null"
/>
<label crm-ui-for="ci.showOrder-presentation">{{ts('Presentation order')}}</label>
</div>
<h2 ng-if="$ctrl.uxOrderField === 'execution_order'" >{{ts('Assignment order')}}</h2>
<h2 ng-if="$ctrl.uxOrderField === 'presentation_order'" >{{ts('Presentation order')}}</h2>
<p ng-if="$ctrl.uxOrderField === 'execution_order'" ><button class="btn btn-secondary" ng-click="$ctrl.copyOrder('presentation_order')" >{{ts('Copy from Presentation Order')}}</button></p>
<p ng-if="$ctrl.uxOrderField === 'presentation_order'" ><button class="btn btn-secondary" ng-click="$ctrl.copyOrder('execution_order')" >{{ts('Copy from Assignment Order')}}</button></p>
<ol class="crm-catmap {{$ctrl.moveIdx !== null ? 'moving' : ''}}">
<li ng-repeat="(idx, row) in $ctrl.categoryDefinitions" class="{{idx === $ctrl.moveIdx ? 'being-moved' : ''}} {{row.deleted ? 'deleted' : ''}}">
<div class=panel>
<div class=panel-body>
<span style="color: {{row.color ? row.color : 'inherit'}};" >
<i ng-if="row.icon" class="crm-i {{row.icon}}" ></i>
{{row.label}}
</span>
<li ng-repeat="(idx, row) in $ctrl[$ctrl.uxOrderField]" class="{{idx === $ctrl.moveIdx ? 'being-moved' : ''}} {{row.deleted ? 'deleted' : ''}}">
<div class=panel>
<div class=panel-body>
<span class="contactcats-label" style="color: {{row.color ? row.color : 'inherit'}};" >
<i ng-if="row.icon" class="crm-i {{row.icon}}" ></i>
{{row.label}}
</span>
<span class="contactcats-description" >
{{row.description}}
</span>
<!-- button group? -->
<span>
<span class=contactcats-actions>
<button ng-if="$ctrl.moveIdx === null && !row.deleted" ng-click="$ctrl.edit(idx)" >
<i class="fa-pencil crm-i"></i> {{ts('Edit')}}
</button>
@ -38,7 +68,7 @@
</button>
<button ng-if="$ctrl.moveIdx === null && !row.deleted && row.search_type !== 'default'" ng-click="$ctrl.moveIdx = idx"
class="btn"
ng-disabled="$ctrl.categoryDefinitions.length === 1"
ng-disabled="$ctrl.presentation_order.length === 1"
>
<i class="fa-arrows-up-down crm-i"></i> {{ts('Move')}}
</button>
@ -50,8 +80,8 @@
<button ng-click="$ctrl.moveTo(idx)" ng-show="$ctrl.moveIdx !== null && (idx === 0 && $ctrl.moveIdx > 0)" ><i class="fa-sort-up crm-i" ></i>{{ts('Before this')}}</button>
<button ng-click="$ctrl.moveTo(idx+1)" ng-show="$ctrl.moveIdx !== null && row.search_type !== 'default' && (idx + 1 < $ctrl.moveIdx || idx > $ctrl.moveIdx)"><i class="fa-sort-down crm-i" ></i>{{ts('After this')}}</button>
</span>
</div>
</div>
</div>
<!-- lower idx : {{idx}} moveIdx {{$ctrl.moveIdx}} -->
<!-- <div class="crm-contact-cats-move-target" ng-show="($ctrl.moveIdx < idx) || (idx +1 < $ctrl.moveIdx)" > -->
<!-- <button ng-click="$ctrl.moveTo(idx+1)" ><i class="fa-right-long crm-i" ></i>{{ts('Move to here')}}</button> -->
@ -72,24 +102,13 @@
Categories saved. Contacts will be updated shortly (when the next Scheduled
Job run happens).
</div>
<h2>Presentation order</h2>
<ol style="padding-left: 3ch;list-style: decimal;">
<li ng-repeat="(idx, row) in $ctrl.presentationOrder" >
<span style="color: {{row.color ? row.color : 'inherit'}};" >
<i ng-if="row.icon" class="crm-i {{row.icon}}" ></i>
{{row.label}}
</span>
</li>
</ol>
</form>
<form ng-show="$ctrl.view === 'edit'"
crm-ui-id-scope>
<div>
<h2 ng-if="$ctrl.categoryDefinitions.length > $ctrl.categoryToEdit.idx">{{ts('Edit category %1', {1 : $ctrl.categoryDefinitions[$ctrl.categoryToEdit.idx].label })}}</h2>
<h2 ng-if="$ctrl.categoryDefinitions.length == $ctrl.categoryToEdit.idx" >{{ts('Add new category')}}</h2>
<h2 ng-if="$ctrl.presentation_order.length > $ctrl.categoryToEdit.idx">{{ts('Edit category %1', {1 : $ctrl.[$ctrl.uxOrderField][$ctrl.categoryToEdit[$ctrl.uxOrderField]].label })}}</h2>
<h2 ng-if="$ctrl.presentation_order.length == $ctrl.categoryToEdit.idx" >{{ts('Add new category')}}</h2>
<div crm-ui-field="{name: 'cc.label', title: ts('Category label'), required: 1}" >
<input

View file

@ -15,7 +15,7 @@
<url desc="Licensing">http://www.gnu.org/licenses/agpl-3.0.html</url>
</urls>
<releaseDate>2024-02-27</releaseDate>
<version>1.0</version>
<version>1.1</version>
<develStage>alpha</develStage>
<compatibility>
<ver>5.70</ver>

View file

@ -57,7 +57,11 @@ return [
'settings' => [
'description' => E::ts('Shows a list of all categories and how many contacts are in each one.'),
'sort' => [
['label', 'ASC'],
[
'presentation_order',
'ASC',
],
['color', 'ASC'],
],
'limit' => 50,
'pager' => FALSE,

View file

@ -33,6 +33,17 @@ return [
'label' => E::ts('Category name'),
],
],
'description' => [
'title' => E::ts('Description'),
'sql_type' => 'text',
'input_type' => 'TextArea',
'required' => FALSE,
'input_attrs' => [
'note_columns' => 60,
'note_rows' => 3,
'label' => E::ts('Category description'),
],
],
'search_type' => [
'title' => E::ts('Definition type'),
'sql_type' => 'varchar(12)',
@ -77,5 +88,12 @@ return [
'default' => '10',
'required' => TRUE,
],
'presentation_order' => [
'title' => E::ts('Presentation order'),
'description' => E::ts('What order should these be presented in?'),
'sql_type' => 'int(1) unsigned',
'default' => '10',
'required' => TRUE,
],
],
];