diff --git a/CRM/Contactcats/Upgrader.php b/CRM/Contactcats/Upgrader.php index 59a00ff..b97c0fe 100644 --- a/CRM/Contactcats/Upgrader.php +++ b/CRM/Contactcats/Upgrader.php @@ -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(<<ctx->log->info('Applying update 0001: add presentation_order'); + CRM_Core_DAO::executeQuery(<< 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'; diff --git a/ang/crmContactcats/crmContactCategorySettings.html b/ang/crmContactcats/crmContactCategorySettings.html index 7c71eed..2a678da 100644 --- a/ang/crmContactcats/crmContactCategorySettings.html +++ b/ang/crmContactcats/crmContactCategorySettings.html @@ -1,8 +1,8 @@
-
+
Loading...
-
@@ -13,20 +13,50 @@ longer title/description. "regular givers who..." --> -

{{ts('Execution order')}}

-

{{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.')}}

+
{{ts('Each contact is assigned the first category that matches, using the assignment order. When categories are listed, the presentation order is used.')}}
+
+ + + + + +
+ +

{{ts('Assignment order')}}

+

{{ts('Presentation order')}}

+

+

    -
  1. -
    -
    - - - {{row.label}} - +
  2. +
    +
    + + + {{row.label}} + + + {{row.description}} + - + @@ -38,7 +68,7 @@ @@ -50,8 +80,8 @@ +
    -
@@ -72,24 +102,13 @@ Categories saved. Contacts will be updated shortly (when the next Scheduled Job run happens).
- -

Presentation order

-
    -
  1. - - - {{row.label}} - -
  2. -
-
-

{{ts('Edit category %1', {1 : $ctrl.categoryDefinitions[$ctrl.categoryToEdit.idx].label })}}

-

{{ts('Add new category')}}

+

{{ts('Edit category %1', {1 : $ctrl.[$ctrl.uxOrderField][$ctrl.categoryToEdit[$ctrl.uxOrderField]].label })}}

+

{{ts('Add new category')}}

http://www.gnu.org/licenses/agpl-3.0.html 2024-02-27 - 1.0 + 1.1 alpha 5.70 diff --git a/managed/SavedSearch_Contact_Category_Counts.mgd.php b/managed/SavedSearch_Contact_Category_Counts.mgd.php index 7cc6f78..21068e2 100644 --- a/managed/SavedSearch_Contact_Category_Counts.mgd.php +++ b/managed/SavedSearch_Contact_Category_Counts.mgd.php @@ -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, diff --git a/schema/ContactCategoryDefinition.entityType.php b/schema/ContactCategoryDefinition.entityType.php index 3c22578..7e0c97e 100644 --- a/schema/ContactCategoryDefinition.entityType.php +++ b/schema/ContactCategoryDefinition.entityType.php @@ -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, + ], ], ];