Checkin, fairly stable

This commit is contained in:
Rich Lott / Artful Robot 2025-03-26 13:29:55 +00:00
parent f88fb5f10e
commit eb6542857b
5 changed files with 161 additions and 119 deletions

View file

@ -3,6 +3,7 @@ namespace Civi\Api4\Action\ContactCategory;
use CRM_Contactcats_ExtensionUtil as E; use CRM_Contactcats_ExtensionUtil as E;
use Civi; use Civi;
use Civi\Api4\ContactCategoryDefinition;
use Civi\Api4\Generic\Result; use Civi\Api4\Generic\Result;
use CRM_Core_DAO; use CRM_Core_DAO;
use CRM_Core_Exception; use CRM_Core_Exception;
@ -296,10 +297,16 @@ class GetFlows extends \Civi\Api4\Generic\AbstractAction {
2 => [$endDateYmd, 'Int'], 2 => [$endDateYmd, 'Int'],
])->fetchAll(); ])->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(?) // Don't use exchange array so as not to gazump non-array data(?)
foreach ($data as $row) { foreach ($data as $row) {
$result[] = [ $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'], 'to_category_id' => (int) $row['to_category_id'],
'contact_count' => (int) $row['contact_count'], 'contact_count' => (int) $row['contact_count'],
]; ];
@ -313,8 +320,8 @@ class GetFlows extends \Civi\Api4\Generic\AbstractAction {
try { try {
$data = CRM_Core_DAO::executeQuery($sql, $params)->fetchAll(); $data = CRM_Core_DAO::executeQuery($sql, $params)->fetchAll();
} }
catch (\Exception $e) { catch (\CRM_Core_Exception $e) {
print $e->getCause()->userinfo; if (method_exists('getClause', $e)) print $e->getCause()->userinfo;
} }
print "\n$msg (" . count($data) . ") records ===============================\n"; print "\n$msg (" . count($data) . ") records ===============================\n";
foreach ($data as $row) { foreach ($data as $row) {

View file

@ -47,6 +47,7 @@
} }
// Ensure we have the minimum of what we need. // Ensure we have the minimum of what we need.
if (!catDefs.find(c => c.search_type === 'default')) { if (!catDefs.find(c => c.search_type === 'default')) {
console.log("Adding default category as was missing");
// There's no default one. // There's no default one.
catDefs.push(Object.assign({}, blankDefn, { catDefs.push(Object.assign({}, blankDefn, {
execution_order: catDefs.length, execution_order: catDefs.length,
@ -67,8 +68,11 @@
ctrl[orderField] = catDefs.slice(); ctrl[orderField] = catDefs.slice();
ctrl[orderField].sort((a, b) => { 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}); // 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 (orderField === 'execution_order') {
if (b.search_type === 'default' && a.search_type !== 'default') return -1; // 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[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;
@ -162,6 +166,16 @@
ctrl.view = 'list'; 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. * Move a category within the currently selected order.
*/ */

View file

@ -2,20 +2,29 @@
<form crm-ui-id-scope > <form crm-ui-id-scope >
<div crm-ui-field="{name: 'cc.startDate', title: ts('Start date')}" > <div crm-ui-field="{name: 'cc.startDate', title: ts('Start date')}" >
<input <input
crm-ui-id="ci.startDate" crm-ui-id="cc.startDate"
type=date type=date
name="startDate" name=startDate
ng-model="$ctrl.startDate" ng-model="$ctrl.startDate"
/> />
</div> </div>
<div crm-ui-field="{name: 'cc.endDate', title: ts('End date')}" > <div crm-ui-field="{name: 'cc.endDate', title: ts('End date')}" >
<input <input
crm-ui-id="ci.endDate" crm-ui-id="cc.endDate"
type=date type=date
name="endDate" name=endDate
ng-model="$ctrl.endDate" ng-model="$ctrl.endDate"
/> />
</div> </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> <div>
<button ng-click="$ctrl.fetchStats()" > <button ng-click="$ctrl.fetchStats()" >
<i ng-if="$ctrl.loading" class="crm-i fa-spin fa-spinner"></i> <i ng-if="$ctrl.loading" class="crm-i fa-spin fa-spinner"></i>
@ -47,7 +56,7 @@
fill="{{p.fill}}" fill="{{p.fill}}"
fill-opacity=0.5 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> </path>
<!-- <!--
<g ng-repeat="label in $ctrl.sankey.labels"> <g ng-repeat="label in $ctrl.sankey.labels">

View file

@ -10,10 +10,120 @@
// Development: set this true to use a small defined fixture instead of real data. // Development: set this true to use a small defined fixture instead of real data.
const useTestFixtures = false; const useTestFixtures = false;
let allFlowsData;
ctrl.redrawSankey = function() {
let flowsData = ctrl.changesOnly
? allFlowsData.filter(({from_category_id, to_category_id}) => from_category_id != to_category_id)
: allFlowsData;
// Process the results into something useful.
ctrl.loading = false;
const labelWidth = ctrl.sankey.labelWidth,
width = Math.max(600, document.querySelector('.contact-cats-sankey').clientWidth),
snakeWidth = width - 2*labelWidth;
ctrl.sankey.width = width;
if (!(flowsData.length)) {
ctrl.noStats = true;
ctrl.stats = [];
return;
}
ctrl.noStats = false;
// Create main analysis structure, indexed by right hand side of snakes.
const analysis = {}, offsetWithinSourcesByCat = {};
ctrl.catDefs.forEach(cat => {
analysis[cat.id] = { sources: [], now: 0, previous: 0, cat };
offsetWithinSourcesByCat[cat.id] = 0;
});
let totalContacts = 0;
flowsData.forEach(row => {
const {from_category_id, to_category_id, contact_count} = row;
if (!from_category_id) {
// from nowhere: ignore for now.
console.log("Skipping", row);
return;
}
analysis[to_category_id].now += contact_count;
if (!catDefsIndexed[from_category_id]) {
console.log("from_category_id", from_category_id, "not found in defs", catDefsIndexed);
}
analysis[from_category_id].previous += contact_count;
analysis[to_category_id].sources.push({ cat: catDefsIndexed[from_category_id], contact_count });
totalContacts += contact_count;
});
// allowing 50px per cat
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 += 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) => {
return a.cat.presentation_order - b.cat.presentation_order;
})
});
// Make a list of snakes we need to draw.
const snakes = [];
let yTopR = 0;
ctrl.catDefs.forEach(cat => {
let toCat = analysis[cat.id];
toCat.yTop = yTopR;
toCat.sources.forEach(source => {
const sourceHeight = parseInt(source.contact_count * scale);
const gradientId = 'contactcats-gradient-' + source.cat.id + '-' + cat.id;
snakes.push({
source,
gradientId,
toTop : yTopR,
toHeight : Math.max(1,sourceHeight),
toCat: toCat.cat,
fromTop: offsetWithinSourcesByCat[source.cat.id],
fromHeight: Math.max(1,sourceHeight), // from and to heights will be the same!
fromCat: source.cat
});
offsetWithinSourcesByCat[source.cat.id] += sourceHeight;
yTopR += sourceHeight;
});
yTopR += 8;// padding TODO: apply this to the lhs too
});
// Create the SVG data for the snakes.
//c${-snakeWidth/4},0,${-snakeWidth},${-snakeWidth*3/4},0,${-snakeWidth}, ${snake.fromTopOffset + snake.fromHeight - snake.toTop - snake.toHeight}
const curve1 = snakeWidth/4, curve2 = snakeWidth*3/4;
snakes.forEach(snake => {
const dyBottom = snake.fromTop + snake.fromHeight - snake.toTop - snake.toHeight;
const dyTop = -snake.fromTop + snake.toTop;
snake.d = `M${snakeWidth},${snake.toTop} l0,${snake.toHeight}
c${-curve1},0,${-curve2},${dyBottom},${-snakeWidth},${dyBottom}
l0,${-snake.fromHeight}
c${curve1},0,${curve2},${dyTop},${snakeWidth},${dyTop}
z
`;
snake.fill = `url(#${snake.gradientId})`;
});
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 gets run after the this controller is called, and after the bindings have been applied.
this.$onInit = async function() { this.$onInit = async function() {
// Default start date to 1 month ago. // Default start date to 1 month ago.
ctrl.loading = true; ctrl.loading = true;
ctrl.changesOnly = false;
ctrl.startDate = new Date(); ctrl.startDate = new Date();
ctrl.startDate.setMonth(ctrl.startDate.getMonth() - 1); ctrl.startDate.setMonth(ctrl.startDate.getMonth() - 1);
ctrl.startDate.setHours(0); ctrl.startDate.setHours(0);
@ -22,14 +132,15 @@
if (useTestFixtures) { if (useTestFixtures) {
ctrl.startDate = new Date('2025-03-10'); ctrl.startDate = new Date('2025-03-10');
} }
ctrl.startDate = new Date('2025-03-10');
ctrl.endDate = null; ctrl.endDate = null;
ctrl.stats = null; ctrl.stats = null;
ctrl.sankey = { height: 500, width: 900, labelWidth: 150, snakeWidth: 600, snakes: [] }; ctrl.sankey = { height: 500, width: 900, labelWidth: 150, snakeWidth: 600, snakes: [] };
if (useTestFixtures) { if (useTestFixtures) {
ctrl.catDefs = [ ctrl.catDefs = [
{ id: 100, name: 'Amazing', color:'#4ad926' }, { id: 100, label: 'Amazing', color:'#ffcc00' },
{ id: 101, name: 'Meh', color:'#d52a80' }, { id: 101, label: 'Meh', color:'#ff6f00' },
]; ];
} }
else { else {
@ -37,9 +148,9 @@
crmApi4("ContactCategoryDefinition", 'get', { orderBy: { presentation_order: 'ASC' }, withLabels: true }) crmApi4("ContactCategoryDefinition", 'get', { orderBy: { presentation_order: 'ASC' }, withLabels: true })
); );
} }
ctrl.sankey.height = ctrl.catDefs.length * 50;
ctrl.loading = false; ctrl.loading = false;
ctrl.catDefs.forEach(cat => {catDefsIndexed[cat.id] = cat;}); ctrl.catDefs.forEach(cat => {catDefsIndexed[cat.id] = cat;});
console.log(catDefsIndexed);
ctrl.fetchStats = function() { ctrl.fetchStats = function() {
ctrl.loading = true; ctrl.loading = true;
@ -59,110 +170,8 @@
}) })
} }
chain.then(r => { chain.then(r => {
// Process the results into something useful. allFlowsData = r;
ctrl.loading = false; ctrl.redrawSankey();
const labelWidth = ctrl.sankey.labelWidth,
width = Math.max(600, document.querySelector('.contact-cats-sankey').clientWidth),
snakeWidth = width - 2*labelWidth;
ctrl.sankey.width = width;
if (!(r.length)) {
ctrl.noStats = true;
ctrl.stats = [];
return;
}
ctrl.noStats = false;
// Create main analysis structure, indexed by right hand side of snakes.
const analysis = {}, offsetWithinSourcesByCat = {};
ctrl.catDefs.forEach(cat => {
analysis[cat.id] = { sources: [], now: 0, previous: 0, cat };
offsetWithinSourcesByCat[cat.id] = 0;
});
let totalContacts = 0;
r.forEach(row => {
const {from_category_id, to_category_id, contact_count} = row;
if (!from_category_id) {
// from nowhere: ignore for now.
console.log("Skipping", row);
return;
}
analysis[to_category_id].now += contact_count;
if (!catDefsIndexed[from_category_id]) {
console.log("from_category_id", from_category_id, "not found in defs", catDefsIndexed);
}
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;
});
// allowing 50px per cat
let scale = (ctrl.catDefs.length * 50) / 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
// 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;
})
});
// Make a list of snakes we need to draw.
const snakes = [];
let yTopR = 0;
ctrl.catDefs.forEach(cat => {
let toCat = analysis[cat.id];
toCat.yTop = yTopR;
toCat.sources.forEach(source => {
const sourceHeight = Math.max(1,parseInt(source.contact_count * scale));
const gradientId = 'contactcats-gradient-' + source.cat.id + '-' + cat.id;
snakes.push({
source,
gradientId,
toTop : yTopR,
toHeight : sourceHeight,
toCat: toCat.cat,
fromTop: offsetWithinSourcesByCat[source.cat.id],
fromHeight: sourceHeight, // from and to heights will be the same!
fromCat: source.cat
});
offsetWithinSourcesByCat[source.cat.id] += sourceHeight;
yTopR += sourceHeight;
});
yTopR += 8;// padding TODO: apply this to the lhs too
});
// Create the SVG data for the snakes.
//c${-snakeWidth/4},0,${-snakeWidth},${-snakeWidth*3/4},0,${-snakeWidth}, ${snake.fromTopOffset + snake.fromHeight - snake.toTop - snake.toHeight}
const curve1 = snakeWidth/4, curve2 = snakeWidth*3/4;
snakes.forEach(snake => {
const dyBottom = snake.fromTop + snake.fromHeight - snake.toTop - snake.toHeight;
const dyTop = -snake.fromTop + snake.toTop;
snake.d = `M${snakeWidth},${snake.toTop} l0,${snake.toHeight}
c${-curve1},0,${-curve2},${dyBottom},${-snakeWidth},${dyBottom}
l0,${-snake.fromHeight}
c${curve1},0,${curve2},${dyTop},${snakeWidth},${dyTop}
z
`;
snake.fill = `url(#${snake.gradientId})`;
});
ctrl.sankey.snakes = snakes;
console.log({snakes, catDefs: catDefsIndexed, analysis});
}); });
} }
// do initial fetch. // do initial fetch.

