mirror of
https://codeberg.org/artfulrobot/contactcats.git
synced 2025-06-25 12:58:05 +02:00
Checkin, fairly stable
This commit is contained in:
parent
f88fb5f10e
commit
eb6542857b
5 changed files with 161 additions and 119 deletions
|
@ -3,6 +3,7 @@ namespace Civi\Api4\Action\ContactCategory;
|
|||
|
||||
use CRM_Contactcats_ExtensionUtil as E;
|
||||
use Civi;
|
||||
use Civi\Api4\ContactCategoryDefinition;
|
||||
use Civi\Api4\Generic\Result;
|
||||
use CRM_Core_DAO;
|
||||
use CRM_Core_Exception;
|
||||
|
@ -296,10 +297,16 @@ class GetFlows extends \Civi\Api4\Generic\AbstractAction {
|
|||
2 => [$endDateYmd, 'Int'],
|
||||
])->fetchAll();
|
||||
|
||||
// It's possible to end up with NULLs at the startSide.
|
||||
// These should be replaced with our default category.
|
||||
$defaultCategoryId = ContactCategoryDefinition::get()
|
||||
->addWhere('search_type:name', '=', 'default')
|
||||
->execute()->first()['id'];
|
||||
|
||||
// Don't use exchange array so as not to gazump non-array data(?)
|
||||
foreach ($data as $row) {
|
||||
$result[] = [
|
||||
'from_category_id' => (int) $row['from_category_id'],
|
||||
'from_category_id' => (int) (($row['from_category_id']) ?: $defaultCategoryId),
|
||||
'to_category_id' => (int) $row['to_category_id'],
|
||||
'contact_count' => (int) $row['contact_count'],
|
||||
];
|
||||
|
@ -313,8 +320,8 @@ class GetFlows extends \Civi\Api4\Generic\AbstractAction {
|
|||
try {
|
||||
$data = CRM_Core_DAO::executeQuery($sql, $params)->fetchAll();
|
||||
}
|
||||
catch (\Exception $e) {
|
||||
print $e->getCause()->userinfo;
|
||||
catch (\CRM_Core_Exception $e) {
|
||||
if (method_exists('getClause', $e)) print $e->getCause()->userinfo;
|
||||
}
|
||||
print "\n$msg (" . count($data) . ") records ===============================\n";
|
||||
foreach ($data as $row) {
|
||||
|
|
|
@ -47,6 +47,7 @@
|
|||
}
|
||||
// Ensure we have the minimum of what we need.
|
||||
if (!catDefs.find(c => c.search_type === 'default')) {
|
||||
console.log("Adding default category as was missing");
|
||||
// There's no default one.
|
||||
catDefs.push(Object.assign({}, blankDefn, {
|
||||
execution_order: catDefs.length,
|
||||
|
@ -67,8 +68,11 @@
|
|||
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 (orderField === 'execution_order') {
|
||||
// Enforce default at end for assignment order
|
||||
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;
|
||||
|
@ -162,6 +166,16 @@
|
|||
ctrl.view = 'list';
|
||||
}
|
||||
|
||||
ctrl.canMoveBelow = function(row, idx) {
|
||||
// Only show move buttons when moving.
|
||||
if (ctrl.moveIdx === null) return false;
|
||||
|
||||
if (ctrl.uxOrderField === 'execution_order') {
|
||||
// The default MUST be the last item in execution_order.
|
||||
if (row.search_type === 'default') return false;
|
||||
}
|
||||
return (idx + 1 < ctrl.moveIdx || idx > ctrl.moveIdx);
|
||||
};
|
||||
/**
|
||||
* Move a category within the currently selected order.
|
||||
*/
|
||||
|
|
|
@ -2,20 +2,29 @@
|
|||
<form crm-ui-id-scope >
|
||||
<div crm-ui-field="{name: 'cc.startDate', title: ts('Start date')}" >
|
||||
<input
|
||||
crm-ui-id="ci.startDate"
|
||||
crm-ui-id="cc.startDate"
|
||||
type=date
|
||||
name="startDate"
|
||||
name=startDate
|
||||
ng-model="$ctrl.startDate"
|
||||
/>
|
||||
</div>
|
||||
<div crm-ui-field="{name: 'cc.endDate', title: ts('End date')}" >
|
||||
<input
|
||||
crm-ui-id="ci.endDate"
|
||||
crm-ui-id="cc.endDate"
|
||||
type=date
|
||||
name="endDate"
|
||||
name=endDate
|
||||
ng-model="$ctrl.endDate"
|
||||
/>
|
||||
</div>
|
||||
<div crm-ui-field="{name: 'cc.changesOnly', title: ts('Show changes only')}" >
|
||||
<input
|
||||
crm-ui-id="cc.changesOnly"
|
||||
type=checkbox
|
||||
name=changesOnly
|
||||
ng-model="$ctrl.changesOnly"
|
||||
ng-change="$ctrl.redrawSankey()"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<button ng-click="$ctrl.fetchStats()" >
|
||||
<i ng-if="$ctrl.loading" class="crm-i fa-spin fa-spinner"></i>
|
||||
|
@ -47,7 +56,7 @@
|
|||
fill="{{p.fill}}"
|
||||
fill-opacity=0.5
|
||||
>
|
||||
<title>{{p.source.contact_count}} {{p.fromCat.name}} → {{p.toCat.name}}</title>
|
||||
<title>{{p.source.contact_count}} {{p.fromCat.label}} → {{p.toCat.label}}</title>
|
||||
</path>
|
||||
<!--
|
||||
<g ng-repeat="label in $ctrl.sankey.labels">
|
||||
|
|
|
@ -10,55 +10,13 @@
|
|||
// Development: set this true to use a small defined fixture instead of real data.
|
||||
const useTestFixtures = false;
|
||||
|
||||
// this.$onInit gets run after the this controller is called, and after the bindings have been applied.
|
||||
this.$onInit = async function() {
|
||||
// Default start date to 1 month ago.
|
||||
ctrl.loading = true;
|
||||
ctrl.startDate = new Date();
|
||||
ctrl.startDate.setMonth(ctrl.startDate.getMonth() - 1);
|
||||
ctrl.startDate.setHours(0);
|
||||
ctrl.startDate.setMinutes(0);
|
||||
ctrl.startDate.setSeconds(0);
|
||||
if (useTestFixtures) {
|
||||
ctrl.startDate = new Date('2025-03-10');
|
||||
}
|
||||
ctrl.endDate = null;
|
||||
ctrl.stats = null;
|
||||
ctrl.sankey = { height: 500, width: 900, labelWidth: 150, snakeWidth: 600, snakes: [] };
|
||||
let allFlowsData;
|
||||
|
||||
if (useTestFixtures) {
|
||||
ctrl.catDefs = [
|
||||
{ id: 100, name: 'Amazing', color:'#4ad926' },
|
||||
{ id: 101, name: 'Meh', color:'#d52a80' },
|
||||
];
|
||||
}
|
||||
else {
|
||||
ctrl.catDefs = await crmStatus({ start: ts('Loading...'), success: ''},
|
||||
crmApi4("ContactCategoryDefinition", 'get', { orderBy: { presentation_order: 'ASC' }, withLabels: true })
|
||||
);
|
||||
}
|
||||
ctrl.loading = false;
|
||||
ctrl.catDefs.forEach(cat => {catDefsIndexed[cat.id] = cat;});
|
||||
console.log(catDefsIndexed);
|
||||
ctrl.redrawSankey = function() {
|
||||
let flowsData = ctrl.changesOnly
|
||||
? allFlowsData.filter(({from_category_id, to_category_id}) => from_category_id != to_category_id)
|
||||
: allFlowsData;
|
||||
|
||||
ctrl.fetchStats = function() {
|
||||
ctrl.loading = true;
|
||||
let chain;
|
||||
if (useTestFixtures) {
|
||||
chain = Promise.resolve([
|
||||
{ from_category_id: 100, to_category_id: 100, contact_count: 10 },
|
||||
{ from_category_id: 100, to_category_id: 101, contact_count: 5 },
|
||||
{ from_category_id: 101, to_category_id: 101, contact_count: 20 },
|
||||
{ from_category_id: 101, to_category_id: 100, contact_count: 2 },
|
||||
])
|
||||
}
|
||||
else {
|
||||
chain = crmApi4('ContactCategory', 'getFlows', {
|
||||
startDate: ctrl.startDate.toISOString().substring(0, 10),
|
||||
endDate: ctrl.endDate ? ctrl.endDate.toISOString().substring(0, 10) : '',
|
||||
})
|
||||
}
|
||||
chain.then(r => {
|
||||
// Process the results into something useful.
|
||||
ctrl.loading = false;
|
||||
const labelWidth = ctrl.sankey.labelWidth,
|
||||
|
@ -66,7 +24,7 @@
|
|||
snakeWidth = width - 2*labelWidth;
|
||||
ctrl.sankey.width = width;
|
||||
|
||||
if (!(r.length)) {
|
||||
if (!(flowsData.length)) {
|
||||
ctrl.noStats = true;
|
||||
ctrl.stats = [];
|
||||
return;
|
||||
|
@ -81,7 +39,7 @@
|
|||
});
|
||||
|
||||
let totalContacts = 0;
|
||||
r.forEach(row => {
|
||||
flowsData.forEach(row => {
|
||||
const {from_category_id, to_category_id, contact_count} = row;
|
||||
if (!from_category_id) {
|
||||
// from nowhere: ignore for now.
|
||||
|
@ -95,26 +53,22 @@
|
|||
analysis[from_category_id].previous += contact_count;
|
||||
analysis[to_category_id].sources.push({ cat: catDefsIndexed[from_category_id], contact_count });
|
||||
|
||||
totalContacts += analysis[to_category_id].now;
|
||||
totalContacts += contact_count;
|
||||
});
|
||||
|
||||
|
||||
// allowing 50px per cat
|
||||
let scale = (ctrl.catDefs.length * 50) / totalContacts;
|
||||
let scale = (ctrl.sankey.height - (ctrl.catDefs.length * 8)) / totalContacts;
|
||||
|
||||
let previousCatHeight = 0;
|
||||
ctrl.catDefs.forEach(cat => {
|
||||
// Now we know how many contacts per category per side.
|
||||
// Set the from offsets
|
||||
offsetWithinSourcesByCat[cat.id] = previousCatHeight;
|
||||
previousCatHeight += Math.max(1, parseInt(analysis[cat.id].previous * scale)) +8; // padding TODO: standardise
|
||||
previousCatHeight += analysis[cat.id].previous * scale +8; // padding TODO: standardise
|
||||
|
||||
// Make sure that our 'from' snake ends are ordered by category presentation_order
|
||||
// This avoids one categories snakes overlapping its others.
|
||||
analysis[cat.id].sources.sort((a, b) => {
|
||||
if (!b.cat) {
|
||||
console.log("Sort fail on b", {a, b});
|
||||
}
|
||||
return a.cat.presentation_order - b.cat.presentation_order;
|
||||
})
|
||||
});
|
||||
|
@ -126,16 +80,16 @@
|
|||
let toCat = analysis[cat.id];
|
||||
toCat.yTop = yTopR;
|
||||
toCat.sources.forEach(source => {
|
||||
const sourceHeight = Math.max(1,parseInt(source.contact_count * scale));
|
||||
const sourceHeight = parseInt(source.contact_count * scale);
|
||||
const gradientId = 'contactcats-gradient-' + source.cat.id + '-' + cat.id;
|
||||
snakes.push({
|
||||
source,
|
||||
gradientId,
|
||||
toTop : yTopR,
|
||||
toHeight : sourceHeight,
|
||||
toHeight : Math.max(1,sourceHeight),
|
||||
toCat: toCat.cat,
|
||||
fromTop: offsetWithinSourcesByCat[source.cat.id],
|
||||
fromHeight: sourceHeight, // from and to heights will be the same!
|
||||
fromHeight: Math.max(1,sourceHeight), // from and to heights will be the same!
|
||||
fromCat: source.cat
|
||||
});
|
||||
offsetWithinSourcesByCat[source.cat.id] += sourceHeight;
|
||||
|
@ -163,6 +117,61 @@
|
|||
|
||||
ctrl.sankey.snakes = snakes;
|
||||
console.log({snakes, catDefs: catDefsIndexed, analysis});
|
||||
}
|
||||
|
||||
// this.$onInit gets run after the this controller is called, and after the bindings have been applied.
|
||||
this.$onInit = async function() {
|
||||
// Default start date to 1 month ago.
|
||||
ctrl.loading = true;
|
||||
ctrl.changesOnly = false;
|
||||
ctrl.startDate = new Date();
|
||||
ctrl.startDate.setMonth(ctrl.startDate.getMonth() - 1);
|
||||
ctrl.startDate.setHours(0);
|
||||
ctrl.startDate.setMinutes(0);
|
||||
ctrl.startDate.setSeconds(0);
|
||||
if (useTestFixtures) {
|
||||
ctrl.startDate = new Date('2025-03-10');
|
||||
}
|
||||
ctrl.startDate = new Date('2025-03-10');
|
||||
ctrl.endDate = null;
|
||||
ctrl.stats = null;
|
||||
ctrl.sankey = { height: 500, width: 900, labelWidth: 150, snakeWidth: 600, snakes: [] };
|
||||
|
||||
if (useTestFixtures) {
|
||||
ctrl.catDefs = [
|
||||
{ id: 100, label: 'Amazing', color:'#ffcc00' },
|
||||
{ id: 101, label: 'Meh', color:'#ff6f00' },
|
||||
];
|
||||
}
|
||||
else {
|
||||
ctrl.catDefs = await crmStatus({ start: ts('Loading...'), success: ''},
|
||||
crmApi4("ContactCategoryDefinition", 'get', { orderBy: { presentation_order: 'ASC' }, withLabels: true })
|
||||
);
|
||||
}
|
||||
ctrl.sankey.height = ctrl.catDefs.length * 50;
|
||||
ctrl.loading = false;
|
||||
ctrl.catDefs.forEach(cat => {catDefsIndexed[cat.id] = cat;});
|
||||
|
||||
ctrl.fetchStats = function() {
|
||||
ctrl.loading = true;
|
||||
let chain;
|
||||
if (useTestFixtures) {
|
||||
chain = Promise.resolve([
|
||||
{ from_category_id: 100, to_category_id: 100, contact_count: 10 },
|
||||
{ from_category_id: 100, to_category_id: 101, contact_count: 5 },
|
||||
{ from_category_id: 101, to_category_id: 101, contact_count: 20 },
|
||||
{ from_category_id: 101, to_category_id: 100, contact_count: 2 },
|
||||
])
|
||||
}
|
||||
else {
|
||||
chain = crmApi4('ContactCategory', 'getFlows', {
|
||||
startDate: ctrl.startDate.toISOString().substring(0, 10),
|
||||
endDate: ctrl.endDate ? ctrl.endDate.toISOString().substring(0, 10) : '',
|
||||
})
|
||||
}
|
||||
chain.then(r => {
|
||||
allFlowsData = r;
|
||||
ctrl.redrawSankey();
|
||||
});
|
||||
}
|
||||
// do initial fetch.
|
||||
|
|
|
@ -66,7 +66,7 @@
|
|||
<button ng-if="$ctrl.moveIdx === null && row.deleted" ng-click="row.deleted = false;$ctrl.dirty = 'dirty';">
|
||||
<i class="fa-trash crm-i"></i> {{ts('Un-Delete')}}
|
||||
</button>
|
||||
<button ng-if="$ctrl.moveIdx === null && !row.deleted && row.search_type !== 'default'" ng-click="$ctrl.moveIdx = idx"
|
||||
<button ng-if="$ctrl.moveIdx === null && !row.deleted && !(row.search_type === 'default' && $ctrl.uxOrderField === 'execution_order')" ng-click="$ctrl.moveIdx = idx"
|
||||
class="btn"
|
||||
ng-disabled="$ctrl.presentation_order.length === 1"
|
||||
>
|
||||
|
@ -78,7 +78,8 @@
|
|||
<i class="fa-circle-xmark crm-i"></i> {{ts('Cancel Move')}}
|
||||
</button>
|
||||
<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>
|
||||
<button ng-click="$ctrl.moveTo(idx+1)" ng-show="$ctrl.canMoveBelow(row, idx)"
|
||||
><i class="fa-sort-down crm-i" ></i>{{ts('After this')}}</button>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
@ -156,11 +157,13 @@
|
|||
name="search_type"
|
||||
ng-model="$ctrl.categoryToEdit.search_type"
|
||||
ng-change="$ctrl.fixSearchData()"
|
||||
ng-show="$ctrl.categoryToEdit.search_type !== 'default'"
|
||||
>
|
||||
<option value="search" >{{ts('Search Kit search')}}</option>
|
||||
<option value="group" >{{ts('Group')}}</option>
|
||||
<!-- future? <option value="sql_template" >{{ts('SQL template')}}</option> -->
|
||||
</select>
|
||||
<div ng-show="$ctrl.categoryToEdit.search_type === 'default'" >{{ts('Default category')}}</div>
|
||||
</div>
|
||||
|
||||
<!-- fields for search kit -->
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue