From e6f0e4d68d034d035855d68f1f68f6ba3a7b229e Mon Sep 17 00:00:00 2001 From: lamtranb Date: Wed, 22 Mar 2023 18:18:35 +0700 Subject: [PATCH] Questionnaire: Drag and Drop "Sorting" question #323936 --- amd/build/sorting_drag_reorder.min.js | 13 + amd/build/sorting_drag_reorder.min.js.map | 1 + amd/build/sorting_reorder.min.js | 13 + amd/build/sorting_reorder.min.js.map | 1 + amd/src/sorting_drag_reorder.js | 347 +++++++++++ amd/src/sorting_reorder.js | 67 ++ .../mobile_view_activity.js} | 0 appjs/latest/mobile_view_activity.js | 73 +++ .../moodle2/backup_questionnaire_stepslib.php | 8 + .../restore_questionnaire_stepslib.php | 23 +- classes/edit_question_form.php | 14 + classes/output/mobile.php | 47 +- classes/output/renderer.php | 18 +- classes/privacy/provider.php | 7 + classes/question/question.php | 3 + classes/question/sorting.php | 585 ++++++++++++++++++ classes/questions_form.php | 3 +- classes/responsetype/response/response.php | 1 + classes/responsetype/responsetype.php | 4 +- classes/responsetype/sorting.php | 357 +++++++++++ complete.php | 3 + db/install.php | 7 + db/install.xml | 14 + db/mobile.php | 6 +- db/upgrade.php | 31 + lang/en/questionnaire.php | 21 + locallib.php | 32 +- preview.php | 6 +- questionnaire.class.php | 12 +- styles.css | 83 +++ styles_app.css | 5 + .../mobile/ionic3/sorting_question.mustache | 40 ++ .../mobile/ionic3/view_activity_page.mustache | 3 + .../mobile/latest/sorting_question.mustache | 52 ++ .../mobile/latest/view_activity_page.mustache | 3 + templates/question_container.mustache | 2 +- templates/question_sorting.mustache | 58 ++ templates/response_sorting.mustache | 35 ++ templates/results_sorting.mustache | 88 +++ templates/resultspdf_sorting.mustache | 90 +++ tests/behat/behat_mod_questionnaire.php | 45 ++ tests/behat/sorting_question.feature | 84 +++ tests/csvexport_test.php | 22 +- tests/generator/lib.php | 54 +- tests/questiontypes_test.php | 15 + tests/responsetypes_test.php | 60 ++ version.php | 2 +- 47 files changed, 2403 insertions(+), 55 deletions(-) create mode 100644 amd/build/sorting_drag_reorder.min.js create mode 100644 amd/build/sorting_drag_reorder.min.js.map create mode 100644 amd/build/sorting_reorder.min.js create mode 100644 amd/build/sorting_reorder.min.js.map create mode 100644 amd/src/sorting_drag_reorder.js create mode 100644 amd/src/sorting_reorder.js rename appjs/{uncheckother.js => ionic3/mobile_view_activity.js} (100%) create mode 100644 appjs/latest/mobile_view_activity.js create mode 100644 classes/question/sorting.php create mode 100644 classes/responsetype/sorting.php create mode 100644 templates/local/mobile/ionic3/sorting_question.mustache create mode 100644 templates/local/mobile/latest/sorting_question.mustache create mode 100644 templates/question_sorting.mustache create mode 100644 templates/response_sorting.mustache create mode 100644 templates/results_sorting.mustache create mode 100644 templates/resultspdf_sorting.mustache create mode 100644 tests/behat/sorting_question.feature diff --git a/amd/build/sorting_drag_reorder.min.js b/amd/build/sorting_drag_reorder.min.js new file mode 100644 index 00000000..ccc7413c --- /dev/null +++ b/amd/build/sorting_drag_reorder.min.js @@ -0,0 +1,13 @@ +/* + * Generic library to allow things in a vertical list to be re-ordered using drag and drop. + * + * To make a set of things draggable, create a new instance of this object passing the + * necessary config, as explained in the comment on the constructor. + * + * @package mod_questionnaire + * @copyright 2023 The Open University + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +define("mod_questionnaire/sorting_drag_reorder",["jquery","core/dragdrop","core/key_codes"],(function($,drag,keys){return function(config){var outer,inner,combined,dragStart=null,originalOrder=null,itemDragging=null,itemMoving=null,proxy=null,orderList=null,dragMove=function(){var list=itemDragging.closest(config.list),closestItem=null,closestDistance=null;if(list.find(config.item).each((function(index,element){var distance=distanceBetweenElements(element,proxy);(null===closestItem||distance.\n\n/*\n * Generic library to allow things in a vertical list to be re-ordered using drag and drop.\n *\n * To make a set of things draggable, create a new instance of this object passing the\n * necessary config, as explained in the comment on the constructor.\n *\n * @package mod_questionnaire\n * @copyright 2023 The Open University\n * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later\n */\n\n/**\n * @module questionnaire/sorting_drag_reorder\n */\ndefine([\n 'jquery',\n 'core/dragdrop',\n 'core/key_codes'\n], function(\n $,\n drag,\n keys\n) {\n\n /**\n * Constructor.\n *\n * To make a list draggable, create a new instance of this object, passing the necessary config.\n * For example:\n * {\n * // Selector for the list (or lists) to be reordered.\n * list: 'ul.my-list',\n *\n * // Selector, relative to the list selector, for the items that can be moved.\n * item: '> li',\n *\n * // The user actually drags a proxy object, which is constructed from this string,\n * // and then added directly as a child of . The token %%ITEM_HTML%% is\n * // replaced with the innerHtml of the item being dragged. The token %%ITEM_CLASS_NAME%%\n * // is replaced with the class attribute of the item being dragged. Because of this,\n * // the styling of the contents of your list item needs to work for the proxy, as well as\n * // for items in place in the context of the list. Your CSS also needs to ensure\n * // that this proxy has position: absolute. You probably want other styles, like a\n * // drop shadow. Using class osep-itemmoving might be all you need to do.\n * proxyHtml: '
%%ITEM_HTML%%
,\n *\n * // While the proxy is being dragged, this class is added to the item being moved.\n * // You can probably use \"some-class\" here.\n * itemMovingClass: \"some-class\",\n *\n * // This is a callback which, when called with the DOM node for an item,\n * // returns the string that uniquely identifies each item.\n * // Therefore, the result of the drag action will be represented by the array\n * // obtained by calling this method on each item in the list in order.\n * idGetter: function(item) { return $(node).data('id'); },\n *\n * // This is a callback which, when called with the DOM node for an item,\n * // returns a string that is the name of the item.\n * nameGetter: function(item) { return $(node).text(); },\n *\n * // Function that will be called when a re-order starts (optional, can be not set).\n * // Useful if you need to save information about the initial state.\n * // This function should have two parameters. The first will be a\n * // jQuery object for the list that was reordered, the second will\n * // be the jQuery object for the item moved - which will not yet have been moved.\n * // Note, it is quite possible for reorderStart to be called with no\n * // subsequent call to reorderDone.\n * reorderStart: function($list, $item) { ... }\n *\n * // Function that will be called when a drag has finished, and the list\n * // has been reordered. This function should have three parameters. The first will be\n * // a jQuery object for the list that was reordered, the second will be the jQuery\n * // object for the item moved, and the third will be the new order, which is\n * // an array of ids obtained by calling idGetter on each item in the list in order.\n * // This callback will only be called in the new order is actually different from the old order.\n * reorderDone: function($list, $item, newOrder) { ... }\n *\n * // Function that is always called when a re-order ends (optional, can be not set)\n * // whether the order has changed. Useful if you need to undo changes made\n * // in reorderStart, since reorderDone is only called if the new order is different\n * // from the original order.\n * reorderEnd: function($list, $item) { ... }\n * }\n *\n * There is a subtlety ( === hack?) that you can use. If you have items in your list that do not\n * have a drag handle, they are considered to be placeholders in otherwise empty containers.\n * See how block_userlinks does it, if this seems like it might be useful. nameGetter should return\n * the container name for these items.\n *\n * @param {Object} config As above.\n */\n return function(config) {\n var dragStart = null, // Information about when and where the drag started.\n originalOrder = null, // Array of ids.\n itemDragging = null, // Item being moved by dragging (jQuery object).\n itemMoving = null, // Item being moved using the accessible modal (jQuery object).\n proxy = null, // Drag proxy (jQuery object).\n orderList = null; // Order list (jQuery object).\n\n var startDrag = function(event, details) {\n orderList = $(config.list);\n\n dragStart = {\n time: new Date().getTime(),\n x: details.x,\n y: details.y\n };\n\n itemDragging = $(event.currentTarget).closest(config.itemInPage);\n\n if (typeof config.reorderStart !== 'undefined') {\n config.reorderStart(itemDragging.closest(config.list), itemDragging);\n }\n\n originalOrder = getCurrentOrder();\n proxy = $(config.proxyHtml.replace('%%ITEM_HTML%%', itemDragging.html())\n .replace('%%ITEM_CLASS_NAME%%', itemDragging.attr('class'))\n .replace('%%LIST_CLASS_NAME%%', orderList.attr('class')));\n\n $(document.body).append(proxy);\n proxy.css('position', 'absolute');\n proxy.css(itemDragging.offset());\n proxy.width(itemDragging.outerWidth());\n proxy.height(itemDragging.outerHeight());\n itemDragging.addClass(config.itemMovingClass);\n updateProxy(itemDragging);\n\n // Start drag.\n drag.start(event, proxy, dragMove, dragEnd);\n };\n\n var dragMove = function() {\n var list = itemDragging.closest(config.list);\n var closestItem = null;\n var closestDistance = null;\n list.find(config.item).each(function(index, element) {\n var distance = distanceBetweenElements(element, proxy);\n if (closestItem === null || distance < closestDistance) {\n closestItem = $(element);\n closestDistance = distance;\n }\n });\n\n if (closestItem[0] === itemDragging[0]) {\n return;\n }\n var offsetValue = 0;\n // Set offset depending on if item is being dragged downwards/upwards.\n if (midY(proxy) < midY(closestItem)) {\n offsetValue = 20;\n window.console.log(\"For midY(proxy) < midY(closestItem) offset is: \" + offsetValue);\n } else {\n offsetValue = -20;\n window.console.log(\"For midY(proxy) < midY(closestItem) offset is: \" + offsetValue);\n }\n if (midY(proxy) + offsetValue < midY(closestItem)) {\n itemDragging.insertBefore(closestItem);\n } else {\n itemDragging.insertAfter(closestItem);\n }\n updateProxy(itemDragging);\n };\n\n /**\n * Update proxy's position.\n * @param {jQuery} itemDragging\n */\n var updateProxy = function(itemDragging) {\n var list = itemDragging.closest('ol, ul');\n var items = list.find('li');\n var count = items.length;\n for (var i = 0; i < count; ++i) {\n if (itemDragging[0] === items[i]) {\n proxy.find('li').attr('value', i + 1);\n break;\n }\n }\n };\n\n /**\n * It outer and inner are two CSS selectors, which may contain commas,\n * then combine them safely. So combineSelectors('a, b', 'c, d')\n * gives 'a c, a d, b c, b d'.\n * @param {Selector} outer\n * @param {Selector} inner\n * @returns {string}\n */\n var combineSelectors = function(outer, inner) {\n var combined = [];\n outer.split(',').forEach(function(firstSelector) {\n inner.split(',').forEach(function(secondSelector) {\n combined.push(firstSelector.trim() + ' ' + secondSelector.trim());\n });\n });\n return combined.join(', ');\n };\n\n var dragEnd = function(x, y) {\n if (typeof config.reorderEnd !== 'undefined') {\n config.reorderEnd(itemDragging.closest(config.list), itemDragging);\n }\n\n var newOrder = getCurrentOrder();\n if (!arrayEquals(originalOrder, newOrder)) {\n // Order has changed, call the callback.\n config.reorderDone(itemDragging.closest(config.list), itemDragging, newOrder);\n } else if (new Date().getTime() - dragStart.time < 500 &&\n Math.abs(dragStart.x - x) < 10 && Math.abs(dragStart.y - y) < 10) {\n // This was really a click. Set the focus on the current item.\n itemDragging[0].focus();\n }\n proxy.remove();\n proxy = null;\n itemDragging.removeClass(config.itemMovingClass);\n itemDragging = null;\n dragStart = null;\n };\n\n /**\n * Items can be moved and placed using certain keys.\n * Tab for tabbing though and choose the item to be moved\n * space, arrow-right arrow-down for moving current element forewards.\n * arrow-right arrow-down for moving the current element backwards.\n * @param {Object} e the event\n * @param {jQuery} current the current moving item\n */\n var itemMovedByKeyboard = function(e, current) {\n switch (e.keyCode) {\n case keys.space:\n case keys.arrowRight:\n case keys.arrowDown:\n e.preventDefault();\n e.stopPropagation();\n var next = current.next();\n if (next.length) {\n next.insertBefore(current);\n }\n break;\n\n case keys.arrowLeft:\n case keys.arrowUp:\n e.preventDefault();\n e.stopPropagation();\n var prev = current.prev();\n if (prev.length) {\n prev.insertAfter(current);\n }\n break;\n }\n };\n\n /**\n * Get the x-position of the middle of the DOM node represented by the given jQuery object.\n * @param {jQuery} jQuery wrapping a DOM node.\n * @returns {number} Number the x-coordinate of the middle (left plus half outerWidth).\n */\n var midX = function(jQuery) {\n return jQuery.offset().left + jQuery.outerWidth() / 2;\n };\n\n /**\n * Get the y-position of the middle of the DOM node represented by the given jQuery object.\n * @param {jQuery} jQuery wrapping a DOM node.\n * @returns {number} Number the y-coordinate of the middle (top plus half outerHeight).\n */\n var midY = function(jQuery) {\n return jQuery.offset().top + jQuery.outerHeight() / 2;\n };\n\n /**\n * Calculate the distance between the centres of two elements.\n * @param {Selector|Element|jQuery} element1 selector, element or jQuery.\n * @param {Selector|Element|jQuery} element2 selector, element or jQuery.\n * @return {number} number the distance in pixels.\n */\n var distanceBetweenElements = function(element1, element2) {\n var e1 = $(element1);\n var e2 = $(element2);\n var dx = midX(e1) - midX(e2);\n var dy = midY(e1) - midY(e2);\n return Math.sqrt(dx * dx + dy * dy);\n };\n\n /**\n * Get the current order of the list containing itemDragging.\n * @returns {Array} Array of strings, the id of each element in order.\n */\n var getCurrentOrder = function() {\n return (itemDragging || itemMoving).closest(config.list).find(config.item).map(\n function(index, item) {\n return config.idGetter(item);\n }).get();\n };\n\n /**\n * Compare two arrays, which just contain simple values like ints or strings,\n * to see if they are equal.\n * @param {Array} a1 first array.\n * @param {Array} a2 second array.\n * @return {Boolean} boolean true if they both contain the same elements in the same order, else false.\n */\n var arrayEquals = function(a1, a2) {\n return a1.length === a2.length &&\n a1.every(function(v, i) {\n return v === a2[i];\n });\n };\n config.itemInPage = combineSelectors(config.list, config.item);\n\n // AJAX for section drag and click-to-move.\n $(config.list).on('mousedown touchstart', config.item, function(event) {\n var details = drag.prepare(event);\n if (details.start) {\n startDrag(event, details);\n }\n });\n\n $(config.list).on('keydown', config.item, function(event) {\n itemMoving = $(event.currentTarget).closest(config.itemInPage);\n originalOrder = getCurrentOrder();\n itemMovedByKeyboard(event, itemMoving);\n var newOrder = getCurrentOrder();\n if (!arrayEquals(originalOrder, newOrder)) {\n // Order has changed, call the callback.\n config.reorderDone(itemMoving.closest(config.list), itemMoving, newOrder);\n }\n });\n\n // Make the items tabbable.\n $(config.itemInPage).attr('tabindex', '0');\n };\n});\n"],"names":["define","$","drag","keys","config","outer","inner","combined","dragStart","originalOrder","itemDragging","itemMoving","proxy","orderList","dragMove","list","closest","closestItem","closestDistance","find","item","each","index","element","distance","distanceBetweenElements","offsetValue","midY","window","console","log","insertBefore","insertAfter","updateProxy","items","count","length","i","attr","dragEnd","x","y","reorderEnd","newOrder","getCurrentOrder","arrayEquals","Date","getTime","time","Math","abs","focus","reorderDone","remove","removeClass","itemMovingClass","midX","jQuery","offset","left","outerWidth","top","outerHeight","element1","element2","e1","e2","dx","dy","sqrt","map","idGetter","get","a1","a2","every","v","itemInPage","split","forEach","firstSelector","secondSelector","push","trim","join","on","event","details","prepare","start","currentTarget","reorderStart","proxyHtml","replace","html","document","body","append","css","width","height","addClass","startDrag","e","current","keyCode","space","arrowRight","arrowDown","preventDefault","stopPropagation","next","arrowLeft","arrowUp","prev","itemMovedByKeyboard"],"mappings":";;;;;;;;;;AA6BAA,gDAAO,CACH,SACA,gBACA,mBACD,SACCC,EACAC,KACAC,aAsEO,SAASC,YAgGoBC,MAAOC,MAC/BC,SAhGJC,UAAY,KACZC,cAAgB,KAChBC,aAAe,KACfC,WAAa,KACbC,MAAQ,KACRC,UAAY,KAkCZC,SAAW,eACPC,KAAOL,aAAaM,QAAQZ,OAAOW,MACnCE,YAAc,KACdC,gBAAkB,QACtBH,KAAKI,KAAKf,OAAOgB,MAAMC,MAAK,SAASC,MAAOC,aACpCC,SAAWC,wBAAwBF,QAASX,QAC5B,OAAhBK,aAAwBO,SAAWN,mBACnCD,YAAchB,EAAEsB,SAChBL,gBAAkBM,aAItBP,YAAY,KAAOP,aAAa,QAGhCgB,YAAc,EAEdC,KAAKf,OAASe,KAAKV,cACnBS,YAAc,GACdE,OAAOC,QAAQC,IAAI,kDAAoDJ,eAEvEA,aAAe,GACfE,OAAOC,QAAQC,IAAI,kDAAoDJ,cAExEC,KAAKf,OAASc,YAAcC,KAAKV,aAChCP,aAAaqB,aAAad,aAE1BP,aAAasB,YAAYf,aAE7BgB,YAAYvB,gBAOZuB,YAAc,SAASvB,sBAEnBwB,MADOxB,aAAaM,QAAQ,UACfG,KAAK,MAClBgB,MAAQD,MAAME,OACTC,EAAI,EAAGA,EAAIF,QAASE,KACrB3B,aAAa,KAAOwB,MAAMG,GAAI,CAC9BzB,MAAMO,KAAK,MAAMmB,KAAK,QAASD,EAAI,WAwB3CE,QAAU,SAASC,EAAGC,QACW,IAAtBrC,OAAOsC,YACdtC,OAAOsC,WAAWhC,aAAaM,QAAQZ,OAAOW,MAAOL,kBAGrDiC,SAAWC,kBACVC,YAAYpC,cAAekC,WAGrB,IAAIG,MAAOC,UAAYvC,UAAUwC,KAAO,KAC/CC,KAAKC,IAAI1C,UAAUgC,EAAIA,GAAK,IAAMS,KAAKC,IAAI1C,UAAUiC,EAAIA,GAAK,IAE9D/B,aAAa,GAAGyC,QAJhB/C,OAAOgD,YAAY1C,aAAaM,QAAQZ,OAAOW,MAAOL,aAAciC,UAMxE/B,MAAMyC,SACNzC,MAAQ,KACRF,aAAa4C,YAAYlD,OAAOmD,iBAChC7C,aAAe,KACfF,UAAY,MAyCZgD,KAAO,SAASC,eACTA,OAAOC,SAASC,KAAOF,OAAOG,aAAe,GAQpDjC,KAAO,SAAS8B,eACTA,OAAOC,SAASG,IAAMJ,OAAOK,cAAgB,GASpDrC,wBAA0B,SAASsC,SAAUC,cACzCC,GAAKhE,EAAE8D,UACPG,GAAKjE,EAAE+D,UACPG,GAAKX,KAAKS,IAAMT,KAAKU,IACrBE,GAAKzC,KAAKsC,IAAMtC,KAAKuC,WAClBjB,KAAKoB,KAAKF,GAAKA,GAAKC,GAAKA,KAOhCxB,gBAAkB,kBACVlC,cAAgBC,YAAYK,QAAQZ,OAAOW,MAAMI,KAAKf,OAAOgB,MAAMkD,KACnE,SAAShD,MAAOF,aACLhB,OAAOmE,SAASnD,SACxBoD,OAUX3B,YAAc,SAAS4B,GAAIC,WACpBD,GAAGrC,SAAWsC,GAAGtC,QACpBqC,GAAGE,OAAM,SAASC,EAAGvC,UACVuC,IAAMF,GAAGrC,OAG5BjC,OAAOyE,YAxHyBxE,MAwHKD,OAAOW,KAxHLT,MAwHWF,OAAOgB,KAvHjDb,SAAW,GACfF,MAAMyE,MAAM,KAAKC,SAAQ,SAASC,eAC9B1E,MAAMwE,MAAM,KAAKC,SAAQ,SAASE,gBAC9B1E,SAAS2E,KAAKF,cAAcG,OAAS,IAAMF,eAAeE,cAG3D5E,SAAS6E,KAAK,OAoHzBnF,EAAEG,OAAOW,MAAMsE,GAAG,uBAAwBjF,OAAOgB,MAAM,SAASkE,WACxDC,QAAUrF,KAAKsF,QAAQF,OACvBC,QAAQE,OArNA,SAASH,MAAOC,SAC5B1E,UAAYZ,EAAEG,OAAOW,MAErBP,UAAY,CACRwC,MAAM,IAAIF,MAAOC,UACjBP,EAAG+C,QAAQ/C,EACXC,EAAG8C,QAAQ9C,GAGf/B,aAAeT,EAAEqF,MAAMI,eAAe1E,QAAQZ,OAAOyE,iBAElB,IAAxBzE,OAAOuF,cACdvF,OAAOuF,aAAajF,aAAaM,QAAQZ,OAAOW,MAAOL,cAG3DD,cAAgBmC,kBAChBhC,MAAQX,EAAEG,OAAOwF,UAAUC,QAAQ,gBAAiBnF,aAAaoF,QAC5DD,QAAQ,sBAAuBnF,aAAa4B,KAAK,UACjDuD,QAAQ,sBAAuBhF,UAAUyB,KAAK,WAEnDrC,EAAE8F,SAASC,MAAMC,OAAOrF,OACxBA,MAAMsF,IAAI,WAAY,YACtBtF,MAAMsF,IAAIxF,aAAagD,UACvB9C,MAAMuF,MAAMzF,aAAakD,cACzBhD,MAAMwF,OAAO1F,aAAaoD,eAC1BpD,aAAa2F,SAASjG,OAAOmD,iBAC7BtB,YAAYvB,cAGZR,KAAKuF,MAAMH,MAAO1E,MAAOE,SAAUyB,SAyL/B+D,CAAUhB,MAAOC,YAIzBtF,EAAEG,OAAOW,MAAMsE,GAAG,UAAWjF,OAAOgB,MAAM,SAASkE,OAC/C3E,WAAaV,EAAEqF,MAAMI,eAAe1E,QAAQZ,OAAOyE,YACnDpE,cAAgBmC,kBA7FM,SAAS2D,EAAGC,gBAC1BD,EAAEE,cACDtG,KAAKuG,WACLvG,KAAKwG,gBACLxG,KAAKyG,UACNL,EAAEM,iBACFN,EAAEO,sBACEC,KAAOP,QAAQO,OACfA,KAAK3E,QACL2E,KAAKhF,aAAayE,oBAIrBrG,KAAK6G,eACL7G,KAAK8G,QACNV,EAAEM,iBACFN,EAAEO,sBACEI,KAAOV,QAAQU,OACfA,KAAK9E,QACL8E,KAAKlF,YAAYwE,UA2E7BW,CAAoB7B,MAAO3E,gBACvBgC,SAAWC,kBACVC,YAAYpC,cAAekC,WAE5BvC,OAAOgD,YAAYzC,WAAWK,QAAQZ,OAAOW,MAAOJ,WAAYgC,aAKxE1C,EAAEG,OAAOyE,YAAYvC,KAAK,WAAY"} \ No newline at end of file diff --git a/amd/build/sorting_reorder.min.js b/amd/build/sorting_reorder.min.js new file mode 100644 index 00000000..78b1adba --- /dev/null +++ b/amd/build/sorting_reorder.min.js @@ -0,0 +1,13 @@ +/* + * Generic library to allow things in a vertical list to be re-ordered using drag and drop. + * + * To make a set of things draggable, create a new instance of this object passing the + * necessary config, as explained in the comment on the constructor. + * + * @package mod_questionnaire + * @copyright 2023 The Open University + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +define("mod_questionnaire/sorting_reorder",["mod_questionnaire/sorting_drag_reorder"],(function(DragReorder){return{init:function(){document.getElementsByClassName("qn-sorting-list").forEach((function(element){new DragReorder({list:"ol#"+element.id,item:"li.qn-sorting-list__items",proxyHtml:'
  1. %%ITEM_HTML%%