View file

@ -66,7 +66,7 @@
<button ng-if="$ctrl.moveIdx === null && row.deleted" ng-click="row.deleted = false;$ctrl.dirty = 'dirty';"> <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')}} <i class="fa-trash crm-i"></i> {{ts('Un-Delete')}}
</button> </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" class="btn"
ng-disabled="$ctrl.presentation_order.length === 1" ng-disabled="$ctrl.presentation_order.length === 1"
> >
@ -78,7 +78,8 @@
<i class="fa-circle-xmark crm-i"></i> {{ts('Cancel Move')}} <i class="fa-circle-xmark crm-i"></i> {{ts('Cancel Move')}}
</button> </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)" 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> </span>
</div> </div>
</div> </div>
@ -156,11 +157,13 @@
name="search_type" name="search_type"
ng-model="$ctrl.categoryToEdit.search_type" ng-model="$ctrl.categoryToEdit.search_type"
ng-change="$ctrl.fixSearchData()" ng-change="$ctrl.fixSearchData()"
ng-show="$ctrl.categoryToEdit.search_type !== 'default'"
> >
<option value="search" >{{ts('Search Kit search')}}</option> <option value="search" >{{ts('Search Kit search')}}</option>
<option value="group" >{{ts('Group')}}</option> <option value="group" >{{ts('Group')}}</option>
<!-- future? <option value="sql_template" >{{ts('SQL template')}}</option> --> <!-- future? <option value="sql_template" >{{ts('SQL template')}}</option> -->
</select> </select>
<div ng-show="$ctrl.categoryToEdit.search_type === 'default'" >{{ts('Default category')}}</div>
</div> </div>
<!-- fields for search kit --> <!-- fields for search kit -->