From fe03da5bddfc1c5aba03144bf2c8d76854c630bc Mon Sep 17 00:00:00 2001 From: yflory Date: Wed, 25 Aug 2021 17:32:10 +0200 Subject: [PATCH] Conditional answers prototype --- www/form/app-form.less | 36 +++ www/form/inner.js | 543 +++++++++++++++++++++++++++++++++++++++-- 2 files changed, 554 insertions(+), 25 deletions(-) diff --git a/www/form/app-form.less b/www/form/app-form.less index e22f3030d..658784297 100644 --- a/www/form/app-form.less +++ b/www/form/app-form.less @@ -10,6 +10,7 @@ @bg-color: @colortheme_apps[form] ); + display: flex; flex-flow: column; font: @colortheme_app-font; @@ -161,6 +162,41 @@ margin-left: 5px; } } + + + .cp-form-conditional { + .cp-form-condition { + display: flex; + align-items: center; + justify-content: center; + & > *:not(:first-child) { + margin-left: 40px; + } + .cp-dropdown-container button { + max-width: 200px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + } + .cp-form-condition-rule { + padding-bottom: 20px; + margin-bottom: 20px; + border-bottom: 1px solid @cryptpad_text_col; + } + } + + div.cp-form-section-sortable { + min-height: 300px; + border: 4px dotted white; // XXX + padding: 20px; + background: @cp_app-bg; + .cp-form-creator-add-inline { + button[data-type="section"] { + display: none; + } + } + } } div.cp-form-creator-content, div.cp-form-creator-results { max-width: 1000px; diff --git a/www/form/inner.js b/www/form/inner.js index 1a776facc..f123f015d 100644 --- a/www/form/inner.js +++ b/www/form/inner.js @@ -74,6 +74,10 @@ define([ var is24h = UIElements.is24h(); var dateFormat = "Y-m-d H:i"; var timeFormat = "H:i"; + + var evCheckConditions = Util.mkEvent(); + var evShowConditions = Util.mkEvent(); + if (!is24h) { dateFormat = "Y-m-d h:i K"; timeFormat = "h:i K"; @@ -848,6 +852,7 @@ define([ }).filter(Boolean); }; + Messages.form_type_section = "Conditional section"; // XXX var STATIC_TYPES = { md: { defaultOpts: { @@ -964,6 +969,55 @@ define([ printResults: function () { return; }, icon: h('i.cptools.cptools-form-page-break') }, + section: { + defaultOpts: { + questions: [] + }, + get: function (opts, a, n, ev, block) { + var sortable = h('div.cp-form-section-sortable'); + var tag = h('div.cp-form-section-edit', [ + h('i.fa.fa-question'), + h('span', Messages.form_type_section), + h('hr'), + sortable + ]); + if (APP.isEditor) { + if (!opts) { opts = block.opts = STATIC_TYPES.section.defaultOpts; } + Sortable.create(sortable, { + group: { + name: 'nested', + put: function (to, from, el) { + // Make sure sections dan't be dropped into other sections + return $(el).attr('data-type') !== 'section'; + } + }, + direction: "vertical", + filter: "input, button, .CodeMirror, .cp-form-type-sort, .cp-form-block-type.editable", + preventOnFilter: false, + draggable: ".cp-form-block", + //forceFallback: true, + fallbackTolerance: 5, + onStart: function () { + var $container = $('div.cp-form-creator-content'); + $container.find('.cp-form-creator-add-inline').remove(); + }, + store: { + set: function (s) { + opts.questions = s.toArray(); + setTimeout(APP.framework.localChange); + if (APP.updateAddInline) { APP.updateAddInline(); } + } + } + }); + } + return { + tag: tag, + noViewMode: true + }; + }, + printResults: function () { return; }, + icon: h('i.fa.fa-question') + }, }; var arrayMax = function (A) { @@ -1157,7 +1211,7 @@ define([ var cursorGetter; var setCursorGetter = function (f) { cursorGetter = f; }; $(tag).find('input[type="radio"]').on('change', function () { - evOnChange.fire(); + evOnChange.fire(false, false, true); }); return { tag: tag, @@ -2435,6 +2489,64 @@ define([ reset, send ]); }; + var getSections = function (content) { + var uids = Object.keys(content.form).filter(function (uid) { + return content.form[uid].type === 'section'; + }); + return uids; + }; + var getSectionFromQ = function (content, uid) { + var arr = content.order; + var idx = content.order.indexOf(uid); + if (idx === -1) { // If it's not in the main array, check in sections + getSections(content).some(function (_uid) { + var block = content.form[_uid]; + if (!block.opts || !Array.isArray(block.opts.questions)) { return; } + var _idx = block.opts.questions.indexOf(uid); + if (_idx !== -1) { + arr = block.opts.questions; + idx = _idx; + return true; + } + }); + } + + return { + arr: arr, + idx: idx + }; + }; + var removeQuestion = function (content, uid) { + delete content.form[uid]; + var idx = content.order.indexOf(uid); + if (idx !== -1) { + content.order.splice(idx, 1); + } else { + getSections(content).some(function (_uid) { + var block = content.form[_uid]; + if (!block.opts || !Array.isArray(block.opts.questions)) { return; } + var _idx = block.opts.questions.indexOf(uid); + if (_idx !== -1) { + block.opts.questions.splice(_idx, 1); + return true; + } + }); + } + }; + var getFullOrder = function (content) { + var order = content.order.slice(); + getSections(content).forEach(function (uid) { + var block = content.form[uid]; + if (!block.opts || !Array.isArray(block.opts.questions)) { return; } + var idx = order.indexOf(uid); + if (idx === -1) { return; } + idx++; + block.opts.questions.forEach(function (el, i) { + order.splice(idx+i, 0, el); + }); + }); + return order; + }; var updateForm = function (framework, content, editable, answers, temp) { var $container = $('div.cp-form-creator-content'); if (!$container.length) { return; } // Not ready @@ -2479,28 +2591,45 @@ define([ } - var getFormCreator = function (uid) { + var getFormCreator = function (uid, inSection) { if (!APP.isEditor) { return; } var full = !uid; + var arr = content.order; var idx = content.order.indexOf(uid); + if (!full) { + if (inSection) { + var section = content.form[uid]; + section.opts = section.opts || STATIC_TYPES.section.opts; + arr = section.opts.questions; + } else { + var obj = getSectionFromQ(content, uid); + arr = obj.arr; + idx = obj.idx; + } + } var addControl = function (type) { var btn = h('button.btn.btn-secondary', { - title: full ? '' : Messages['form_type_'+type] + title: full ? '' : Messages['form_type_'+type], + 'data-type': type }, [ (TYPES[type] || STATIC_TYPES[type]).icon.cloneNode(), full ? h('span', Messages['form_type_'+type]) : undefined ]); $(btn).click(function () { var uid = Util.uid(); + + // Make sure we can't create a section inside another one + if (type === 'section' && arr !== content.order) { return; } + content.form[uid] = { //q: Messages.form_default, //opts: opts type: type, }; - if (full) { - content.order.push(uid); + if (full || inSection) { + arr.push(uid); } else { - content.order.splice(idx, 0, uid); + arr.splice(idx, 0, uid); } framework.localChange(); updateForm(framework, content, true); @@ -2541,19 +2670,29 @@ define([ }; - var updateAddInline = function () { + var updateAddInline = APP.updateAddInline = function () { $container.find('.cp-form-creator-add-inline').remove(); + // Add before existing question $container.find('.cp-form-block').each(function (i, el) { var $el = $(el); var uid = $el.attr('data-id'); $el.before(getFormCreator(uid)); }); + // Add to section + $container.find('.cp-form-section-sortable').each(function (i, el) { + var $el = $(el); + var uid = $el.closest('.cp-form-block').attr('data-id'); + $el.append(getFormCreator(uid, true)); + }); }; var elements = []; var n = 1; // Question number - content.order.forEach(function (uid) { + + var order = getFullOrder(content); + + order.forEach(function (uid, blockIdx) { var block = form[uid]; var type = block.type; var model = TYPES[type] || STATIC_TYPES[type]; @@ -2570,7 +2709,7 @@ define([ name = user.name; } - var data = model.get(block.opts, _answers, name, evOnChange); + var data = model.get(block.opts, _answers, name, evOnChange, block); if (!data) { return; } data.uid = uid; if (answers && answers[uid] && data.setValue) { data.setValue(answers[uid]); } @@ -2579,7 +2718,10 @@ define([ elements.push(data); return; } - + if (data.noViewMode && !editable) { + elements.push(data); + return; + } Messages.form_required = "Required"; // XXX var requiredTag; @@ -2606,7 +2748,7 @@ define([ Messages.form_required_on = "Required answer"; Messages.form_required_off = "Optional answer"; // Required radio displayed only for types that have an "isEmpty" function - var requiredDiv; + var requiredDiv, conditionalDiv; if (APP.isEditor && !isStatic && data.isEmpty) { if (!block.opts) { block.opts = TYPES[type].defaultOpts; } var isRequired = Boolean(block.opts.required); @@ -2633,6 +2775,272 @@ define([ }); } + if (APP.isEditor && blockIdx && type === "section") { + var getConditionsValues = function () { + order = getFullOrder(content); + var blockIdx = order.indexOf(uid); + var blocks = order.slice(0, blockIdx); // Get all previous questions + var values = blocks.map(function(uid) { + var block = form[uid]; + var type = block.type; + if (['radio'].indexOf(type) === -1) { return; } + if (type === 'radio') { + return { + uid: uid, + q: block.q || Messages.form_default, + values: block.opts ? block.opts.values : TYPES.radio.defaultOpts.values + }; + } + }).filter(Boolean); + return values; + }; + Messages.form_conditional_add = "Add condition OR"; + Messages.form_conditional_addAnd = "Add condition AND"; + var addCondition = h('button.btn.btn-default', [ + h('i.fa.fa-plus'), + h('span', Messages.form_conditional_add) + ]); + var $addC = $(addCondition); + var getConditions; + var getAddAndButton = function ($container, rules) { + var btn = h('button.btn.btn-default.cp-form-add-and', [ + h('i.fa.fa-plus'), + h('span', Messages.form_conditional_addAnd) + ]); + $(btn).click(function () { + getConditions($container, true, rules); + }); + $container.append(btn); + }; + getConditions = function ($container, isNew, rules, condition) { + var content = h('div.cp-form-condition'); + var $content = $(content); + var values = getConditionsValues(); + var qOptions = values.map(function (obj) { + return { + tag: 'a', + attributes: { + 'class': 'cp-form-condition-question', + 'data-value': obj.uid, + 'href': '#', + }, + content: obj.q + }; + }); + var qConfig = { + text: '', // Button initial text + options: qOptions, // Entries displayed in the menu + isSelect: true, + caretDown: true, + buttonCls: 'btn btn-secondary' + }; + var qSelect = UIElements.createDropdown(qConfig); + Messages.form_condition_is = 'is'; // XXX + Messages.form_condition_isnot = 'is not'; // XXX + + var isOn = !condition || condition.is !== 0; + var iOptions = [{ + tag: 'a', + attributes: { + 'data-value': 1, + 'href': '#', + }, + content: Messages.form_condition_is + }, { + tag: 'a', + attributes: { + 'data-value': 0, + 'href': '#', + }, + content: Messages.form_condition_isnot + }]; + var iConfig = { + options: iOptions, // Entries displayed in the menu + isSelect: true, + caretDown: true, + buttonCls: 'btn btn-secondary' + }; + var iSelect = UIElements.createDropdown(iConfig); + iSelect.setValue(isOn ? 1 : 0); + $(iSelect).hide(); + + $content.append(qSelect).append(iSelect); + if ($container.find('button.cp-form-add-and').length) { + $container.find('button.cp-form-add-and').before($content); + } else { + $container.append($content); + } + + var isChange; + qSelect.onChange.reg(function (prettyVal, val, init) { + $(iSelect).show(); + var res; + values.some(function (obj) { + if (String(obj.uid) === String(val)) { + res = obj.values; + return true; + } + }); + $content.find('.cp-form-condition-values').remove(); + if (!res) { return; } + var vOptions = res.map(function (str) { + return { + tag: 'a', + attributes: { + 'class': 'cp-form-condition-value', + 'data-value': str, + 'href': '#', + }, + content: str + }; + }); + var vConfig = { + text: '', // Button initial text + options: vOptions, // Entries displayed in the menu + //left: true, // Open to the left of the button + //container: $(type), + isSelect: true, + caretDown: true, + buttonCls: 'btn btn-secondary' + }; + var vSelect = UIElements.createDropdown(vConfig); + vSelect.addClass('cp-form-condition-values'); + if ($content.find('.cp-condition-remove').length) { + $content.find('.cp-condition-remove').before(vSelect); + } else { + $content.append(vSelect); + } + + var onChange = function () { + var w = block.opts.when = block.opts.when || []; + + condition = condition || {}; + condition.q = val; + condition.is = Number(iSelect.getValue()); + condition.v = vSelect.getValue(); + + var wasNew = isNew; + if (isNew) { + if (!Array.isArray(rules)) { // new set of rules (OR) + rules = [condition]; + w.push(rules); + getAddAndButton($container, rules); + } else { + rules.push(condition); + } + isNew = false; + } + + framework.localChange(); + framework._.cpNfInner.chainpad.onSettle(function () { + UI.log(Messages.saved); + if (wasNew) { $addC.show(); } + }); + + }; + + if (isChange) { iSelect.onChange.unreg(isChange); } + isChange = function () { + if (!vSelect.getValue()) { return; } + onChange(); + }; + iSelect.onChange.reg(isChange); + + vSelect.onChange.reg(function () { + if (!$content.find('.cp-condition-remove').length) { + var remove = h('button.btn.btn-danger-alt.cp-condition-remove', [ + h('i.fa.fa-times') + ]); + $(remove).click(function () { + var w = block.opts.when = block.opts.when || []; + var deleteRule = false; + if (rules.length === 1) { + var rIdx = w.indexOf(rules); + w.splice(rIdx, 1); + deleteRule = true; + } else { + var idx = rules.indexOf(condition); + rules.splice(idx, 1); + } + framework.localChange(); + framework._.cpNfInner.chainpad.onSettle(function () { + if (deleteRule) { + $content.closest('.cp-form-condition-rule').remove(); + return; + } + $content.remove(); + }); + }).appendTo($content); + } + onChange(); + }); + + if (condition && condition.v && init) { + vSelect.setValue(condition.v); + vSelect.onChange.fire(condition.v, condition.v); + } + + }); + if (condition && condition.q) { + qSelect.setValue(condition.q); // XXX check if exists? or integrity + qSelect.onChange.fire(condition.q, condition.q, true); + } + + + }; + Messages.form_conditional = "Only show this question when:"; // XXX + + conditionalDiv = h('div.cp-form-conditional', [ + h('div', Messages.form_conditional), + addCondition + ]); + var $condition = $(conditionalDiv); + var redraw = function () { + var w = block.opts.when = block.opts.when || []; + w.forEach(function (rules) { + var rulesC = h('div.cp-form-condition-rule'); + var $rulesC = $(rulesC); + getAddAndButton($rulesC, rules); + rules.forEach(function (obj) { + getConditions($rulesC, false, rules, obj); + }); + $addC.before($rulesC); // XXX + }); + }; + redraw(); + + $addC.click(function () { + $addC.hide(); + var rulesC = h('div.cp-form-condition-rule'); + var $rulesC = $(rulesC); + getConditions($rulesC, true); + $addC.before($rulesC); + }); + if (getConditionsValues().length) { + $condition.show(); + } else { + $condition.hide(); + } + evShowConditions.reg(function () { + if (getConditionsValues().length) { + $condition.show(); + } else { + $condition.hide(); + } + }); + evCheckConditions.reg(function (_uid) { + if (uid !== _uid) { return; } + // If our conditions are invalid, redraw them + if (getConditionsValues().length) { + $condition.show(); + } else { + $condition.hide(); + } + $condition.find('.cp-form-condition-rule').remove(); + redraw(); + }); + } + var changeType; if (editable) { // Drag handle @@ -2699,9 +3107,7 @@ define([ classes: 'btn-danger', new: true }, function () { - delete content.form[uid]; - var idx = content.order.indexOf(uid); - content.order.splice(idx, 1); + removeQuestion(content, uid); $('.cp-form-block[data-id="'+uid+'"]').remove(); framework.localChange(); updateAddInline(); @@ -2763,7 +3169,6 @@ define([ h('span', Messages['form_type_'+type]) ]); - //Messages.form_changeType = "Change type"; // XXX Messages.form_changeTypeConfirm = "Select the new type of this question and click OK."; // XXX Messages.form_breakAnswers = "Changing the type may corrupt existing answers"; if (Array.isArray(model.compatible)) { @@ -2822,13 +3227,15 @@ define([ } var editableCls = editable ? ".editable" : ""; elements.push(h('div.cp-form-block'+editableCls, { - 'data-id':uid + 'data-id':uid, + 'data-type':type }, [ APP.isEditor ? dragHandle : undefined, changeType, isStatic ? undefined : q, h('div.cp-form-block-content', [ APP.isEditor && !isStatic ? requiredDiv : undefined, + APP.isEditor ? conditionalDiv : undefined, APP.isEditor && !isStatic ? previewDiv : undefined, data.tag, editButtons @@ -2896,10 +3303,40 @@ define([ } $container.empty().append(_content); + + getSections(content).forEach(function (uid) { + var block = content.form[uid]; + if (!block.opts || !Array.isArray(block.opts.questions)) { return; } + var $block = $container.find('.cp-form-block[data-id="'+uid+'"] .cp-form-section-sortable'); + block.opts.questions.forEach(function (_uid) { + $container.find('.cp-form-block[data-id="'+_uid+'"]').appendTo($block); + }); + }); updateAddInline(); + // In view mode, hide sections when conditions aren't met + evOnChange.reg(function (reset, save, condition) { + if (!reset && !condition) { return; } + var results = getFormResults(); + getSections(content).forEach(function (uid) { + var block = content.form[uid]; + if (!block.opts || !Array.isArray(block.opts.questions) || !block.opts.when) { return; } + var w = block.opts.when; + var show = !w.length || w.some(function (rules) { + return rules.every(function (rule) { + return rule.is ? results[rule.q] === rule.v + : results[rule.q] !== rule.v; + }); + }); + block.opts.questions.forEach(function (_uid) { + $container.find('.cp-form-block[data-id="'+_uid+'"]').toggle(show); + }); + }); + }); + if (editable) { APP.mainSortable = Sortable.create($container[0], { + group: 'nested', direction: "vertical", filter: "input, button, .CodeMirror, .cp-form-type-sort, .cp-form-block-type.editable", preventOnFilter: false, @@ -2912,7 +3349,7 @@ define([ store: { set: function (s) { content.order = s.toArray(); - framework.localChange(); + setTimeout(framework.localChange); updateAddInline(); } } @@ -2931,7 +3368,7 @@ define([ // In view mode, add "Submit" and "reset" buttons $container.append(makeFormControls(framework, content, Boolean(answers), evOnChange)); - // In view mode, tell the user and answers are forced to be anonymous or authenticated + // In view mode, tell the user if answers are forced to be anonymous or authenticated if (!APP.isEditor) { var infoTxt; var loggedIn = framework._.sfCommon.isLoggedIn(); @@ -3313,21 +3750,77 @@ define([ var checkIntegrity = function (getter) { if (!content.order || !content.form) { return; } var changed = false; - content.order.forEach(function (uid) { - if (!content.form[uid]) { - var idx = content.order.indexOf(uid); - content.order.splice(idx, 1); - changed = true; - } + var deduplicate = []; + // Check if the questions in the lists (content.order or sections) exist in + // content.form and remove duplicates + var check1 = function (arr) { + arr.forEach(function (uid) { + if (!content.form[uid] || deduplicate.indexOf(uid) !== -1) { + var idx = arr.indexOf(uid); + arr.splice(idx, 1); + changed = true; + return; + } + deduplicate.push(uid); + }); + }; + check1(content.order); + getSections(content).forEach(function (uid) { + var block = content.form[uid]; + if (!block.opts || !Array.isArray(block.opts.questions)) { return; } + check1(block.opts.questions); }); + + // Make sure all the questions are displayed in the main list or a section and add + // the missing ones Object.keys(content.form).forEach(function (uid) { - var idx = content.order.indexOf(uid); + var idx = deduplicate.indexOf(uid); if (idx === -1) { changed = true; content.order.push(uid); } }); + // Check if conditions on sections are valid + var order = getFullOrder(content); + getSections(content).forEach(function (uid) { + var block = content.form[uid]; + if (!block.opts || !Array.isArray(block.opts.when)) { return; } + var sectionIdx = order.indexOf(uid); + if (sectionIdx === -1) { return; } + var available = order.slice(0, sectionIdx); + var errors = false; + block.opts.when.forEach(function (rules) { + if (!Array.isArray(rules)) { + var idx = block.opts.when.indexOf(rules); + block.opts.when.splice(idx, 1); + errors = true; + return; + } + rules.forEach(function (obj) { + var idx = available.indexOf(obj.q); + // If this question doesn't exist before the section, remove the condition + if (!obj.q || idx === -1) { + var cIdx = rules.indexOf(obj); + rules.splice(cIdx, 1); + errors = true; + return; + } + }); + if (!rules.length) { + var rIdx = block.opts.when.indexOf(rules); + block.opts.when.splice(rIdx, 1); + errors = true; + } + }); + if (errors) { + evCheckConditions.fire(uid); + changed = true; + } + }); + + evShowConditions.fire(); + if (!getter && changed) { framework.localChange(); } };