',itemMovingClass:"current-drop",idGetter:function(item){return item.getAttribute("id")},nameGetter:function(item){return item.text},reorderStart:function(list,item){},reorderEnd:function(list,item){},reorderDone:function(list,item,newOrder){}})}))}}})); + +//# sourceMappingURL=sorting_reorder.min.js.map \ No newline at end of file diff --git a/amd/build/sorting_reorder.min.js.map b/amd/build/sorting_reorder.min.js.map new file mode 100644 index 00000000..14ab8141 --- /dev/null +++ b/amd/build/sorting_reorder.min.js.map @@ -0,0 +1 @@ +{"version":3,"file":"sorting_reorder.min.js","sources":["../src/sorting_reorder.js"],"sourcesContent":["// This file is part of Moodle - http://moodle.org/\n//\n// Moodle is free software: you can redistribute it and/or modify\n// it under the terms of the GNU General Public License as published by\n// the Free Software Foundation, either version 3 of the License, or\n// (at your option) any later version.\n//\n// Moodle is distributed in the hope that it will be useful,\n// but WITHOUT ANY WARRANTY; without even the implied warranty of\n// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the\n// GNU General Public License for more details.\n//\n// You should have received a copy of the GNU General Public License\n// along with Moodle. If not, see .\n\n/*\n * Generic library to allow things in a vertical list to be re-ordered using drag and drop.\n *\n * To make a set of things draggable, create a new instance of this object passing the\n * necessary config, as explained in the comment on the constructor.\n *\n * @package mod_questionnaire\n * @copyright 2023 The Open University\n * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later\n */\n\n/**\n * @module questionnaire/sorting_reorder\n */\ndefine(['mod_questionnaire/sorting_drag_reorder'], function(DragReorder) {\n return {\n /**\n * Initialise one ordering question.\n */\n init: function() {\n var elements = document.getElementsByClassName('qn-sorting-list');\n elements.forEach(function(element) {\n new DragReorder({\n list: 'ol#' + element.id,\n item: 'li.qn-sorting-list__items',\n proxyHtml: '
' +\n '
  1. ' +\n '%%ITEM_HTML%%
