Merge branch 'form-date-condorcet' into 5.3-rc

This commit is contained in:
David Benque 2023-04-27 09:52:30 +01:00
commit b1af6da17c
3 changed files with 552 additions and 7 deletions

View File

@ -679,6 +679,12 @@
line-height: 31px;
}
}
.cp-form-result-details {
margin: 10px 0px;
&> * {
margin-right: 0.5em;
}
}
&.editable {
&:not(.nodrag) { cursor: grab; }
.cp-form-edit-save {
@ -1276,6 +1282,17 @@
.cp-form-setting-title {
color: @cryptpad_color_link;
}
.cp-form-status-container {
.cp-form-input-block {
display: flex;
.flatpickr-input { // expiration date picker
margin: 0 10px 0 0;
}
& > * {
margin-right: 10px;
}
}
}
}
}
& > .flatpickr-calendar {

316
www/form/condorcet.js Normal file
View File

@ -0,0 +1,316 @@
define([], function () {
var Condorcet = {};
// Creates every possible combination pair of given options
var getPermutations = function(array, size) {
var result = [];
var generatePermutations = function (t, i) {
if (t.length === size) {
result.push(t);
result.push(t.slice().reverse());
return;
}
if (i >= array.length) {
return;
}
generatePermutations(t.concat(array[i]), i + 1);
generatePermutations(t, i + 1);
};
generatePermutations([], 0);
return result;
};
Condorcet.showCondorcetWinner = function (method, optionArray, listOfLists) {
var comparePairs = function () {
var pairs = getPermutations(optionArray, 2);
var pairDict = {};
pairs.forEach(function (pair) {
pairDict[pair] = 0;
listOfLists.forEach(function(optionList) {
var idx1 = optionList.indexOf(pair[0]);
var idx2 = optionList.indexOf(pair[1]);
// Put missing options as last in the array
if (idx1 === -1) { idx1 = Infinity; }
if (idx2 === -1) { idx2 = Infinity; }
if (idx1 < idx2) { pairDict[pair] ++; }
});
});
var pathDictionary = {};
//Adds winner of each pairwise comparison to path
pairs.forEach(function (pair) {
var key1 = [pair[0], pair[1]].join();
var key2 = [pair[1], pair[0]].join();
if (pairDict[key1] > pairDict[key2]) {
pathDictionary[key1] = pairDict[key1] - pairDict[key2];
} else if (pairDict[key2] > pairDict[key1]) {
pathDictionary[key2] = pairDict[key2] - pairDict[key1];
}
});
return pathDictionary;
};
var schulzeMethod = function(optionArray) {
var findWeakestPath = function(optionArray) {
var pathDictionary = comparePairs(optionArray);
//Iterates through paths between two options comparing the weakest edge to current score
optionArray.forEach(function(option1) {
optionArray.forEach(function(option2) {
if (option1 === option2) {
return;
} else {
optionArray.forEach(function(option3){
if (option1 === option3 || option2 === option3) {
return;
} else {
var key1 = [option2, option3].join();
var key2 = [option2, option1].join();
var key3 = [option1, option3].join();
var score;
if (!pathDictionary[key1]) {
score = 0;
} else {
score = pathDictionary[key1];
}
var path1;
if (pathDictionary[key2]) {
path1 = pathDictionary[key2];
} else {
path1 = 0;
}
var path2;
if (pathDictionary[key3]) {
path2 = pathDictionary[key3];
} else {
path2 = 0;
}
var weakestPath = Math.min(path1, path2);
if (weakestPath > score) {
pathDictionary[key1] = weakestPath;
}
}
});
}
});
});
return pathDictionary;
};
var calculateWinner = function() {
var pathDictionary = findWeakestPath(optionArray);
//Calculate scores for each option and select winner
var winningMatches = {};
optionArray.forEach(function(option){
winningMatches[option] = 0;
});
Object.keys(pathDictionary).forEach(function(pair) {
var option1 = pair.split(',')[0];
var option2 = pair.split(',')[1];
winningMatches[option1] ++;
winningMatches[option2] --;
Object.keys(pathDictionary).forEach(function(p) {
if (p.split(',')[1] === option1 && pathDictionary[p] > pathDictionary[pair]) {
winningMatches[option1] --;
}
});
});
var rankedResults = {};
Object.keys(winningMatches).forEach(function(option) {
if (rankedResults[winningMatches[option]]) {
rankedResults[winningMatches[option]].push(option);
} else {
rankedResults[winningMatches[option]] = [option];
}
});
var losing = [];
var winning = [];
var winningPairs = [];
Object.keys(pathDictionary).forEach(function(pair) {
var option1 = pair.split(',')[0];
var option2 = pair.split(',')[1];
losing.push(option2);
winning.push(option1);
});
Object.keys(pathDictionary).forEach(function(pair) {
var option1 = pair.split(',')[0];
if (!losing.includes(option1)) {
winningPairs.push(option1);
}
});
var winner;
var winnersArray = [];
winningPairs.forEach(function(pair) {
if (!winnersArray.includes(pair)) {
winnersArray.push(pair);
}
});
var sortedRankedResults = [];
Object.keys(rankedResults).map(Number).sort(function(a, b){return a - b;}).forEach(function(score) {
sortedRankedResults.push([score, rankedResults[score]]);
});
if (winnersArray.length !== 0) {
winner = winnersArray;
} else if (winnersArray.length === 0 && Object.keys(rankedResults).length === 1) {
winner = [];
} else {
var maxScore = Math.max.apply(null, Object.keys(rankedResults));
winner = rankedResults[maxScore];
}
return [winner, sortedRankedResults];
};
return(calculateWinner());
};
var rankedPairsMethod = function (optionArray) {
//'Locks' pairwise comparisons which do not create a beatpath cycle
var pathDictionary = comparePairs();
var items = Object.keys(pathDictionary).map(function(key) {
return [key, pathDictionary[key]];
});
var itemsDict = {};
Object.values(items).forEach(function(value) {
if (itemsDict[value[1]]) {
itemsDict[value[1]].push(value[0]);
} else {
itemsDict[value[1]] = [];
itemsDict[value[1]].push(value[0]);
}
});
var sortedArray = [];
Object.keys(itemsDict).map(Number).sort(function(a, b){return a - b;}).forEach(function(score) {
sortedArray.push([score, itemsDict[score]]);
});
var rankingDict = {};
var winning = [];
var losing = [];
sortedArray.forEach(function(arr) {
arr[1].forEach(function(pair) {
winning.push(pair.split(',')[0]);
losing.push(pair.split(',')[1]);
});
});
optionArray.forEach(function(option){
rankingDict[option] = 0;
});
var rankingArray = [];
Object.values(rankingDict).forEach(function(pair) {
if (!rankingArray .includes(pair)) {
rankingArray .push(pair);
}
});
if (rankingArray.length <= 1) {
optionArray.forEach(function(option){
if (winning.includes(option)) {
rankingDict[option] ++;
}
if (losing.includes(option)) {
rankingDict[option] --;
}
});
}
var rankedResults = {};
Object.keys(rankingDict).forEach(function(option) {
if (rankedResults[rankingDict[option]]) {
rankedResults[rankingDict[option]].push(option);
} else {
rankedResults[rankingDict[option]] = [option];
}
});
var rankedKeys = Object.keys(rankedResults).map(function(key) {
return [key, rankedResults[key]];
});
var finalsortedItems = {};
Object.values(rankedKeys).forEach(function(value){
if (finalsortedItems[value[1]]) {
finalsortedItems[value[1]].push(value[0]);
} else {
finalsortedItems[value[1]] = [value[0]];
}
});
var finalRankingArray = [];
Object.values(rankingDict).forEach(function(pair) {
if (!finalRankingArray.includes(pair)) {
finalRankingArray.push(pair);
}
});
var winner;
if (finalRankingArray.length > 1) {
var maxScore = Math.max.apply(null, Object.keys(rankedResults));
winner = rankedResults[maxScore];
} else if (sortedArray.length === 0) {
winner = [];
} else {
var topScoring = sortedArray.slice(-1);
topScoring.forEach(function(pair) {
pair[1].forEach(function(p) {
var index = winning.indexOf(p.split(',')[1]);
winning.splice(index, 1);
});
});
var winnerArray = [];
winning.forEach(function(option){
if (!winnerArray.includes(option)) {
winnerArray.push(option);
}
});
if (winnerArray.length === optionArray.length) {
winner = [];
} else {
winner = winnerArray;
}
}
var sortedRankedResults = [];
Object.keys(rankedResults).map(Number).sort(function(a, b){return a - b;}).forEach(function(score) {
sortedRankedResults.push([score, rankedResults[score]]);
});
return [winner, sortedRankedResults];
};
var pickMethod = function (optionArray){
if (method === "schulze") {
return schulzeMethod(optionArray);
} else if (method === "ranked") {
return rankedPairsMethod(optionArray);
}
};
return pickMethod(optionArray);
};
return Condorcet;
});

View File

@ -6,6 +6,7 @@ define([
'/common/sframe-app-framework.js',
'/common/toolbar.js',
'/form/export.js',
'/form/condorcet.js',
'/bower_components/nthen/index.js',
'/common/sframe-common.js',
'/common/common-util.js',
@ -52,6 +53,7 @@ define([
Framework,
Toolbar,
Exporter,
Condorcet,
nThen,
SFCommon,
Util,
@ -73,6 +75,7 @@ define([
Sortable
)
{
var APP = window.APP = {
blocks: {}
};
@ -140,7 +143,6 @@ define([
evOnSave.fire();
}, 500));
}
var type, typeSelect;
if (opts.type) {
// Messages.form_text_text.form_text_number.form_text_url.form_text_email
@ -201,6 +203,15 @@ define([
saveAndCancel
];
};
var editDateOptions = function (cb) {
var saveAndCancel = saveAndCancelOptions(cb);
return [
saveAndCancel
];
};
var editOptions = function (v, isDefaultOpts, setCursorGetter, cb, tmp) {
var evOnSave = Util.mkEvent();
@ -650,7 +661,6 @@ define([
if (typeSelect) {
res.type = typeSelect.getValue();
}
return res;
};
@ -2013,6 +2023,75 @@ define([
},
icon: h('i.cptools.cptools-form-grid-radio')
},
date: {
defaultOpts: {
type: 'date',
},
get: function (opts, a, n, evOnChange) {
opts = Util.clone(TYPES.date.defaultOpts);
var tag = h('input');
Flatpickr(tag, {
enableTime: true,
time_24hr: is24h,
dateFormat: dateFormat,
});
var $tag = $(tag);
$tag.on('change keypress', Util.throttle(function () {
evOnChange.fire();
}, 500));
return {
tag: tag,
isEmpty: function () { return !$tag.val().trim(); },
getValue: function () {
var invalid = $tag.is(':invalid');
if (invalid) { return; }
return $tag.val();
},
setValue: function (val) { $tag.val(val); },
setEditable: function (state) {
if (state) { $tag.removeAttr('disabled'); }
else { $tag.attr('disabled', 'disabled'); }
},
edit: function (cb) {
return editDateOptions(cb);
},
reset: function () { $tag.val(''); }
};
},
printResults: function (answers, uid) { // results text
var results = [];
var empty = 0;
var tally = {};
var isEmpty = function (answer) {
return !answer || !answer.trim();
};
Object.keys(answers).forEach(function (author) {
var obj = answers[author];
var answer = obj.msg[uid];
if (isEmpty(answer)) { return empty++; }
Util.inc(tally, answer);
});
//if (max < 2) { // there are no duplicates, so just return text
results.push(getEmpty(empty));
Object.keys(answers).forEach(function (author) {
var obj = answers[author];
var answer = obj.msg[uid];
if (!answer || !answer.trim()) { return empty++; }
results.push(h('div.cp-charts-row', h('span.cp-value', answer)));
});
return h('div.cp-form-results-contained', h('div.cp-charts.cp-text-table', results));
},
icon: h('i.cp-calendar-active.fa.fa-calendar')},
checkbox: {
compatible: ['radio', 'checkbox', 'sort'],
defaultOpts: {
@ -2420,7 +2499,8 @@ define([
});
sortNode(toSort);
reorder();
}
},
};
},
@ -2442,7 +2522,6 @@ define([
Util.inc(count, el, score);
});
});
var rendered = renderTally(count, empty, showBars);
return h('div.cp-charts.cp-bar-table', rendered);
},
@ -2786,7 +2865,9 @@ define([
var switchMode = h('button.btn.btn-secondary', Messages.form_showIndividual);
$controls.hide().append(switchMode);
var show = function (answers, header) {
var order = getFullOrder(content);
var elements = order.map(function (uid) {
var block = form[uid];
@ -2801,8 +2882,136 @@ define([
answers: answers
});
var q = h('div.cp-form-block-question', block.q || Messages.form_default);
var showCondorcetWinner = function(answers, opts, condorcetMethod, uid) {
var _answers = parseAnswers(answers);
var optionArray = [];
opts.values.forEach(function (option) {
optionArray.push(option.v);
});
var listOfLists = [];
Object.keys(_answers).forEach(function(a) {
if (_answers[a].msg[uid]) {
listOfLists.push(_answers[a].msg[uid]);
}
});
try {
if (listOfLists.length) {
return Condorcet.showCondorcetWinner(condorcetMethod, optionArray, listOfLists);
}
} catch (e) {
console.error(e);
return [];
}
};
var condorcetWinnerDiv = h('div.cp-form-block-content');
var condorcetMethod = 'schulze';
try {
if (type === "sort" && summary) {
var calculateCondorcet = function() {
var condorcetResults = h('span');
var c = showCondorcetWinner(answers, block.opts, condorcetMethod, uid);
if (!c) { return; }
var condorcetWinner = c[0];
var rankedResults = c[1];
if (!condorcetWinner || !condorcetResults) { return; }
if (condorcetWinner.length > 1) {
condorcetResults.append(h('span', condorcetWinner.join(', ')));
} else if (condorcetWinner.length === 1 ) {
condorcetResults.append(h('span', condorcetWinner));
} else {
condorcetResults.append(h('span', Messages.form_noCondorcetWinner));
}
var detailedResults = rankedResults.reverse().map(function(result) {
if (result.length > 1) {
return result[1].join(', ') + ' : ' + result[0];
} else {
return result[1] + ' : ' + result[0];
}
});
return [condorcetResults, detailedResults];
};
var dropdownOpts = [{
key: 'schulze',
str: Messages.form_condorcetSchulze
}, {
key: 'ranked',
str: Messages.form_condorcetRanked
}];
var options = dropdownOpts.map(function (t) {
return {
tag: 'a',
attributes: {
'class': 'cp-form-type-value',
'data-value': t.key,
'href': '#',
},
content: t.str
};
});
var dropdownConfig = {
text: '', // Button initial text
options: options,
isSelect: true,
caretDown: true,
buttonCls: 'btn btn-secondary'
};
var typeSelect = UIElements.createDropdown(dropdownConfig);
typeSelect.setValue(condorcetMethod);
var evOnChange = Util.mkEvent();
typeSelect.onChange.reg(evOnChange.fire);
var method = h('div.cp-dropdown-container', typeSelect[0]);
condorcetWinnerDiv = h('div.cp-form-edit-type');
var detailsContainer, condorcetWinner;
var condorcetResult = calculateCondorcet();
if (condorcetResult) {
condorcetWinner = h('span#cp-condorcet-winner', condorcetResult[0]);
detailsContainer = h('details#cp-condorcet-details', [
h('summary', Messages.form_showDetails),
Messages.form_condorcetExtendedDisplay,
h('div', condorcetResult[1].join(', '))
]);
}
evOnChange.reg(function () {
$('#cp-condorcet-winner').empty();
$('#cp-condorcet-details').empty();
condorcetMethod = typeSelect.getValue();
var condorcetResult = calculateCondorcet();
if (!condorcetResult || !condorcetResult[0] || !condorcetResult[1]) {
return;
}
$('#cp-condorcet-winner').replaceWith(h('span#cp-condorcet-winner', condorcetResult[0]));
$('#cp-condorcet-details').replaceWith(h('details#cp-condorcet-details', [
h('summary', Messages.form_showDetails),
Messages.form_condorcetExtendedDisplay,
h('div', condorcetResult[1].join(', '))
]));
});
condorcetWinnerDiv.append(h('div.cp-form-result-details', [
h('span', Messages.form_showCondorcetMethod),
method,
h('span', Messages.form_showCondorcetWinner, condorcetWinner),
detailsContainer
]));
}
} catch (err) {
console.error(err);
}
var q = h('div.cp-form-block-question', block.q || Messages.form_default);
//Messages.form_type_checkbox.form_type_input.form_type_md.form_type_multicheck.form_type_multiradio.form_type_poll.form_type_radio.form_type_sort.form_type_textarea.form_type_section
return h('div.cp-form-block', [
h('div.cp-form-block-type', [
@ -2811,6 +3020,7 @@ define([
]),
q,
h('div.cp-form-block-content', print),
condorcetWinnerDiv,
]);
});
$results.empty().append(elements);
@ -2818,6 +3028,7 @@ define([
};
show(answers);
if (APP.isEditor || APP.isAuditor) { $controls.show(); }
var $s = $(switchMode).click(function () {
@ -3463,7 +3674,6 @@ define([
if (!data.isEmpty) { return; }
if (!block) { return; }
if (!block.opts || !block.opts.required) { return; }
// Don't require questions that are in a hidden section
var section = getSectionFromQ(content, uid);
if (section.uid) {
@ -3698,6 +3908,7 @@ define([
uid: uid,
tmp: temp && temp[uid]
});
if (!data) { return; }
data.uid = uid;
if (answers && answers[uid] && data.setValue) { data.setValue(answers[uid]); }
@ -3767,6 +3978,7 @@ define([
data.editing = true;
}
var changeType;
if (editable) {
// Drag handle
@ -3846,6 +4058,7 @@ define([
h('span', Messages['form_type_'+type])
]);
// Values
if (data.edit) {
var edit = h('button.btn.btn-default.cp-form-edit-button', [
@ -4597,7 +4810,6 @@ define([
refreshEndDate();
});
var confirmContent = h('div', [
h('div', Messages.form_setEnd),
h('div.cp-form-input-block', [datePicker, save, cancel]),
]);
$button.after(confirmContent);