',\n itemMovingClass: \"current-drop\",\n idGetter: function(item) {\n return item.getAttribute('id');\n },\n nameGetter: function(item) {\n return item.text;\n },\n // eslint-disable-next-line no-unused-vars\n reorderStart: function(list, item) {\n // Do nothing.\n },\n // eslint-disable-next-line no-unused-vars\n reorderEnd: function(list, item) {\n // Do nothing.\n },\n // eslint-disable-next-line no-unused-vars\n reorderDone: function(list, item, newOrder) {\n // Do nothing.\n }\n });\n });\n }\n };\n});\n"],"names":["define","DragReorder","init","document","getElementsByClassName","forEach","element","list","id","item","proxyHtml","itemMovingClass","idGetter","getAttribute","nameGetter","text","reorderStart","reorderEnd","reorderDone","newOrder"],"mappings":";;;;;;;;;;AA6BAA,2CAAO,CAAC,2CAA2C,SAASC,mBACjD,CAIHC,KAAM,WACaC,SAASC,uBAAuB,mBACtCC,SAAQ,SAASC,aAClBL,YAAY,CACZM,KAAM,MAAQD,QAAQE,GACtBC,KAAM,4BACNC,UAAW,mJAGXC,gBAAiB,eACjBC,SAAU,SAASH,aACRA,KAAKI,aAAa,OAE7BC,WAAY,SAASL,aACVA,KAAKM,MAGhBC,aAAc,SAAST,KAAME,QAI7BQ,WAAY,SAASV,KAAME,QAI3BS,YAAa,SAASX,KAAME,KAAMU"} \ No newline at end of file diff --git a/amd/src/sorting_drag_reorder.js b/amd/src/sorting_drag_reorder.js new file mode 100644 index 00000000..6246199c --- /dev/null +++ b/amd/src/sorting_drag_reorder.js @@ -0,0 +1,347 @@ +// This file is part of Moodle - http://moodle.org/ +// +// Moodle is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// Moodle is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with Moodle. If not, see . + +/* + * Generic library to allow things in a vertical list to be re-ordered using drag and drop. + * + * To make a set of things draggable, create a new instance of this object passing the + * necessary config, as explained in the comment on the constructor. + * + * @package mod_questionnaire + * @copyright 2023 The Open University + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +/** + * @module questionnaire/sorting_drag_reorder + */ +define([ + 'jquery', + 'core/dragdrop', + 'core/key_codes' +], function( + $, + drag, + keys +) { + + /** + * Constructor. + * + * To make a list draggable, create a new instance of this object, passing the necessary config. + * For example: + * { + * // Selector for the list (or lists) to be reordered. + * list: 'ul.my-list', + * + * // Selector, relative to the list selector, for the items that can be moved. + * item: '> li', + * + * // The user actually drags a proxy object, which is constructed from this string, + * // and then added directly as a child of . The token %%ITEM_HTML%% is + * // replaced with the innerHtml of the item being dragged. The token %%ITEM_CLASS_NAME%% + * // is replaced with the class attribute of the item being dragged. Because of this, + * // the styling of the contents of your list item needs to work for the proxy, as well as + * // for items in place in the context of the list. Your CSS also needs to ensure + * // that this proxy has position: absolute. You probably want other styles, like a + * // drop shadow. Using class osep-itemmoving might be all you need to do. + * proxyHtml: '
%%ITEM_HTML%%
, + * + * // While the proxy is being dragged, this class is added to the item being moved. + * // You can probably use "some-class" here. + * itemMovingClass: "some-class", + * + * // This is a callback which, when called with the DOM node for an item, + * // returns the string that uniquely identifies each item. + * // Therefore, the result of the drag action will be represented by the array + * // obtained by calling this method on each item in the list in order. + * idGetter: function(item) { return $(node).data('id'); }, + * + * // This is a callback which, when called with the DOM node for an item, + * // returns a string that is the name of the item. + * nameGetter: function(item) { return $(node).text(); }, + * + * // Function that will be called when a re-order starts (optional, can be not set). + * // Useful if you need to save information about the initial state. + * // This function should have two parameters. The first will be a + * // jQuery object for the list that was reordered, the second will + * // be the jQuery object for the item moved - which will not yet have been moved. + * // Note, it is quite possible for reorderStart to be called with no + * // subsequent call to reorderDone. + * reorderStart: function($list, $item) { ... } + * + * // Function that will be called when a drag has finished, and the list + * // has been reordered. This function should have three parameters. The first will be + * // a jQuery object for the list that was reordered, the second will be the jQuery + * // object for the item moved, and the third will be the new order, which is + * // an array of ids obtained by calling idGetter on each item in the list in order. + * // This callback will only be called in the new order is actually different from the old order. + * reorderDone: function($list, $item, newOrder) { ... } + * + * // Function that is always called when a re-order ends (optional, can be not set) + * // whether the order has changed. Useful if you need to undo changes made + * // in reorderStart, since reorderDone is only called if the new order is different + * // from the original order. + * reorderEnd: function($list, $item) { ... } + * } + * + * There is a subtlety ( === hack?) that you can use. If you have items in your list that do not + * have a drag handle, they are considered to be placeholders in otherwise empty containers. + * See how block_userlinks does it, if this seems like it might be useful. nameGetter should return + * the container name for these items. + * + * @param {Object} config As above. + */ + return function(config) { + var dragStart = null, // Information about when and where the drag started. + originalOrder = null, // Array of ids. + itemDragging = null, // Item being moved by dragging (jQuery object). + itemMoving = null, // Item being moved using the accessible modal (jQuery object). + proxy = null, // Drag proxy (jQuery object). + orderList = null; // Order list (jQuery object). + + var startDrag = function(event, details) { + orderList = $(config.list); + + dragStart = { + time: new Date().getTime(), + x: details.x, + y: details.y + }; + + itemDragging = $(event.currentTarget).closest(config.itemInPage); + + if (typeof config.reorderStart !== 'undefined') { + config.reorderStart(itemDragging.closest(config.list), itemDragging); + } + + originalOrder = getCurrentOrder(); + proxy = $(config.proxyHtml.replace('%%ITEM_HTML%%', itemDragging.html()) + .replace('%%ITEM_CLASS_NAME%%', itemDragging.attr('class')) + .replace('%%LIST_CLASS_NAME%%', orderList.attr('class'))); + + $(document.body).append(proxy); + proxy.css('position', 'absolute'); + proxy.css(itemDragging.offset()); + proxy.width(itemDragging.outerWidth()); + proxy.height(itemDragging.outerHeight()); + itemDragging.addClass(config.itemMovingClass); + updateProxy(itemDragging); + + // Start drag. + drag.start(event, proxy, dragMove, dragEnd); + }; + + var dragMove = function() { + var list = itemDragging.closest(config.list); + var closestItem = null; + var closestDistance = null; + list.find(config.item).each(function(index, element) { + var distance = distanceBetweenElements(element, proxy); + if (closestItem === null || distance < closestDistance) { + closestItem = $(element); + closestDistance = distance; + } + }); + + if (closestItem[0] === itemDragging[0]) { + return; + } + var offsetValue = 0; + // Set offset depending on if item is being dragged downwards/upwards. + if (midY(proxy) < midY(closestItem)) { + offsetValue = 20; + window.console.log("For midY(proxy) < midY(closestItem) offset is: " + offsetValue); + } else { + offsetValue = -20; + window.console.log("For midY(proxy) < midY(closestItem) offset is: " + offsetValue); + } + if (midY(proxy) + offsetValue < midY(closestItem)) { + itemDragging.insertBefore(closestItem); + } else { + itemDragging.insertAfter(closestItem); + } + updateProxy(itemDragging); + }; + + /** + * Update proxy's position. + * @param {jQuery} itemDragging + */ + var updateProxy = function(itemDragging) { + var list = itemDragging.closest('ol, ul'); + var items = list.find('li'); + var count = items.length; + for (var i = 0; i < count; ++i) { + if (itemDragging[0] === items[i]) { + proxy.find('li').attr('value', i + 1); + break; + } + } + }; + + /** + * It outer and inner are two CSS selectors, which may contain commas, + * then combine them safely. So combineSelectors('a, b', 'c, d') + * gives 'a c, a d, b c, b d'. + * @param {Selector} outer + * @param {Selector} inner + * @returns {string} + */ + var combineSelectors = function(outer, inner) { + var combined = []; + outer.split(',').forEach(function(firstSelector) { + inner.split(',').forEach(function(secondSelector) { + combined.push(firstSelector.trim() + ' ' + secondSelector.trim()); + }); + }); + return combined.join(', '); + }; + + var dragEnd = function(x, y) { + if (typeof config.reorderEnd !== 'undefined') { + config.reorderEnd(itemDragging.closest(config.list), itemDragging); + } + + var newOrder = getCurrentOrder(); + if (!arrayEquals(originalOrder, newOrder)) { + // Order has changed, call the callback. + config.reorderDone(itemDragging.closest(config.list), itemDragging, newOrder); + } else if (new Date().getTime() - dragStart.time < 500 && + Math.abs(dragStart.x - x) < 10 && Math.abs(dragStart.y - y) < 10) { + // This was really a click. Set the focus on the current item. + itemDragging[0].focus(); + } + proxy.remove(); + proxy = null; + itemDragging.removeClass(config.itemMovingClass); + itemDragging = null; + dragStart = null; + }; + + /** + * Items can be moved and placed using certain keys. + * Tab for tabbing though and choose the item to be moved + * space, arrow-right arrow-down for moving current element forewards. + * arrow-right arrow-down for moving the current element backwards. + * @param {Object} e the event + * @param {jQuery} current the current moving item + */ + var itemMovedByKeyboard = function(e, current) { + switch (e.keyCode) { + case keys.space: + case keys.arrowRight: + case keys.arrowDown: + e.preventDefault(); + e.stopPropagation(); + var next = current.next(); + if (next.length) { + next.insertBefore(current); + } + break; + + case keys.arrowLeft: + case keys.arrowUp: + e.preventDefault(); + e.stopPropagation(); + var prev = current.prev(); + if (prev.length) { + prev.insertAfter(current); + } + break; + } + }; + + /** + * Get the x-position of the middle of the DOM node represented by the given jQuery object. + * @param {jQuery} jQuery wrapping a DOM node. + * @returns {number} Number the x-coordinate of the middle (left plus half outerWidth). + */ + var midX = function(jQuery) { + return jQuery.offset().left + jQuery.outerWidth() / 2; + }; + + /** + * Get the y-position of the middle of the DOM node represented by the given jQuery object. + * @param {jQuery} jQuery wrapping a DOM node. + * @returns {number} Number the y-coordinate of the middle (top plus half outerHeight). + */ + var midY = function(jQuery) { + return jQuery.offset().top + jQuery.outerHeight() / 2; + }; + + /** + * Calculate the distance between the centres of two elements. + * @param {Selector|Element|jQuery} element1 selector, element or jQuery. + * @param {Selector|Element|jQuery} element2 selector, element or jQuery. + * @return {number} number the distance in pixels. + */ + var distanceBetweenElements = function(element1, element2) { + var e1 = $(element1); + var e2 = $(element2); + var dx = midX(e1) - midX(e2); + var dy = midY(e1) - midY(e2); + return Math.sqrt(dx * dx + dy * dy); + }; + + /** + * Get the current order of the list containing itemDragging. + * @returns {Array} Array of strings, the id of each element in order. + */ + var getCurrentOrder = function() { + return (itemDragging || itemMoving).closest(config.list).find(config.item).map( + function(index, item) { + return config.idGetter(item); + }).get(); + }; + + /** + * Compare two arrays, which just contain simple values like ints or strings, + * to see if they are equal. + * @param {Array} a1 first array. + * @param {Array} a2 second array. + * @return {Boolean} boolean true if they both contain the same elements in the same order, else false. + */ + var arrayEquals = function(a1, a2) { + return a1.length === a2.length && + a1.every(function(v, i) { + return v === a2[i]; + }); + }; + config.itemInPage = combineSelectors(config.list, config.item); + + // AJAX for section drag and click-to-move. + $(config.list).on('mousedown touchstart', config.item, function(event) { + var details = drag.prepare(event); + if (details.start) { + startDrag(event, details); + } + }); + + $(config.list).on('keydown', config.item, function(event) { + itemMoving = $(event.currentTarget).closest(config.itemInPage); + originalOrder = getCurrentOrder(); + itemMovedByKeyboard(event, itemMoving); + var newOrder = getCurrentOrder(); + if (!arrayEquals(originalOrder, newOrder)) { + // Order has changed, call the callback. + config.reorderDone(itemMoving.closest(config.list), itemMoving, newOrder); + } + }); + + // Make the items tabbable. + $(config.itemInPage).attr('tabindex', '0'); + }; +}); diff --git a/amd/src/sorting_reorder.js b/amd/src/sorting_reorder.js new file mode 100644 index 00000000..5e241b70 --- /dev/null +++ b/amd/src/sorting_reorder.js @@ -0,0 +1,67 @@ +// This file is part of Moodle - http://moodle.org/ +// +// Moodle is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// Moodle is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with Moodle. If not, see . + +/* + * Generic library to allow things in a vertical list to be re-ordered using drag and drop. + * + * To make a set of things draggable, create a new instance of this object passing the + * necessary config, as explained in the comment on the constructor. + * + * @package mod_questionnaire + * @copyright 2023 The Open University + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +/** + * @module questionnaire/sorting_reorder + */ +define(['mod_questionnaire/sorting_drag_reorder'], function(DragReorder) { + return { + /** + * Initialise one ordering question. + */ + init: function() { + var elements = document.getElementsByClassName('qn-sorting-list'); + elements.forEach(function(element) { + new DragReorder({ + list: 'ol#' + element.id, + item: 'li.qn-sorting-list__items', + proxyHtml: '
' + + '
  1. ' + + '%%ITEM_HTML%%
', + itemMovingClass: "current-drop", + idGetter: function(item) { + return item.getAttribute('id'); + }, + nameGetter: function(item) { + return item.text; + }, + // eslint-disable-next-line no-unused-vars + reorderStart: function(list, item) { + // Do nothing. + }, + // eslint-disable-next-line no-unused-vars + reorderEnd: function(list, item) { + // Do nothing. + }, + // eslint-disable-next-line no-unused-vars + reorderDone: function(list, item, newOrder) { + // Do nothing. + } + }); + }); + } + }; +}); diff --git a/appjs/uncheckother.js b/appjs/ionic3/mobile_view_activity.js similarity index 100% rename from appjs/uncheckother.js rename to appjs/ionic3/mobile_view_activity.js diff --git a/appjs/latest/mobile_view_activity.js b/appjs/latest/mobile_view_activity.js new file mode 100644 index 00000000..73010b14 --- /dev/null +++ b/appjs/latest/mobile_view_activity.js @@ -0,0 +1,73 @@ +(function(){ + /** + * Questionnaire init function. + * + * @param {this} outerThis Window document. + */ + window.questionnaireInit = function(outerThis) { + /** + * Fired when the component routing to has finished animating. + */ + outerThis.ionViewDidEnter = function() { + outerThis.init(); + }; + + /** + * Fired when the component loaded from the template. + */ + outerThis.init = function() { + const groups = document.querySelectorAll('ion-list ion-reorder-group'); + if (groups) { + groups.forEach((group) => { + const qId = group.getAttribute('data-qid'); + const qInput = document.getElementById('question-' + qId); + // Review the sorting questionnaire. + if (qInput.value) { + const response = qInput.value.split(','); + const reOrderGroups = outerThis.reOrederSortGroups(group.children, response); + if (reOrderGroups) { + reOrderGroups.forEach(item => group.appendChild(item)); + } + } + // Binding event 'ionItemReorder' to the 'ion-reorder-group' element for enable 'drag and drop' feature. + group.addEventListener('ionItemReorder', function(e){ + e.detail.complete(true); + outerThis.setValueSorting(qId, group.children); + }); + }); + } + }; + + /** + * Sorting the element in the groups question sort. + * + * @param {Array} itemGroups list of children group element. + * @param {Array} response list reponse of user. + * @returns the list reordered based on the response of user. + */ + outerThis.reOrederSortGroups = function(itemGroups, response) { + return Array.from(response).map((index) => { + return Array.from(itemGroups).find(item => item.getAttribute('data-index') === index); + }); + }; + + /** + * Set value for the input field. + * + * @param {Number} qId question id. + * @param {Array} items list of the element which re-order by user. + */ + outerThis.setValueSorting = function(qId, items) { + const sorted = Array.from(items).map((item) => item.getAttribute('data-index')); + const qInput = document.getElementById('question-' + qId); + qInput.value = sorted.join(','); + }; + + /** + * Initializing the functions. + */ + setTimeout(function() { + outerThis.init(); + }); + }; +})(); diff --git a/backup/moodle2/backup_questionnaire_stepslib.php b/backup/moodle2/backup_questionnaire_stepslib.php index 747b304b..6a800b50 100644 --- a/backup/moodle2/backup_questionnaire_stepslib.php +++ b/backup/moodle2/backup_questionnaire_stepslib.php @@ -109,6 +109,10 @@ protected function define_structure() { $responsetext = new backup_nested_element('response_text', array('id'), array('response_id', 'question_id', 'response')); + $responsesorts = new backup_nested_element('response_sorts'); + + $responsesort = new backup_nested_element('response_sort', ['id'], ['response_id', 'question_id', 'response']); + // Build the tree. $questionnaire->add_child($surveys); $surveys->add_child($survey); @@ -152,6 +156,9 @@ protected function define_structure() { $response->add_child($responsetexts); $responsetexts->add_child($responsetext); + $response->add_child($responsesorts); + $responsesorts->add_child($responsesort); + // Define sources. $questionnaire->set_source_table('questionnaire', array('id' => backup::VAR_ACTIVITYID)); @@ -183,6 +190,7 @@ protected function define_structure() { $responserank->set_source_table('questionnaire_response_rank', array('response_id' => backup::VAR_PARENTID)); $responsesingle->set_source_table('questionnaire_resp_single', array('response_id' => backup::VAR_PARENTID)); $responsetext->set_source_table('questionnaire_response_text', array('response_id' => backup::VAR_PARENTID)); + $responsesort->set_source_table('questionnaire_response_sort', ['response_id' => backup::VAR_PARENTID]); } // Define id annotations. diff --git a/backup/moodle2/restore_questionnaire_stepslib.php b/backup/moodle2/restore_questionnaire_stepslib.php index d5235635..f29538ae 100644 --- a/backup/moodle2/restore_questionnaire_stepslib.php +++ b/backup/moodle2/restore_questionnaire_stepslib.php @@ -84,7 +84,8 @@ protected function define_structure() { '/activity/questionnaire/attempts/attempt/responses/response/response_singles/response_single'); $paths[] = new restore_path_element('questionnaire_response_text', '/activity/questionnaire/attempts/attempt/responses/response/response_texts/response_text'); - + $paths[] = new restore_path_element('questionnaire_response_sort', + '/activity/questionnaire/attempts/attempt/responses/response/response_sorts/response_sort'); } else { // New system. $paths[] = new restore_path_element('questionnaire_response', '/activity/questionnaire/responses/response'); @@ -102,6 +103,8 @@ protected function define_structure() { '/activity/questionnaire/responses/response/response_singles/response_single'); $paths[] = new restore_path_element('questionnaire_response_text', '/activity/questionnaire/responses/response/response_texts/response_text'); + $paths[] = new restore_path_element('questionnaire_response_sort', + '/activity/questionnaire/responses/response/response_sorts/response_sort'); } } @@ -422,6 +425,24 @@ protected function process_questionnaire_response_text($data) { $DB->insert_record('questionnaire_response_text', $data); } + /** + * Processing questionnaire response sort. + * + * @param array $data + * @return void + * @throws dml_exception + */ + protected function process_questionnaire_response_sort($data) { + global $DB; + + $data = (object)$data; + $data->response_id = $this->get_new_parentid('questionnaire_response'); + $data->question_id = $this->get_mappingid('questionnaire_question', $data->question_id); + + // Insert the questionnaire_response_sort record. + $DB->insert_record('questionnaire_response_sort', $data); + } + /** * Stuff to do after execution. */ diff --git a/classes/edit_question_form.php b/classes/edit_question_form.php index 92098e84..e3bfffd5 100644 --- a/classes/edit_question_form.php +++ b/classes/edit_question_form.php @@ -120,6 +120,20 @@ public function validation($data, $files) { } } + if ($data['type_id'] == QUESSORT) { + $validanswer = 0; + foreach ($data['answer'] as $key => $answer) { + $text = clean_param($answer['text'], PARAM_RAW_TRIMMED); + if (!empty($text)) { + $validanswer++; + } else { + if ($key <= 1) { + $errors["answer[$key]"] = get_string('sortingerrorsformitems', 'questionnaire'); + } + } + } + } + return $errors; } diff --git a/classes/output/mobile.php b/classes/output/mobile.php index 74d3f549..2493be9d 100644 --- a/classes/output/mobile.php +++ b/classes/output/mobile.php @@ -27,6 +27,33 @@ * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class mobile { + /** @var string Folder of ionic5 (latest) app version */ + const IONIC5_FOLDER = 'latest'; + + /** @var string Folder of ionic3 app version */ + const IONIC3_FOLDER = 'ionic3'; + + /** + * Returns shared (global) templates and information for the mobile app feature. + * + * @param array $args Arguments (empty) + * @return array Array with information required by app + */ + public static function mobile_questionnaire_init(array $args) : array { + global $CFG; + $args = (object) $args; + $versionname = self::mobile_get_folder_name($args); + $js = preg_replace_callback('~(?:^|\n)\s*// IMPORT:(.*?\.js)\.?\s*(?=(?:\n|$))~', function($matches) { + global $CFG; + return file_get_contents($CFG->dirroot . '/mod/questionnaire/appjs/' . $matches[1]); + }, file_get_contents($CFG->dirroot . '/mod/questionnaire/appjs/' . $versionname . '/mobile_view_activity.js')); + return [ + 'templates' => [], + 'javascript' => $js, + 'otherdata' => '', + 'files' => [], + ]; + } /** * Returns the initial page when viewing the activity for the mobile app. @@ -35,12 +62,12 @@ class mobile { * @return array HTML, javascript and other data */ public static function mobile_view_activity($args) { - global $OUTPUT, $USER, $CFG, $DB; + global $OUTPUT, $USER, $CFG; require_once($CFG->dirroot.'/mod/questionnaire/questionnaire.class.php'); $args = (object) $args; - $versionname = $args->appversioncode >= 3950 ? 'latest' : 'ionic3'; + $versionname = self::mobile_get_folder_name($args); $cmid = $args->cmid; $rid = isset($args->rid) ? $args->rid : 0; $action = isset($args->action) ? $args->action : 'index'; @@ -176,19 +203,17 @@ public static function mobile_view_activity($args) { } $data['hasmorepages'] = $data['prevpage'] || $data['nextpage']; - - $return = [ + return [ 'templates' => [ [ 'id' => 'main', 'html' => $OUTPUT->render_from_template($template, $data) ], ], - 'javascript' => file_get_contents($CFG->dirroot . '/mod/questionnaire/appjs/uncheckother.js'), + 'javascript' => 'window.questionnaireInit(this)', 'otherdata' => $responses, 'files' => null ]; - return $return; } /** @@ -271,4 +296,14 @@ protected static function add_pagequestion_data($questionnaire, $pagenum, $respo return ['pagequestions' => $pagequestions, 'responses' => $responses]; } + + /** + * Get the latest folder name has the new files used for the newest app version. + * + * @param object $args Standard mobile web service arguments + * @return string Folder name + */ + protected static function mobile_get_folder_name($args): string { + return isset($args->appversioncode) && $args->appversioncode >= 3950 ? self::IONIC5_FOLDER : self::IONIC3_FOLDER; + } } diff --git a/classes/output/renderer.php b/classes/output/renderer.php index 5bc029b3..5d5ff98e 100755 --- a/classes/output/renderer.php +++ b/classes/output/renderer.php @@ -293,15 +293,17 @@ public function all_response_output($responses, $questions = null) { if (empty($pagetags = $question->questionstart_survey_display($qnum))) { continue; } - foreach ($responses as $response) { - $resptags = $question->response_output($response); - // If the response has a template, then render it from the 'qformelement' context. - // If no template, then 'qformelement' already contains HTML. - if (($template = $question->response_template())) { - $resptags->qformelement = $this->render_from_template($template, $resptags->qformelement); + foreach ($responses as $index => $response) { + if (isset($responses[$index]->answers[$question->id])) { + $resptags = $question->response_output($response); + // If the response has a template, then render it from the 'qformelement' context. + // If no template, then 'qformelement' already contains HTML. + if (($template = $question->response_template())) { + $resptags->qformelement = $this->render_from_template($template, $resptags->qformelement); + } + $resptags->respdate = userdate($response->submitted); + $pagetags->responses[] = $resptags; } - $resptags->respdate = userdate($response->submitted); - $pagetags->responses[] = $resptags; } $qnum++; $output .= $this->render_from_template('mod_questionnaire/response_container', $pagetags); diff --git a/classes/privacy/provider.php b/classes/privacy/provider.php index 3388857d..340e3b73 100644 --- a/classes/privacy/provider.php +++ b/classes/privacy/provider.php @@ -89,6 +89,12 @@ public static function get_metadata(collection $collection): collection { 'response' => 'privacy:metadata:questionnaire_response_text:response', ], 'privacy:metadata:questionnaire_response_text'); + $collection->add_database_table('questionnaire_response_sort', [ + 'response_id' => 'privacy:metadata:questionnaire_response_sort:response_id', + 'question_id' => 'privacy:metadata:questionnaire_response_sort:question_id', + 'response' => 'privacy:metadata:questionnaire_response_sort:response', + ], 'privacy:metadata:questionnaire_response_sort'); + $collection->add_database_table('questionnaire_resp_multiple', [ 'response_id' => 'privacy:metadata:questionnaire_resp_multiple:response_id', 'question_id' => 'privacy:metadata:questionnaire_resp_multiple:question_id', @@ -333,6 +339,7 @@ private static function delete_responses(\moodle_recordset $responses) { $DB->delete_records('questionnaire_response_rank', ['response_id' => $response->id]); $DB->delete_records('questionnaire_resp_single', ['response_id' => $response->id]); $DB->delete_records('questionnaire_response_text', ['response_id' => $response->id]); + $DB->delete_records('questionnaire_response_sort', ['response_id' => $response->id]); } } } diff --git a/classes/question/question.php b/classes/question/question.php index faa4ec80..2cc4e89a 100644 --- a/classes/question/question.php +++ b/classes/question/question.php @@ -42,6 +42,7 @@ define('QUESDATE', 9); define('QUESNUMERIC', 10); define('QUESSLIDER', 11); +define('QUESSORT', 12); define('QUESPAGEBREAK', 99); define('QUESSECTIONTEXT', 100); @@ -117,6 +118,7 @@ abstract class question { QUESRATE => 'rate', QUESDATE => 'date', QUESNUMERIC => 'numerical', + QUESSORT => 'sorting', QUESPAGEBREAK => 'pagebreak', QUESSECTIONTEXT => 'sectiontext', QUESSLIDER => 'slider', @@ -957,6 +959,7 @@ public function questionstart_survey_display($qnum, $response=null) { $content = format_text(file_rewrite_pluginfile_urls($this->content, 'pluginfile.php', $this->context->id, 'mod_questionnaire', 'question', $this->id), FORMAT_HTML, $options); $pagetags->qcontent = $content; + $pagetags->qlegend = $this->content; return $pagetags; } diff --git a/classes/question/sorting.php b/classes/question/sorting.php new file mode 100644 index 00000000..3b03b1df --- /dev/null +++ b/classes/question/sorting.php @@ -0,0 +1,585 @@ +. + +namespace mod_questionnaire\question; + +use html_writer; +use mod_questionnaire\edit_question_form; +use \questionnaire; + +/** + * Class for sorting question types. + * + * @author The Open University + * @license http://www.gnu.org/copyleft/gpl.html GNU Public License + * @package mod_questionnaire + * + * @property \mod_questionnaire\responsetype\sorting $responsetype + */ +class sorting extends question { + /** + * Number of answers in question by default. + */ + const NUM_ITEMS_DEFAULT = 3; + + /** + * Minimum number of answers to show. + */ + const NUM_ITEMS_MIN = 2; + + /** + * Number of answers to add on demand. + */ + const NUM_ITEMS_ADD = 1; + + /** + * Rows count in answer field. + */ + const TEXTFIELD_ROWS = 2; + + /** + * Cols count in answer field. + */ + const TEXTFIELD_COLS = 60; + + /** + * Sorting data. + * @var \stdClass|null + */ + public ?\stdClass $sortingdata; + + /** + * Constructor. + * @param int $id + * @param \stdClass $question + * @param \context $context + * @param array $params + */ + public function __construct($id = 0, $question = null, $context = null, $params = []) { + parent::__construct($id, $question, $context, $params); + $this->sortingdata = json_decode($this->extradata); + } + + /** + * Name of table + */ + public function table_name() { + return "questionnaire_response_sort"; + } + + /** + * Get response class name. + * + * @return string + */ + protected function responseclass(): string { + return '\\mod_questionnaire\\responsetype\\sorting'; + } + + /** + * Get help name. + * + * @return string + */ + public function helpname(): string { + return 'sorting'; + } + + /** + * Override and return a form template if provided. Output of question_survey_display is iterpreted based on this. + * @return string + */ + public function question_template() { + return 'mod_questionnaire/question_sorting'; + } + + /** + * Override and return a response template if provided. Output of response_survey_display is iterpreted based on this. + * + * @return string + */ + public function response_template() { + return 'mod_questionnaire/response_sorting'; + } + + /** + * Question specific display method. + * + * @param \stdClass $formdata + * @param array $descendantsdata + * @param bool $blankquestionnaire + */ + protected function question_survey_display($formdata, $descendantsdata, $blankquestionnaire) { + $questiontags = new \stdClass(); + $questiontags->qelements = new \stdClass(); + // Display list of sorting answers. + $questiontags->qelements->sortinglist = $this->prepare_answers(); + $questiontags->qelements->qid = 'q' . $this->id; + // Display type of sorting layout. + $layout = $this->responsetype->questionnaire_sort_type_layout()[$this->get_layout()]; + $questiontags->qelements->sortingdirection = strtolower($layout); + return $questiontags; + } + + /** + * Get layout sorting. + * @return string + */ + protected function get_layout(): string { + if ($this->sortingdata instanceof \stdClass) { + if (property_exists($this->sortingdata, 'sortingdirection')) { + return $this->sortingdata->sortingdirection; + } + } + return QUESTIONNAIRE_LAYOUT_VERTICAL; + } + + /** + * Return the consorting tags for the sorting response template. + * + * @param object $response + * @return object The sorting question response tags. + */ + protected function response_survey_display($response) { + $resptags = new \stdClass(); + $res = isset($response->answers[$this->id]) ? reset($response->answers[$this->id]) : ''; + if (!empty($res) && $value = $res->value) { + $resptags->content = new \stdClass(); + $resptags->content->qelements = new \stdClass(); + $resptags->content->qelements->sortinglist = $this->prepare_answers($value); + $resptags->content->qelements->qid = 'q' . $this->id; + $resptags->content->qelements->isresponse = true; + $layout = $this->responsetype->questionnaire_sort_type_layout()[$this->get_layout()]; + $resptags->content->qelements->sortingdirection = strtolower($layout); + } + return $resptags; + } + + /** + * Return the length form element. + * + * @param \MoodleQuickForm $mform + * @param string $helpname + * @return \MoodleQuickForm + */ + protected function form_length(\MoodleQuickForm $mform, $helpname = '') { + return parent::form_length_hidden($mform); + } + + /** + * Return the precision form element. + * + * @param \MoodleQuickForm $mform + * @param string $helpname + * @return \MoodleQuickForm + */ + protected function form_precise(\MoodleQuickForm $mform, $helpname = '') { + return question::form_precise_hidden($mform); + } + + /** + * Add the form required field. + * + * @param \MoodleQuickForm $mform + * @return \MoodleQuickForm + */ + protected function form_required(\MoodleQuickForm $mform) { + return $mform; + } + + /** + * Add the form name field. + * + * @param \MoodleQuickForm $mform + * @return \MoodleQuickForm + */ + protected function form_name(\MoodleQuickForm $mform) { + $form = parent::form_name($mform); + $form = self::form_direction($mform); + return $form; + } + + /** + * Override if the question uses the extradata field. + * + * @param \MoodleQuickForm $mform + * @param string $helpname + * @return \MoodleQuickForm + */ + protected function form_extradata(\MoodleQuickForm $mform, $helpname = "") { + $form = parent::form_extradata($mform); + $form = self::form_drap_drop_items($mform); + return $form; + } + + /** + * Returns editor attributes. + * + * @return array + */ + protected function get_editor_attributes(): array { + return [ + 'rows' => self::TEXTFIELD_ROWS, + 'cols' => self::TEXTFIELD_COLS, + ]; + } + + /** + * Returns editor options. + * + * @return array + */ + protected function get_editor_options(): array { + return [ + 'context' => $this->context, + 'noclean' => true, + ]; + } + + /** + * Returns editor options. + * + * @return int + */ + protected function get_answer_repeats(): int { + $repeats = self::NUM_ITEMS_DEFAULT; + if ($this->surveyid != 0) { + $repeats = count($this->_customdata['answers']); + } else if ($repeats < self::NUM_ITEMS_MIN) { + $repeats = self::NUM_ITEMS_MIN; + } + return $repeats; + } + + /** + * Returns editor options. + * + * @param string $type + * @param int $max + * @return array + */ + protected function get_addcount_options(string $type, int $max = 10): array { + // Generate options. + $options = []; + for ($i = 1; $i <= $max; $i++) { + if ($i == 1) { + $options[$i] = get_string("sortingaddsingle{$type}", 'mod_questionnaire'); + } else { + $options[$i] = get_string("sortingaddmultiple{$type}s", 'mod_questionnaire', $i); + } + } + return $options; + } + + /** + * Adjust HTML editor and removal buttons. + * + * @param object $mform + * @param string $name + */ + protected function adjust_html_editors($mform, string $name): void { + + // Cache the number of formats supported + // by the preferred editor for each format. + $count = []; + $ids = []; + + $answers = []; + + if (!empty($_POST['answer'])) { + $answers = $_POST['answer']; + } else if (!empty($extradata['answers'])) { + $answers = $extradata['answers']; + } + + if (!empty($answers)) { + $ids = array_keys($answers); + } + + $defaultanswerformat = FORMAT_MOODLE; + + $repeats = "count{$name}s"; // E.g. countanswers. + if ($mform->elementExists($repeats)) { + // Use mform element to get number of repeats. + $repeats = $mform->getElement($repeats)->getValue(); + } else { + // Determine number of repeats by object sniffing. + $repeats = 0; + while ($mform->elementExists($name . "[$repeats]")) { + $repeats++; + } + } + + for ($i = 0; $i < $repeats; $i++) { + $editor = $mform->getElement($name . "[$i]"); + + $id = null; + + if (isset($ids[$i])) { + $id = $ids[$i]; + } + + // The old/new name of the button to remove the HTML editor + // old : the name of the button when added by repeat_elements + // new : the simplified name of the button to satisfy "no_submit_button_pressed()" in lib/formslib.php. + $oldname = $name . "removeeditor[{$i}]"; + $newname = $name . "removeeditor_{$i}"; + + // Remove HTML editor, if necessary. + if (optional_param($newname, 0, PARAM_RAW)) { + $format = $this->reset_editor_format($editor, FORMAT_MOODLE); + $_POST['answer'][$i]['format'] = $format; // Overwrite incoming data. + } else if (!is_null($id)) { + $format = $answers[$id]['format']; + } else { + $format = $this->reset_editor_format($editor, $defaultanswerformat); + } + + // Check we have a submit button - it should always be there !! + if ($mform->elementExists($oldname)) { + if (! isset($count[$format])) { + $editor = editors_get_preferred_editor($format); + $count[$format] = $editor->get_supported_formats(); + $count[$format] = count($count[$format]); + } + if ($count[$format] > 1) { + $mform->removeElement($oldname); + } else { + $submit = $mform->getElement($oldname); + $submit->setName($newname); + } + $mform->registerNoSubmitButton($newname); + } + } + } + /** + * Reset editor format. + * + * @param object $editor + * @param string $format + * @return string + */ + protected function reset_editor_format($editor, string $format): string { + $value = $editor->getValue(); + $value['format'] = $format; + $editor->setValue($value); + return $format; + } + + /** + * Add new form direction for edit question sorting. + * + * @param \MoodleQuickForm $mform + * @return \MoodleQuickForm + */ + protected function form_direction(\MoodleQuickForm $mform): \MoodleQuickForm { + $helpname = 'sortingdirection'; + $mform->addElement('select', $helpname, + get_string($helpname, 'mod_questionnaire'), $this->responsetype->questionnaire_sort_type_layout()); + $mform->addHelpButton($helpname, $helpname, 'mod_questionnaire'); + $mform->setDefault($helpname, $this->_customdata['sortingdirection'] ?? QUESTIONNAIRE_LAYOUT_VERTICAL); + return $mform; + } + + /** + * Add new form drag and drop items. + * + * @param \MoodleQuickForm $mform + * @return \MoodleQuickForm + */ + protected function form_drap_drop_items(\MoodleQuickForm $mform): \MoodleQuickForm { + global $OUTPUT; + $type = 'answer'; + $types = $type . 's'; + $addtypes = 'add' . $types; + $counttypes = 'count' . $types; + $addtypescount = $addtypes . 'count'; + $addtypesgroup = $addtypes . 'group'; + $options = $this->get_addcount_options($type); + + $repeatnum = $this->get_answer_repeats(); + + $count = optional_param($addtypescount, self::NUM_ITEMS_ADD, PARAM_INT); + + $name = 'sortingdraggableitem'; + $label = get_string($name, 'mod_questionnaire'); + + $repeatarray = []; + $repeatarray[] = $mform->createElement('header', $name, $label); + $repeatarray[] = $mform->createElement('editor', 'answer', get_string($name, 'mod_questionnaire'), + $this->get_editor_attributes(), $this->get_editor_options()); + $repeatarray[] = $mform->createElement('submit', 'answerremoveeditor', + get_string('sortingremoveeditor', 'mod_questionnaire'), + ['onclick' => 'skipClientValidation = true;']); + $repeatoptions = []; + $repeatoptions[$name] = ['expanded' => true]; + + $possibleanswerslabel = get_string('possibleanswerssorting', 'questionnaire'); + $possibleanswers = html_writer::tag('b', $possibleanswerslabel); + $required = html_writer::empty_tag('img', [ + 'class' => 'req', + 'title' => get_string('required', 'questionnaire'), + 'alt' => get_string('required', 'questionnaire'), + 'src' => $OUTPUT->image_url('req'), + ]); + $helpicon = $OUTPUT->help_icon('possibleanswerssorting', 'questionnaire'); + $possibleanswershtml = $possibleanswers . ' ' . $required . ' ' . $helpicon; + $mform->addElement('html', $possibleanswershtml); + + $this->_form->repeat_elements( + $repeatarray, $repeatnum, $repeatoptions, $counttypes, $addtypes, $count, $label, true); + $mform->removeElement($addtypes); + $mform->addGroup([ + $mform->createElement('submit', $addtypes, get_string('add')), + $mform->createElement('select', $addtypescount, '', $options), + ], $addtypesgroup, '', ' ', false); + + if (!empty($this->_customdata) && $data = $this->_customdata['answers']) { + $mform->setDefault('answer', $data); + } + + // Adjust HTML editor and removal buttons. + $this->adjust_html_editors($mform, 'answer'); + + return $mform; + } + + /** + * True if question provides mobile support. + * + * @return bool + */ + public function supports_mobile() { + return true; + } + + /** + * Any preprocessing of general data. + * + * @param \stdClass $formdata + * @return bool + */ + protected function form_preprocess_data($formdata) { + // Remove empty answers. + $answers = array_filter($formdata->answer, [$this, 'is_not_blank']); + $result = []; + foreach (array_values($answers) as $index => $answer) { + $result[] = (object) [ + 'index' => $index, + 'text' => $answer['text'], + 'format' => $answer['format'], + ]; + } + $sortingdata = (object) [ + 'answers' => $result, + 'sortingdirection' => $formdata->sortingdirection, + ]; + $formdata->extradata = json_encode($sortingdata); + return parent::form_preprocess_data($formdata); + } + + /** + * Callback function for filtering answers with array_filter + * + * @param mixed $value + * @return bool If true, this item should be saved. + */ + public function is_not_blank($value): bool { + if (is_array($value)) { + $value = $value['text']; + } + $value = clean_param($value, PARAM_RAW_TRIMMED); + return ($value || $value === '0'); + } + + /** + * Custom edit form of questionnaire. + * + * @param edit_question_form $form The main moodleform object. + * @param questionnaire $questionnaire The questionnaire being edited. + * @return bool + */ + public function edit_form(edit_question_form $form, questionnaire $questionnaire): bool { + $this->_form =& $form; + if (!empty($this->qid)) { + $question = $questionnaire->questions[$this->qid]; + $data = $this->sortingdata; + $this->_customdata['sortingdirection'] = $data->sortingdirection ?? QUESTIONNAIRE_LAYOUT_VERTICAL; + if (!empty($data->answers)) { + foreach ($data->answers as $index => $answer) { + $this->_customdata['answers'][$index] = $answer; + } + } + } + return parent::edit_form($form, $questionnaire); + } + + /** + * Override and return false if not supporting mobile app. + * + * @param int $qnum + * @param bool $autonum + * @return \stdClass + */ + public function mobile_question_display($qnum, $autonum = false) { + $mobiledata = parent::mobile_question_display($qnum, $autonum); + $mobiledata->issorting = true; + return $mobiledata; + } + + /** + * Override and return false if not supporting mobile app. + * + * @return array + */ + public function mobile_question_choices_display() { + $choices = []; + $data = $this->sortingdata; + if (!empty($data->answers)) { + foreach ($data->answers as $index => $answer) { + $choices[$index] = new \stdClass(); + $choices[$index]->content = format_text($answer->text, $answer->format ?? FORMAT_MOODLE); + $choices[$index]->index = $index; + } + } + return $choices; + } + + /** + * Return the mobile response data. + * @param \stdClass $response + * @return array + */ + public function get_mobile_response_data($response) { + $resultdata = []; + if (isset($response->answers[$this->id])) { + foreach ($response->answers[$this->id] as $answer) { + $resultdata[$this->mobile_fieldkey()] = $answer->value; + } + } + return $resultdata; + } + + /** + * Prepare answers before pass to response type. + * @param string|null $value + * @param bool $istext + * @return array|string + */ + public function prepare_answers(?string $value = null, bool $istext = false) { + return $this->responsetype->prepare_answers($this->sortingdata->answers, $value, $istext); + } +} diff --git a/classes/questions_form.php b/classes/questions_form.php index 769cbe86..65bff64a 100644 --- a/classes/questions_form.php +++ b/classes/questions_form.php @@ -256,7 +256,8 @@ public function definition() { $manageqgroup[] =& $mform->createElement('image', 'editbutton['.$question->id.']', $esrc, $eextra); $manageqgroup[] =& $mform->createElement('image', 'removebutton['.$question->id.']', $rsrc, $rextra); - if ($tid != QUESPAGEBREAK && $tid != QUESSECTIONTEXT && $tid != QUESSLIDER) { + if ($tid != QUESPAGEBREAK && $tid != QUESSECTIONTEXT && $tid != QUESSLIDER + && $tid != QUESSORT) { if ($required == 'y') { $reqsrc = $questionnaire->renderer->image_url('t/stop'); $strrequired = get_string('required', 'questionnaire'); diff --git a/classes/responsetype/response/response.php b/classes/responsetype/response/response.php index 070160d2..bf5a4ad3 100644 --- a/classes/responsetype/response/response.php +++ b/classes/responsetype/response/response.php @@ -169,5 +169,6 @@ public function add_questions_answers() { $this->answers += \mod_questionnaire\responsetype\boolean::response_answers_by_question($this->id); $this->answers += \mod_questionnaire\responsetype\date::response_answers_by_question($this->id); $this->answers += \mod_questionnaire\responsetype\text::response_answers_by_question($this->id); + $this->answers += \mod_questionnaire\responsetype\sorting::response_answers_by_question($this->id); } } diff --git a/classes/responsetype/responsetype.php b/classes/responsetype/responsetype.php index 59e3715d..fd3953f1 100644 --- a/classes/responsetype/responsetype.php +++ b/classes/responsetype/responsetype.php @@ -69,8 +69,8 @@ public static function response_table() { */ public static function all_response_tables() { return ['questionnaire_response_bool', 'questionnaire_response_date', 'questionnaire_response_other', - 'questionnaire_response_rank', 'questionnaire_response_text', 'questionnaire_resp_multiple', - 'questionnaire_resp_single']; + 'questionnaire_response_rank', 'questionnaire_response_text', 'questionnaire_response_sort', + 'questionnaire_resp_multiple', 'questionnaire_resp_single']; } /** diff --git a/classes/responsetype/sorting.php b/classes/responsetype/sorting.php new file mode 100644 index 00000000..1d240ddd --- /dev/null +++ b/classes/responsetype/sorting.php @@ -0,0 +1,357 @@ +. + +namespace mod_questionnaire\responsetype; + +use mod_questionnaire\db\bulk_sql_config; + +/** + * Class for sorting response types. + * + * @author The Open University + * @license http://www.gnu.org/copyleft/gpl.html GNU Public License + * @package mod_questionnaire + */ +class sorting extends text { + /** + * Name of response table . + * + * @return string + */ + public static function response_table() { + return 'questionnaire_response_sort'; + } + + /** + * Provide an array of answer objects from web form data for the question. + * + * @param array $responsedata All of the responsedata as an object. + * @param \stdClass $question sorting type. + * @return array An array of answer objects. + */ + public static function answers_from_webform($responsedata, $question) { + $answers = []; + $values = []; + foreach ($responsedata as $key => $data) { + // Get value from input field by name is 'q1-1'. + if (preg_match('/q' . $question->id . '\-\d/', $key)) { + $values[] = $data; + } + } + if (count($values) > 0) { + $record = new \stdClass(); + $record->responseid = $responsedata->rid; + $record->questionid = $question->id; + $record->value = implode(',', $values); + $answers[] = answer\answer::create_from_data($record); + } + return $answers; + } + + /** + * Provide an array of answer objects from mobile data for the question. + * + * @param \stdClass $responsedata All the responsedata as an object. + * @param \stdClass $question sorting type. + * @return array \mod_questionnaire\responsetype\answer\answer An array of answer objects. + */ + public static function answers_from_appdata($responsedata, $question) { + $answers = []; + $qname = 'q' . $question->id; + if (isset($responsedata->{$qname}[0]) && !empty($responsedata->{$qname}[0])) { + $record = new \stdClass(); + $record->responseid = $responsedata->rid; + $record->questionid = $question->id; + $record->value = ''; + if (!empty($responsedata->{$qname}[0])) { + $record->value = $responsedata->{$qname}[0]; + } + $answers[] = answer\answer::create_from_data($record); + } + return $answers; + } + + /** + * Get response answer for sorting question. + * + * @param bool $rids response question + * @param bool $anonymous user. + * @return array result the answer. + * @throws \dml_exception + */ + public function get_results($rids = [], $anonymous = false) { + global $DB; + + $rsql = ""; + if (!empty($rids)) { + list($rsql, $params) = $DB->get_in_or_equal($rids); + $rsql = " AND response_id " . $rsql; + } + $userfields = []; + foreach (\core_user\fields::get_name_fields() as $field) { + $userfields[] = "u.{$field}"; + } + $sqluserfields = implode(', ', $userfields); + if ($anonymous) { + $sql = "SELECT t.id, t.response, r.submitted AS submitted, " . + "r.questionnaireid, r.id AS rid " . + "FROM {" . static::response_table() . "} t, " . + "{questionnaire_response} r " . + "WHERE question_id=" . $this->question->id . $rsql . + " AND t.response_id = r.id " . + "ORDER BY r.submitted DESC"; + } else { + $sql = "SELECT t.id, t.response, r.submitted AS submitted, + r.userid, + u.id as usrid, + r.questionnaireid, + r.id AS rid, + q.extradata, + " . $sqluserfields . " + FROM {" . self::response_table() . "} t, + {questionnaire_response} r, + {questionnaire_question} q, + {user} u + WHERE t.response_id = r.id + AND q.id = t.question_id + AND t.question_id = ? " . $rsql . " + AND u.id = r.userid + ORDER BY u.lastname, u.firstname, r.submitted;"; + } + $params = array_merge([$this->question->id], $params); + return $DB->get_records_sql($sql, $params); + } + + /** + * Provide a template for results screen if defined. + * + * @param bool $pdf printing. + * @return string The template string. + */ + public function results_template($pdf = false) { + if ($pdf) { + return 'mod_questionnaire/resultspdf_sorting'; + } + return 'mod_questionnaire/results_sorting'; + } + + /** + * Display answer results. + * + * @param array $rids response ids of question. + * @param string $sort + * @param bool $anonymous + * @return \stdClass result tags. + * @throws \coding_exception + */ + public function display_results($rids = [], $sort = '', $anonymous = false) { + if (is_array($rids)) { + $prtotal = 1; + } else if (is_int($rids)) { + $prtotal = 0; + } + $pagetags = new \stdClass(); + if ($rows = $this->get_results($rids, $anonymous)) { + $numrespondents = count($rids); + $numresponses = count($rows); + $pagetags = $this->get_results_tags($rows, $numrespondents, $numresponses, $prtotal); + } + return $pagetags; + } + + /** + * Override the results tags function for templates for questions with dates. + * + * @param array $rows + * @param int $participants Number of questionnaire participants. + * @param int $respondents Number of question respondents. + * @param int $showtotals + * @param string $sort + * @return \stdClass + * @throws \coding_exception + */ + public function get_results_tags($rows, $participants, $respondents, $showtotals = 1, $sort = '') { + $pagetags = new \stdClass(); + if ($respondents == 0) { + return $pagetags; + } + + if (is_object(reset($rows))) { + global $SESSION, $questionnaire; + $viewsingleresponse = $questionnaire->capabilities->viewsingleresponse; + $nonanonymous = $questionnaire->respondenttype != 'anonymous'; + $uri = '/mod/questionnaire/report.php'; + $urlparams = []; + if ($viewsingleresponse && $nonanonymous) { + $currentgroupid = ''; + if (isset($SESSION->questionnaire->currentgroupid)) { + $currentgroupid = $SESSION->questionnaire->currentgroupid; + } + $urlparams['action'] = 'vresp'; + $urlparams['sid'] = $questionnaire->survey->id; + $urlparams['currentgroupid'] = $currentgroupid; + $url = new \moodle_url($uri, $urlparams); + } + $evencolor = false; + foreach ($rows as $row) { + $response = new \stdClass(); + $response->respondent = ''; + $response->sorting = format_text($row->response, FORMAT_HTML); + if ($viewsingleresponse && $nonanonymous) { + $urlparams['rid'] = $row->rid; + $urlparams['individualresponse'] = 1; + $url = new \moodle_url($uri, $urlparams); + $response->respondent = \html_writer::link($url, fullname($row), ['title' => userdate($row->submitted)]); + } + // The 'evencolor' attribute is used by the PDF template. + $response->evencolor = $evencolor; + + // Preparing data to display anwser. + $extradata = !empty($row->extradata) ? json_decode($row->extradata) : ''; + $response->qelements = new \stdClass(); + $response->qelements->sortinglist = $this->prepare_answers($extradata->answers, $row->response); + $response->qelements->qid = 'q' . $row->rid; + $response->qelements->isresponse = true; + $layoutdirection = $extradata->sortingdirection ?? QUESTIONNAIRE_LAYOUT_VERTICAL; + $layout = $this->questionnaire_sort_type_layout()[$layoutdirection]; + $response->qelements->sortingdirection = strtolower($layout); + $pagetags->responses[] = (object)['response' => $response]; + $evencolor = !$evencolor; + } + + if ($showtotals == 1) { + $pagetags->total = new \stdClass(); + $pagetags->total->total = "$respondents/$participants"; + } + } + return $pagetags; + } + + /** + * Return an array of answers by question/choice for the given response. Must be implemented by the subclass. + * + * @param int $rid The response id. + * @return array response values. + * @throws \dml_exception + */ + public static function response_select($rid) { + global $DB; + + $values = []; + $sql = "SELECT q.id, q.content, a.response as aresponse " . + "FROM {" . static::response_table() . "} a, {questionnaire_question} q " . + "WHERE a.response_id=? AND a.question_id=q.id "; + $records = $DB->get_records_sql($sql, [$rid]); + foreach ($records as $qid => $row) { + unset($row->id); + $row = (array)$row; + $newrow = []; + foreach ($row as $key => $val) { + if (!is_numeric($key)) { + $newrow[] = $val; + } + } + $values[$qid] = $newrow; + $val = array_pop($values[$qid]); + array_push($values[$qid], $val, $val); + } + + return $values; + } + + /** + * Return an array of answer objects by question for the given response id. + * THIS SHOULD REPLACE response_select. + * + * @param int $rid The response id. + * @return array array answer. + * @throws \dml_exception + */ + public static function response_answers_by_question($rid) { + global $DB; + + $answers = []; + $sql = "SELECT id, response_id as responseid, question_id as questionid, 0 as choiceid, response as value " . + "FROM {" . static::response_table() ."} " . + "WHERE response_id = ? "; + $records = $DB->get_records_sql($sql, [$rid]); + foreach ($records as $record) { + $answers[$record->questionid][] = answer\answer::create_from_data($record); + } + + return $answers; + } + + /** + * Configure bulk sql. + * @return bulk_sql_config + */ + protected function bulk_sql_config() { + return new bulk_sql_config(static::response_table(), 'qrt', false, true, false); + } + + /** + * Preparing sorting answers. + * + * @param array|null $answers of questions. [array of \stdClass with properties: index, text]. + * @param string|null $responses join with (,) character. Example: 0,1,2. + * @param bool $istext + * @return array|string data sorting question. + */ + public function prepare_answers(array $answers, + ?string $responses = null, bool $istext = false) { + $sortinglist = []; + if (!empty($responses)) { + foreach (explode(',', $responses) as $index) { + if (isset($answers[$index])) { + $sortinglist[] = [ + 'index' => $index, + 'text' => format_text($answers[$index]->text, $answers[$index]->format), + 'title' => strip_tags($answers[$index]->text) + ]; + } + }; + } else { + foreach ($answers as $index => $answer) { + $sortinglist[] = [ + 'index' => $index, + 'text' => format_text($answer->text, $answer->format), + 'title' => strip_tags($answers[$index]->text) + ]; + }; + } + + if ($istext) { + $newsortinglist = array_map(function($item) { + return strip_tags($item['text']); + }, $sortinglist); + $sortinglist = join(' ', $newsortinglist); + } + return $sortinglist; + } + + /** + * Get key and value of type question layout. + * + * @return array + */ + public function questionnaire_sort_type_layout(): array { + return [ + QUESTIONNAIRE_LAYOUT_VERTICAL => get_string(QUESTIONNAIRE_LAYOUT_VERTICAL_VALUE, 'mod_questionnaire'), + QUESTIONNAIRE_LAYOUT_HORIZONTAL => get_string(QUESTIONNAIRE_LAYOUT_HORIZONTAL_VALUE, 'mod_questionnaire'), + ]; + } +} diff --git a/complete.php b/complete.php index 9950770f..6514cb36 100644 --- a/complete.php +++ b/complete.php @@ -78,6 +78,9 @@ $event->trigger(); } +// Initialize javascript. +$PAGE->requires->js_call_amd('mod_questionnaire/sorting_reorder', 'init'); + // Generate the view HTML in the page. $questionnaire->view(); diff --git a/db/install.php b/db/install.php index 55b0eda4..b2705cb3 100644 --- a/db/install.php +++ b/db/install.php @@ -100,6 +100,13 @@ function xmldb_questionnaire_install() { $questiontype->response_table = 'response_text'; $id = $DB->insert_record('questionnaire_question_type', $questiontype); + $questiontype = new stdClass(); + $questiontype->typeid = 12; + $questiontype->type = 'Sorting'; + $questiontype->has_choices = 'n'; + $questiontype->response_table = 'response_sort'; + $id = $DB->insert_record('questionnaire_question_type', $questiontype); + $questiontype = new stdClass(); $questiontype->typeid = 99; $questiontype->type = 'Page Break'; diff --git a/db/install.xml b/db/install.xml index a1b7af0b..098bdc01 100644 --- a/db/install.xml +++ b/db/install.xml @@ -225,6 +225,20 @@ + + + + + + + + + + + + + +
diff --git a/db/mobile.php b/db/mobile.php index 01bec8fb..2a1abbce 100644 --- a/db/mobile.php +++ b/db/mobile.php @@ -36,8 +36,9 @@ 'method' => 'mobile_view_activity', 'styles' => [ 'url' => $CFG->wwwroot . '/mod/questionnaire/styles_app.css', - 'version' => '1.5' - ] + 'version' => '1.6' + ], + 'init' => 'mobile_questionnaire_init', ] ], 'lang' => [ @@ -50,6 +51,7 @@ ['savechanges', 'moodle'], ['nextpage', 'questionnaire'], ['previouspage', 'questionnaire'], + ['unsupported_version', 'questionnaire'], ['fullname', 'moodle'], ['required', 'moodle'] ], diff --git a/db/upgrade.php b/db/upgrade.php index 3861f365..67bdf8e8 100644 --- a/db/upgrade.php +++ b/db/upgrade.php @@ -997,6 +997,37 @@ function xmldb_questionnaire_upgrade($oldversion=0) { upgrade_mod_savepoint(true, 2022092200, 'questionnaire'); } + if ($oldversion < 2022123000) { + $exist = $DB->record_exists('questionnaire_question_type', ['typeid' => 12]); + if (!$exist) { + $questiontype = new stdClass(); + $questiontype->typeid = 12; + $questiontype->type = 'Sorting'; + $questiontype->has_choices = 'n'; + $questiontype->response_table = 'response_sort'; + $DB->insert_record('questionnaire_question_type', $questiontype); + } + + // Define table questionnaire_response_sort to be created. + $table = new xmldb_table('questionnaire_response_sort'); + + // Adding fields to table questionnaire_response_sort. + $table->add_field('id', XMLDB_TYPE_INTEGER, '10', null, XMLDB_NOTNULL, XMLDB_SEQUENCE, null); + $table->add_field('response_id', XMLDB_TYPE_INTEGER, '10', null, XMLDB_NOTNULL, null, null); + $table->add_field('question_id', XMLDB_TYPE_INTEGER, '10', null, XMLDB_NOTNULL, null, null); + $table->add_field('response', XMLDB_TYPE_TEXT, null, null, null, null, null); + + $table->add_key('primary', XMLDB_KEY_PRIMARY, ['id']); + $table->add_key('question_id', XMLDB_KEY_FOREIGN, ['question_id'], 'questionnaire_question', ['id']); + + // Conditionally launch create table for questionnaire_response_sort. + if (!$dbman->table_exists($table)) { + $dbman->create_table($table); + } + + upgrade_mod_savepoint(true, 2022123000, 'questionnaire'); + } + return $result; } diff --git a/lang/en/questionnaire.php b/lang/en/questionnaire.php index dce6e767..32b7f87c 100644 --- a/lang/en/questionnaire.php +++ b/lang/en/questionnaire.php @@ -449,6 +449,11 @@ $string['privacy:metadata:questionnaire_response_text:question_id'] = 'The ID of the question record for this response.'; $string['privacy:metadata:questionnaire_response_text:response'] = 'The specific text answer.'; +$string['privacy:metadata:questionnaire_response_sort'] = 'A sorting question response.'; +$string['privacy:metadata:questionnaire_response_sort:response_id'] = 'The ID of the response record for this response.'; +$string['privacy:metadata:questionnaire_response_sort:question_id'] = 'The ID of the question record for this response.'; +$string['privacy:metadata:questionnaire_response_sort:response'] = 'The specific sorting answer.'; + $string['privacy:metadata:questionnaire_resp_multiple'] = 'A multiple choice question response.'; $string['privacy:metadata:questionnaire_resp_multiple:response_id'] = 'The ID of the response record for this response.'; $string['privacy:metadata:questionnaire_resp_multiple:question_id'] = 'The ID of the question record for this response.'; @@ -589,6 +594,21 @@ $string['started'] = 'started'; $string['strfdate'] = '%d/%m/%Y'; $string['strfdateformatcsv'] = 'd/m/Y H:i:s'; +// Sorting question strings. +$string['sorting'] = 'Sorting'; +$string['sorting_help'] = 'Several items are displayed in a jumbled sorting. The items can be dragged into a meaningful sorting.'; +$string['sortingerrorsformitems'] = 'Sorting questions must have more than 2 answers.'; +$string['sortingaddsingleanswer'] = 'Add one more item'; +$string['sortingaddmultipleanswers'] = 'Add {$a} more items'; +$string['sortingdraggableitem'] = 'Draggable item {no}'; +$string['sortingdirection'] = 'Direction'; +$string['sortingdirection_help'] = 'Choose whether to display the items vertically or horizontally.'; +$string['sortingremoveeditor'] = 'Remove HTML editor'; +$string['sortingremoveitem'] = 'Remove draggable item'; +$string['sortinglabelanswers_help'] = 'In this question type, the respondent must select and sort the choices offered into their personal preference.'; +$string['sortingstrictformatting'] = 'Please rank the following items. It is OK if you don\'t need to change anything.'; +$string['possibleanswerssorting'] = 'Possible answers'; +$string['possibleanswerssorting_help'] = 'In this question type, the respondent must select and sort the choices offered into their personal preference.'; $string['submissionnotificationhtmlanon'] = 'There is a new submission to the "{$a->name}" questionnaire.'; $string['submissionnotificationhtmluser'] = '{$a->username} has a new submission to the "{$a->name}" questionnaire in the course "{$a->coursename}".'; $string['submissionnotificationsubject'] = 'New questionnaire submission'; @@ -642,6 +662,7 @@ $string['undefinedquestiontype'] = 'Undefined question type!'; $string['unknown'] = 'Unknown'; $string['unknownaction'] = 'Unknown questionnaire action specified...'; +$string['unsupported_version'] = 'Unsupported in this version of the app.'; $string['url'] = 'Confirmation URL'; $string['url_help'] = 'The URL to which a user is redirected after completing this questionnaire.'; $string['useprivate'] = 'Copy existing'; diff --git a/locallib.php b/locallib.php index f58a5d49..e7a0e880 100644 --- a/locallib.php +++ b/locallib.php @@ -29,23 +29,25 @@ defined('MOODLE_INTERNAL') || die(); -require_once($CFG->dirroot.'/calendar/lib.php'); +require_once($CFG->dirroot . '/calendar/lib.php'); // Constants. -define ('QUESTIONNAIREUNLIMITED', 0); -define ('QUESTIONNAIREONCE', 1); -define ('QUESTIONNAIREDAILY', 2); -define ('QUESTIONNAIREWEEKLY', 3); -define ('QUESTIONNAIREMONTHLY', 4); - -define ('QUESTIONNAIRE_STUDENTVIEWRESPONSES_NEVER', 0); -define ('QUESTIONNAIRE_STUDENTVIEWRESPONSES_WHENANSWERED', 1); -define ('QUESTIONNAIRE_STUDENTVIEWRESPONSES_WHENCLOSED', 2); -define ('QUESTIONNAIRE_STUDENTVIEWRESPONSES_ALWAYS', 3); - +define('QUESTIONNAIREUNLIMITED', 0); +define('QUESTIONNAIREONCE', 1); +define('QUESTIONNAIREDAILY', 2); +define('QUESTIONNAIREWEEKLY', 3); +define('QUESTIONNAIREMONTHLY', 4); +define('QUESTIONNAIRE_STUDENTVIEWRESPONSES_NEVER', 0); +define('QUESTIONNAIRE_STUDENTVIEWRESPONSES_WHENANSWERED', 1); +define('QUESTIONNAIRE_STUDENTVIEWRESPONSES_WHENCLOSED', 2); +define('QUESTIONNAIRE_STUDENTVIEWRESPONSES_ALWAYS', 3); define('QUESTIONNAIRE_MAX_EVENT_LENGTH', 5 * 24 * 60 * 60); // 5 days maximum. - define('QUESTIONNAIRE_DEFAULT_PAGE_COUNT', 20); +define('QUESTIONNAIRE_LAYOUT_VERTICAL_VALUE', 'vertical'); +define('QUESTIONNAIRE_LAYOUT_HORIZONTAL_VALUE', 'horizontal'); +define('QUESTIONNAIRE_LAYOUT_VERTICAL', 0); +define('QUESTIONNAIRE_LAYOUT_HORIZONTAL', 1); + global $questionnairetypes; $questionnairetypes = array (QUESTIONNAIREUNLIMITED => get_string('qtypeunlimited', 'questionnaire'), @@ -315,6 +317,7 @@ function questionnaire_delete_response($response, $questionnaire='') { $DB->delete_records('questionnaire_response_rank', array('response_id' => $rid)); $DB->delete_records('questionnaire_resp_single', array('response_id' => $rid)); $DB->delete_records('questionnaire_response_text', array('response_id' => $rid)); + $DB->delete_records('questionnaire_response_sort', ['response_id' => $rid]); $status = $status && $DB->delete_records('questionnaire_response', array('id' => $rid)); @@ -345,6 +348,7 @@ function questionnaire_delete_responses($qid) { $DB->delete_records('questionnaire_response_rank', ['question_id' => $qid]); $DB->delete_records('questionnaire_resp_single', ['question_id' => $qid]); $DB->delete_records('questionnaire_response_text', ['question_id' => $qid]); + $DB->delete_records('questionnaire_response_sort', ['question_id' => $qid]); return true; } @@ -489,6 +493,8 @@ function questionnaire_get_type ($id) { return get_string('numeric', 'questionnaire'); case 11: return get_string('slider', 'questionnaire'); + case 12: + return get_string('sorting', 'questionnaire'); case 100: return get_string('sectiontext', 'questionnaire'); case 99: diff --git a/preview.php b/preview.php index 49245e3e..a62a7aeb 100644 --- a/preview.php +++ b/preview.php @@ -119,12 +119,10 @@ } // Include the needed js. - - $PAGE->requires->js('/mod/questionnaire/module.js'); -// Print the tabs. - +$PAGE->requires->js_call_amd('mod_questionnaire/sorting_reorder', 'init'); +// Print the tabs. echo $questionnaire->renderer->header(); if (!$popup) { require('tabs.php'); diff --git a/questionnaire.class.php b/questionnaire.class.php index 901b07dd..65e5338f 100644 --- a/questionnaire.class.php +++ b/questionnaire.class.php @@ -1941,7 +1941,7 @@ private function response_delete($rid, $sec = null) { /* delete values */ $select = 'response_id = \'' . $rid . '\' ' . $qsql; foreach (array('response_bool', 'resp_single', 'resp_multiple', 'response_rank', 'response_text', - 'response_other', 'response_date') as $tbl) { + 'response_other', 'response_date', 'response_sort') as $tbl) { $DB->delete_records_select('questionnaire_'.$tbl, $select, $params); } } @@ -2012,7 +2012,7 @@ private function response_select_max_pos($rid) { $max = 0; foreach (array('response_bool', 'resp_single', 'resp_multiple', 'response_rank', 'response_text', - 'response_other', 'response_date') as $tbl) { + 'response_other', 'response_date', 'response_sort') as $tbl) { $sql = 'SELECT MAX(q.position) as num FROM {questionnaire_'.$tbl.'} a, {questionnaire_question} q '. 'WHERE a.response_id = ? AND '. 'q.id = a.question_id AND '. @@ -3254,6 +3254,7 @@ public function generate_csv($currentgroupid, $rid='', $userid='', $choicecodes= '1', // 9: date -> string '0', // 10: numeric -> number. '0', // 11: slider -> number. + '1', // 12: sort -> string ); if (!$survey = $DB->get_record('questionnaire_survey', array('id' => $this->survey->id))) { @@ -3407,7 +3408,9 @@ public function generate_csv($currentgroupid, $rid='', $userid='', $choicecodes= } else { $columns[][$qpos] = $col; $questionidcols[][$qpos] = $qid; - array_push($types, $idtocsvmap[$type]); + if (isset($idtocsvmap[$type])) { + array_push($types, $idtocsvmap[$type]); + } } $num++; } @@ -3520,6 +3523,9 @@ public function generate_csv($currentgroupid, $rid='', $userid='', $choicecodes= } $responsetxt = $choicetxt; $row[$position] = $responsetxt; + } else if ($qtype === QUESSORT) { + $row[$questionpositions[$qid]] = $questionobj->prepare_answers( + $responserow->response, true); } else { $position = $questionpositions[$qid]; if ($questionobj->has_choices()) { diff --git a/styles.css b/styles.css index 41dcd324..aee23e76 100644 --- a/styles.css +++ b/styles.css @@ -348,6 +348,89 @@ td.selected { height: auto; } +.qn-sorting-list { + float: left; + list-style-type: none; + margin: 0 0 0 8px; +} + +ol.qn-sorting-list { + padding-left: 0; + margin-inline-start: 0; +} + +.qn-sorting-list.active { + border: 1px dotted #333; + border-radius: 4px; +} + +.qn-sorting-list li { + background-color: #fff; + border: 1px solid #000; + list-style-type: none; + border-radius: 0; + margin: 10px 10px 10px 0; + padding: 5px 8px; +} + +.qn-sorting-list li.qn-sorting-list__items { + position: relative; + cursor: move; +} + +.qn-sorting-list li.qn-sorting-list__items:focus { + border-color: #0a0; + box-shadow: 0 0 5px 5px rgba(255, 255, 150, 1); +} + +.qn-sorting-list.numberingnone li { + list-style-type: none; + margin-left: 0; +} + +.qn-sorting-list.horizontal { + display: flex; + flex-wrap: wrap; +} + +/* Better define 'row' of item for horizontal list. */ +.qn-sorting-list.horizontal { + display: flex; + flex-wrap: wrap; + align-items: flex-start; +} + +.qn-sorting-list.vertical li { + min-height: 18px; +} + +/* Styles for when things are being dragged. */ +.qn-sorting-list-dragproxy { + margin: 0; + padding: 0; + border: 0 none; +} +.qn-sorting-list-dragproxy .qn-sorting-list { + margin: 0; + padding: 0; + float: none; +} +.qn-sorting-list-dragproxy .qn-sorting-list li.qn-sorting-list__items { + margin: 0; + padding: 6px 0 6px 12px; + width: 100%; + word-wrap: break-word; +} +.qn-sorting-list-dragproxy .qn-sorting-list li.horizontal { + float: none; +} +.item-moving { + box-shadow: 3px 3px 4px #000; +} +.current-drop { + visibility: hidden; +} + #notice .qn-question { margin: 0; } diff --git a/styles_app.css b/styles_app.css index e1b368db..d0a550d8 100644 --- a/styles_app.css +++ b/styles_app.css @@ -14,4 +14,9 @@ span.mobileratequestion { ion-label.disabled { opacity: 0.8 !important; +} + +ion-label.unsupported_version { + font-weight: bold; + font-size: 1em; } \ No newline at end of file diff --git a/templates/local/mobile/ionic3/sorting_question.mustache b/templates/local/mobile/ionic3/sorting_question.mustache new file mode 100644 index 00000000..d8ba8d82 --- /dev/null +++ b/templates/local/mobile/ionic3/sorting_question.mustache @@ -0,0 +1,40 @@ +{{! + This file is part of Moodle - http://moodle.org/ + + Moodle is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + Moodle is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with Moodle. If not, see . +}} +{{! + @template mod_questionnaire/mobile_sorting_question + + Template which defines a sorting question display in the mobile app. + + Context variables required for this template: + * id - number: question id. + * fieldkey - string: key for storing data from mobile to web server. + * choices - [sorting_question]: List of sorting answer. + * completed - boolean: question has completed. + * content - string|HTML|MoodleFormat|Markdown: content of answer. + + Example context (json): + { + "id" : 1, + "fieldkey" : response_16_60 + "choices": [{ + "content" : "The answer 1", + "completed" : true + }] + } +}} +{{=<% %>=}} +{{ 'plugin.mod_questionnaire.unsupported_version' | translate }} \ No newline at end of file diff --git a/templates/local/mobile/ionic3/view_activity_page.mustache b/templates/local/mobile/ionic3/view_activity_page.mustache index 62e9ae53..ce885a3a 100644 --- a/templates/local/mobile/ionic3/view_activity_page.mustache +++ b/templates/local/mobile/ionic3/view_activity_page.mustache @@ -124,6 +124,9 @@ <%#isslider%> <%> mod_questionnaire/local/mobile/ionic3/slider_question %> <%/isslider%> + <%#issorting%> + <%> mod_questionnaire/local/mobile/ionic3/sorting_question %> + <%/issorting%> <%/pagequestions%> <%^pagequestions%> diff --git a/templates/local/mobile/latest/sorting_question.mustache b/templates/local/mobile/latest/sorting_question.mustache new file mode 100644 index 00000000..3424dbd7 --- /dev/null +++ b/templates/local/mobile/latest/sorting_question.mustache @@ -0,0 +1,52 @@ +{{! + This file is part of Moodle - http://moodle.org/ + + Moodle is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + Moodle is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with Moodle. If not, see . +}} +{{! + @template mod_questionnaire/mobile_sorting_question + + Template which defines a sorting question display in the mobile app. + + Context variables required for this template: + * id - number: question id. + * fieldkey - string: key for storing data from mobile to web server. + * choices - [sorting_question]: List of sorting answer. + * completed - boolean: question has completed. + * content - string|HTML|MoodleFormat|Markdown: content of answer. + + Example context (json): + { + "id" : 1, + "fieldkey" : response_16_60 + "choices": [{ + "content" : "The answer 1", + "completed" : true + }] + } +}} +{{=<% %>=}} + +disabled="false"<%/completed%> data-qid="<%id%>"> + <%#choices%> + + + + + <%^completed%> + + <%/completed%> + + <%/choices%> + diff --git a/templates/local/mobile/latest/view_activity_page.mustache b/templates/local/mobile/latest/view_activity_page.mustache index 54a1bae8..4bd58060 100644 --- a/templates/local/mobile/latest/view_activity_page.mustache +++ b/templates/local/mobile/latest/view_activity_page.mustache @@ -127,6 +127,9 @@ <%#isslider%> <%> mod_questionnaire/local/mobile/latest/slider_question %> <%/isslider%> + <%#issorting%> + <%> mod_questionnaire/local/mobile/latest/sorting_question %> + <%/issorting%> <%/pagequestions%> <%^pagequestions%> diff --git a/templates/question_container.mustache b/templates/question_container.mustache index 53986bbf..56574f0d 100644 --- a/templates/question_container.mustache +++ b/templates/question_container.mustache @@ -45,7 +45,7 @@
{{{dependencylist}}} {{#qnum}} - {{# str }}questionnum, mod_questionnaire{{/ str}}{{{qnum}}} + {{# str }}questionnum, mod_questionnaire{{/ str}}{{{qnum}}} {{{qlegend}}}

{{{qnum}}}

diff --git a/templates/question_sorting.mustache b/templates/question_sorting.mustache new file mode 100644 index 00000000..3bc5cb27 --- /dev/null +++ b/templates/question_sorting.mustache @@ -0,0 +1,58 @@ +{{! + This file is part of Moodle - http://moodle.org/ + + Moodle is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + Moodle is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with Moodle. If not, see . +}} +{{! + @template mod_questionnaire/question_sorting + + Template which defines a text type question survey display. + + Classes required for JS: + * /mod/questionnaire/module.js + + Data attributes required for JS: + * none + + Context variables required for this template: + + Example context (json): + { + "qelements": { + "sortinglist": { + "index": "1", + "text": "30", + "qid": "455" + }, + "sortingdirection": "vertical" + } + } + }} + +
+ {{#qelements}} + {{^isresponse}}
{{#str}}sortingstrictformatting, mod_questionnaire{{/str}}
{{/isresponse}} +
    + {{#sortinglist}} +
  1. +
    +
    {{{text}}}
    + +
    +
  2. + {{/sortinglist}} +
+ {{/qelements}} +
+ diff --git a/templates/response_sorting.mustache b/templates/response_sorting.mustache new file mode 100644 index 00000000..c766a2bb --- /dev/null +++ b/templates/response_sorting.mustache @@ -0,0 +1,35 @@ +{{! + This file is part of Moodle - http://moodle.org/ + + Moodle is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + Moodle is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with Moodle. If not, see . +}} +{{! + @template mod_questionnaire/response_sorting + + Template which defines a sorting type question survey display. + + Consorting variables required for this template: + + Example context (json): + { + "content": "HTML for sorting" + } +}} + +
+ {{#content}} + {{> mod_questionnaire/question_sorting }} + {{/content}} +
+ diff --git a/templates/results_sorting.mustache b/templates/results_sorting.mustache new file mode 100644 index 00000000..aa30ca7c --- /dev/null +++ b/templates/results_sorting.mustache @@ -0,0 +1,88 @@ +{{! + This file is part of Moodle - http://moodle.org/ + + Moodle is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + Moodle is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with Moodle. If not, see . +}} +{{! + @template mod_questionnaire/results_sorting + + Template which defines a results display for sorting responses. + + Classes required for JS: + * /mod/questionnaire/module.js + + Data attributes required for JS: + * none + + Context variables required for this template: + + Example context (json): + { + "responses": [ + { + "response": { + "respondent" : "User 1", + "template": "mod_questionnaire/question_sorting" + } + }, + { + "response": { + "respondent" : "User 2", + "template": "mod_questionnaire/question_sorting" + } + } + ], + "total": { + "total": "8/10" + } + } + }} + +{{#responses.0}} +
+ + + + + + + +{{/responses.0}} +{{#responses}} + {{#response}} + + + + + {{/response}} +{{/responses}} +{{#total}} + + + + + + + +{{/total}} +{{#responses.0}} + +
{{#str}} respondent, mod_questionnaire {{/str}}{{#str}} response, mod_questionnaire {{/str}}
{{{response.respondent}}} + {{> mod_questionnaire/question_sorting }} +
{{#str}} totalresponses, mod_questionnaire{{/str}}{{total}}
+{{/responses.0}} +{{^responses}} +

 {{#str}} noresponsedata, questionnaire{{/str}}

+{{/responses}} + diff --git a/templates/resultspdf_sorting.mustache b/templates/resultspdf_sorting.mustache new file mode 100644 index 00000000..e84dc413 --- /dev/null +++ b/templates/resultspdf_sorting.mustache @@ -0,0 +1,90 @@ +{{! + This file is part of Moodle - http://moodle.org/ + + Moodle is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + Moodle is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with Moodle. If not, see . +}} +{{! + @template mod_questionnaire/results_sorting + + Template which defines a results display for sorting responses. This template uses a simple level of HTML, suitable for being + translated into a PDF file by the TCPDF library. + + Classes required for JS: + * /mod/questionnaire/module.js + + Data attributes required for JS: + * none + + Context variables required for this template: + + Example context (json): + { + "responses": [ + { + "response": { + "respondent" : "User 1", + "template": "mod_questionnaire/question_sorting" + } + }, + { + "response": { + "respondent" : "User 2", + "template": "mod_questionnaire/question_sorting" + } + } + ], + "total": { + "total": "8/10" + } + } + }} + + +{{#responses.0}} + + + + + + + + +{{/responses.0}} +{{#responses}} + {{#response}} + + + + + {{/response}} +{{/responses}} +{{#total}} + + + + + + + +{{/total}} +{{#responses.0}} + +
{{#str}} respondent, mod_questionnaire {{/str}}{{#str}} response, mod_questionnaire {{/str}}
{{{response.respondent}}} + {{> mod_questionnaire/question_sorting }} +
{{#str}} totalresponses, mod_questionnaire{{/str}}{{total}}
+{{/responses.0}} +{{^responses}} +

 {{#str}} noresponsedata, questionnaire{{/str}}

+{{/responses}} + diff --git a/tests/behat/behat_mod_questionnaire.php b/tests/behat/behat_mod_questionnaire.php index b0d67965..a76e986e 100644 --- a/tests/behat/behat_mod_questionnaire.php +++ b/tests/behat/behat_mod_questionnaire.php @@ -117,6 +117,7 @@ public function i_add_a_question_and_i_fill_the_form_with($questiontype, TableNo 'Numeric', 'Radio Buttons', 'Rate (scale 1..5)', + 'Sorting', 'Text Box', 'Yes/No', 'Slider'); @@ -464,4 +465,48 @@ protected function get_cm_by_questionnaire_name(string $name): stdClass { $questionnaire = $this->get_questionnaire_by_name($name); return get_coursemodule_from_instance('questionnaire', $questionnaire->id, $questionnaire->course); } + + /** + * Get the xpath for a given item by label. + * + * @param string $label the text of the item to drag. + * @return string the xpath expression. + */ + protected function item_xpath_by_label(string $label): string { + return '//li[contains(@class, "qn-sorting-list__items") and contains(., "' + . $this->escape($label) . '")]'; + } + + /** + * Get the xpath for a given drop box. + * + * @param string $position the number of place to drop it. + * @return string the xpath expression. + */ + protected function item_xpath_by_position(string $position): string { + return '//li[contains(@class, "qn-sorting-list__items")][' . $position . ']'; + } + + /** + * Drag the drag item with the given text to the given space. + * + * Also, do not use this to drag an item to the last place. Just drag all + * the other non-last items to their place. + * + * @param string $label the text of the item to drag. + * @param int $position the number of the position to drop it at. + * + * @Given /^I drag "(?P