diff --git a/corsscripts/sortable.css b/corsscripts/sortable.css index f72f4c7d826118c1aa3f1972f13546260cb36980..66570e5f6c105a7b49b214b7bbdd2df6d436ade5 100644 --- a/corsscripts/sortable.css +++ b/corsscripts/sortable.css @@ -11,18 +11,32 @@ button { } .container { - overflow: auto; + display: flex; + flex-wrap: wrap; } -#usedList:empty { - height:50px; background-color: floralwhite; border: 2px solid rgb(155, 199, 206); -} - -#usedList > li { +.usedList > li[data-id] { background-color:rgb(176, 221, 228); border-left: thick solid rgb(155, 199, 206); float: left; + flex-shrink: 1; +} + +.usedList.col-rigid > li[data-id] { + margin-left: 0; +} + +.row { + margin-right: 0; + margin-left: 0; + margin-bottom: 0; } - + +.col-rigid { + width: 175px; + flex: 0 0 auto; + display: inline-block +} + #availableList > li { background-color:rgb(243, 189, 88); float: left; @@ -36,16 +50,72 @@ button { box-shadow:0px 0px 0px 3px rgb(226, 150, 9) inset; } -#usedList > .sortable-chosen { +.usedList > .sortable-chosen { box-shadow:0px 0px 0px 3px rgb(155, 199, 206) inset; } -#usedList > .header { - background-color: inherit; text-align: center; border-bottom: thick solid; +.usedList > .header { + height: 50px; + background-color: inherit; text-align: center; border-bottom: thick solid; padding: 10px; + display: inline-block; +} + +.usedList > .index { + background-color: inherit; text-align: left; border-right: thick solid; display: inline-block; + margin-right: 12px; } #availableList > .header { - background-color: inherit; text-align: center; border-bottom: thick solid rgb(196, 131, 10); + min-height: 50px; + height: auto; + width: max(100%, 150px); + background-color: inherit; text-align: center; border-bottom: thick solid rgb(196, 131, 10); + padding: 10px; display: inline-block; +} + +#availableList > .index { + min-height: 50px; + height: auto; + background-color: inherit; text-align: left; border-right: thick solid rgb(196, 131, 10); + padding: 10px; margin: 12px; +} + +.grid-item { + height: 50px; + background-color: #fff; + border: solid 1px rgb(0,0,0,0.2); + padding: 10px; + margin: 12px; + display: flex; + text-align: center; +} + +.grid-item-rigid { + height: 50px; + width: 150px; + background-color: #fff; + border: solid 1px rgb(0,0,0,0.2); + padding: 10px; + margin: 12px; + display: flex; + text-align: center; +} + +#availableList > .grid-item-rigid.index { + width: 150px; +} + +.usedList:empty { + height: 50px; + background-color: floralwhite; border: 1px solid rgb(155, 199, 206); + padding: 10px; + margin: 12px; + text-align: center; + display: flex; +} + +.usedList.col-rigid:empty { + width: 150px; } .parsons-button { diff --git a/corsscripts/sortable.min.css b/corsscripts/sortable.min.css index 3923116800265f04807ee1849cada19c0c0d1bc4..ad854424b330d8ff094a502fadc7f1b6559a2775 100644 --- a/corsscripts/sortable.min.css +++ b/corsscripts/sortable.min.css @@ -1,2 +1,2 @@ @import url("https://cdn.jsdelivr.net/npm/bootstrap@5.3.1/dist/css/bootstrap.min.css");@import url("https://cdnjs.cloudflare.com/ajax/libs/font-awesome/4.7.0/css/font-awesome.min.css");@import url("../styles.css");body{background-color:inherit;} -button{margin:2px 3px;}.container{overflow:auto;}#usedList:empty{height:50px;background-color:floralwhite;border:2px solid rgb(155,199,206);}#usedList>li{background-color:rgb(176,221,228);border-left:thick solid rgb(155,199,206);float:left;}#availableList>li{background-color:rgb(243,189,88);float:left;}#availableList:empty{height:50px;background-color:lightpink;}#availableList>.sortable-chosen{box-shadow:0px 0px 0px 3px rgb(226,150,9)inset;}#usedList>.sortable-chosen{box-shadow:0px 0px 0px 3px rgb(155,199,206)inset;}#usedList>.header{background-color:inherit;text-align:center;border-bottom:thick solid;}#availableList>.header{background-color:inherit;text-align:center;border-bottom:thick solid rgb(196,131,10);}.parsons-button{display:inline-flex;align-items:center;padding:0.5rem 0.75rem;margin:2px 2px;font-size:25px;line-height:1.5;text-align:center;white-space:nowrap;vertical-align:middle;user-select:none;cursor:pointer;background-color:#6c757d;border:1px solid#6c757d;color:#ffffff;border-radius:0.35rem;transition:color 0.15s ease-in-out,background-color 0.15s ease-in-out,border-color 0.15s ease-in-out,box-shadow 0.15s ease-in-out;}.parsons-button:hover{background-color:#5a6268;border-color:#545b62;color:#ffffff;}.parsons-bin{width:33%;cursor:default;}.bin-icon{margin-right:0.75rem;}.drop-zone{vertical-align:middle;display:inline-flex;flex:1;border:2px dashed#6c757d;border-radius:0.25rem;background-color:lightgray;align-self:stretch;}.drop-zone>li{font-size:0.75rem;background-color:lightcoral;max-height:100%;max-width:100%;text-align:left;flex:1;box-shadow:0px 0px 0px 2px rgb(167,89,89)inset;}.sortable-warning{float:right;font-size:0.8em;margin:-0.3em-0.3em;} \ No newline at end of file +button{margin:2px 3px;}.container{display:flex;flex-wrap:wrap;}.usedList>li[data-id]{background-color:rgb(176,221,228);border-left:thick solid rgb(155,199,206);float:left;flex-shrink:1;}.usedList.col-rigid>li[data-id]{margin-left:0;}.row{margin-right:0;margin-left:0;margin-bottom:0;}.col-rigid{width:175px;flex:0 0 auto;display:inline-block}#availableList>li{background-color:rgb(243,189,88);float:left;}#availableList:empty{height:50px;background-color:lightpink;}#availableList>.sortable-chosen{box-shadow:0px 0px 0px 3px rgb(226,150,9)inset;}.usedList>.sortable-chosen{box-shadow:0px 0px 0px 3px rgb(155,199,206)inset;}.usedList>.header{height:50px;background-color:inherit;text-align:center;border-bottom:thick solid;padding:10px;display:inline-block;}.usedList>.index{background-color:inherit;text-align:left;border-right:thick solid;display:inline-block;margin-right:12px;}#availableList>.header{min-height:50px;height:auto;width:max(100%,150px);background-color:inherit;text-align:center;border-bottom:thick solid rgb(196,131,10);padding:10px;display:inline-block;}#availableList>.index{min-height:50px;height:auto;background-color:inherit;text-align:left;border-right:thick solid rgb(196,131,10);padding:10px;margin:12px;}.grid-item{height:50px;background-color:#fff;border:solid 1px rgb(0,0,0,0.2);padding:10px;margin:12px;display:flex;text-align:center;}.grid-item-rigid{height:50px;width:150px;background-color:#fff;border:solid 1px rgb(0,0,0,0.2);padding:10px;margin:12px;display:flex;text-align:center;}#availableList>.grid-item-rigid.index{width:150px;}.usedList:empty{height:50px;background-color:floralwhite;border:1px solid rgb(155,199,206);padding:10px;margin:12px;text-align:center;display:flex;}.usedList.col-rigid:empty{width:150px;}.parsons-button{display:inline-flex;align-items:center;padding:0.5rem 0.75rem;margin:2px 2px;font-size:25px;line-height:1.5;text-align:center;white-space:nowrap;vertical-align:middle;user-select:none;cursor:pointer;background-color:#6c757d;border:1px solid#6c757d;color:#ffffff;border-radius:0.35rem;transition:color 0.15s ease-in-out,background-color 0.15s ease-in-out,border-color 0.15s ease-in-out,box-shadow 0.15s ease-in-out;}.parsons-button:hover{background-color:#5a6268;border-color:#545b62;color:#ffffff;}.parsons-bin{width:33%;cursor:default;}.bin-icon{margin-right:0.75rem;}.drop-zone{vertical-align:middle;display:inline-flex;flex:1;border:2px dashed#6c757d;border-radius:0.25rem;background-color:lightgray;align-self:stretch;}.drop-zone>li{font-size:0.75rem;background-color:lightcoral;max-height:100%;max-width:100%;text-align:left;flex:1;box-shadow:0px 0px 0px 2px rgb(167,89,89)inset;}.sortable-warning{float:right;font-size:0.8em;margin:-0.3em-0.3em;} \ No newline at end of file diff --git a/corsscripts/sortable.min.js b/corsscripts/sortablecore.min.js similarity index 100% rename from corsscripts/sortable.min.js rename to corsscripts/sortablecore.min.js diff --git a/corsscripts/stacksortable.js b/corsscripts/stacksortable.js index cd5026b059c524efacfc9beaf00c02b75cec3ea7..8043c78bedfbf566e64786fe47839cb59831323d 100644 --- a/corsscripts/stacksortable.js +++ b/corsscripts/stacksortable.js @@ -25,74 +25,79 @@ export const SUPPORTED_CALLBACK_FUNCTIONS = [ ]; /** - * Preprocess and validate proof steps, block user options, and sortable user options ready for use in `stack_sortable` class. + * Preprocess and validate steps, sortable user options, headers and indices ready for use in `stack_sortable` class. * - * The function takes proof steps in the form of a Parson's JSON or Maxima string variable, along with block user options + * The function takes steps in the form of a Parson's JSON or Maxima string variable, along with block user options * and sortable user options. It performs the following tasks: - * 1. If `proofSteps` is a Maxima string of expected format, it converts it to an object using `_stackstring_objectify`. + * 1. If `steps` is a Maxima string of expected format, it converts it to an object using `_stackstring_objectify`. * 2. It validates the structure of the Parson's JSON using `_validate_parsons_JSON`. - * 3. If the Parsons JSON contains "steps" and "options," it separates them. - * - If "header" is present in options, it separates this away from Sortable options into `blockUserOpts`. - * - It splits Sortable options into "used" and "available" and passes to `sortableUserOpts`. - * 4. If `proofSteps` is a Maxima string (after separation), it converts it to an object. + * 3. If the Parsons JSON is of depth two with a valid set of top-level keys it separates them. + * 4. If `steps` is a Maxima string (after separation), it converts it to an object. + * + * NB: there is a rare case in which this causes issues: if the author happens to only use a subset of + * ["steps", "options", "headers", "available_header", "index"] as the keys inside their `steps` Question variable. + * This will cause improper validation in the call of `_validate_parsons_JSON` but it will also cause + * functional issues in the question because we will extract the values of those keys from the object. * - * @param {string|Object} proofSteps - The proof steps to be preprocessed. Either a JSON of expected format - * or - * @param {Object} blockUserOpts - Block user options for the 'header' setting, should be passed as an empty Object. - * @param {Object} sortableUserOpts - Sortable user options split into used and available, should be passed as an empty Object. - * @returns {Array} - An array containing preprocessed proof steps, block user options, - * sortable user options, and a boolean indicating the validity of the proof steps structure. + * @param {Object|string} steps - The steps object or string representation of steps. + * @param {Object} sortableUserOpts - Options for the sortable plugin. + * @param {Array} headers - Headers for the answer lists. + * @param {Array} available_header - Header for the available list. + * @param {Array} index - Index column. + * @returns {Array} - An array containing preprocessed steps, options, headers, available header, and index in that order. * * @example * // Returns [ - * // { step1: "Proof step 1", step2: "Proof step 2" }, - * // { used: { header: "Header 1" }, available: { header: "Header 2" } }, - * // { used: { option1: "Value 1" }, available: { option2: "Value 2" } }, + * // { step1: "step 1 text", step2: "step 2 text" }, + * // { option1: "Value 1", option2: "Value 2" }, + * // ["header 1", "header 2"], + * // ["Drag from here:"], + * // null, * // true * // ] * preprocess_steps({ * steps: { - * step1: "Proof step 1", - * step2: "Proof step 2" + * step1: "step 1 text", + * step2: "step 2 text" * }, * options: { - * header: ["Header 1", "Header 2"], * option1: "Value 1", * option2: "Value 2" * } - * }, {}, {}); + * }, ["header 1", "header 2"], ["Drag from here:"], null); */ -export function preprocess_steps(proofSteps, blockUserOpts, sortableUserOpts) { - // Check if proofSteps is a string and convert it to an object - // (this occurs when proof steps are a flat list coming from a Maxima variable) - if (typeof proofSteps === "string") { - proofSteps = _stackstring_objectify(proofSteps); +export function preprocess_steps(steps, sortableUserOpts, headers, available_header, index) { + // Check if steps is a string and convert it to an object + // (this occurs when the steps are a flat list coming from a Maxima variable) + if (typeof steps === "string") { + steps = _stackstring_objectify(steps); } // Validate the object - var valid = _validate_parsons_JSON(proofSteps); + var valid = _validate_parsons_JSON(steps); - // Separate steps and options if they are present - if (JSON.stringify(Object.keys(proofSteps)) === JSON.stringify(["steps", "options"])) { - var userOpts = proofSteps["options"]; - proofSteps = proofSteps["steps"]; - - // Process block user options for the 'header' setting - if (userOpts.header !== undefined) { - blockUserOpts = {used: {header: userOpts.header[0]}, available: {header: userOpts.header[1]}}; + // At this point, we know steps is either a flat JSON, or it's top-level keys are a subset of + // ["steps", "options", "headers", "available_header", "index"], and contains at least "steps". + // Separate these if they are present. + if (_validate_top_level_keys_JSON(steps, ["steps", "options", "headers", "index", "available_header"], ["steps"])) { + var sortableUserOpts = {used: steps["options"], available: steps["options"]}; + // only want to replace defaults for headers if they have been provided + if ("headers" in steps) { + headers = steps["headers"]; } - - // Split Sortable options into used and available - delete userOpts.header; - sortableUserOpts = {used: userOpts, available: userOpts}; + if ("available_header" in steps) { + available_header = steps["available_header"]; + } + index = steps["index"]; + steps = steps["steps"]; } - // Convert proofSteps to an object if it is still a string (occurs when the proof steps comes from a Maxima variable) - if (typeof proofSteps === "string") { - proofSteps = _stackstring_objectify(proofSteps); + // Convert steps to an object if it is still a string (occurs when the steps comes from a Maxima variable) + if (typeof steps === "string") { + steps = _stackstring_objectify(steps); } - return [proofSteps, blockUserOpts, sortableUserOpts, valid]; + return [steps, sortableUserOpts, headers, available_header, index, valid]; } /** @@ -111,30 +116,37 @@ function _stackstring_objectify(stackjson_array_string) { } /** - * Validate the structure of Parson's JSON for proof steps. + * Validate the structure of Parson's JSON `steps`. * - * The function checks the structure of the provided Parson's JSON (`proofSteps`) + * The function checks the structure of the provided Parson's JSON (`steps`) * to ensure it follows specific patterns: - * 1. If the JSON has depth 1, it should be a valid proofStep JSON (i.e., should have string values). - * 2. If the JSON has depth 2, the top-level keys should be ["steps", "options"], and the value for "steps" - * should be a valid proofStep JSON. Options are not validated here. + * 1. If the JSON has depth 1, it should be a valid flat JSON (i.e., should have string values). + * 2. If the JSON has depth 2, the top-level keys should be a subset of + * `["steps", "options", "headers", "index", "available_header"]`, and must contain `"steps"`. + * The value for "steps" should be a valid flat JSON. Options are not validated here. + * + * NB: The separation of depth 1 and depth 2 cases only really works when raw JSON are written by the author. + * This does not work in cases where they are pulled through from Maxima variables using `{# ... #}`. This is + * because we check JSON depth by checking if any of the values is an object, and in the Maxima case, this isn't true as the + * item causing "depth 2" is now a string containing a two-dimensional array. Not clear how to circumvent this, + * it just means there's a gap in validation currently but does not break any functionality. * - * @param {Object} proofSteps - The Parson's JSON to be validated. + * @param {Object} steps - The Parson's JSON to be validated. * @returns {boolean} - Returns true if the provided Parsons JSON follows the expected structure, false otherwise. * * @example * // Returns true * _validate_parsons_JSON({ - * "step1": "proof step 1", - * "step2": "proof step 2" + * "step1": "step 1 text", + * "step2": "step 2 text" * }); * * @example * // Returns true * _validate_parsons_JSON({ * "steps": { - * "step1": "proof step 1", - * "step2": "proof step 2" + * "step1": "step 1 text", + * "step2": "step 2 text" * }, * "options": { * "option1": "value1", @@ -146,121 +158,84 @@ function _stackstring_objectify(stackjson_array_string) { * // Returns false * _validate_parsons_JSON({ * "invalidKey": { - * "step1": "proof step 1", - * "step2": "proof step 2" + * "step1": "step 1 text", + * "step2": "step 2 text" * } * }); */ -function _validate_parsons_JSON(proofSteps) { - // If the JSON has depth 1 then it should be a valid proofStep JSON (i.e., should have string values) - if (Object.values(proofSteps).every((val) => !(typeof(val) == 'object'))) { - return _validate_proof_steps(proofSteps); - } - // Else the top-level of the JSON should have keys ["steps", "options"]. - // The value for "keys" should be a valid proofStep JSON - // We do not validate options here - if (Object.values(proofSteps).some((val) => typeof(val) == "object")) { - if (JSON.stringify(Object.keys(proofSteps)) !== JSON.stringify(["steps", "options"])) { +function _validate_parsons_JSON(steps) { + // If the JSON has depth 1 then it should be a valid flat JSON (i.e., should have string values). + if (Object.values(steps).every((val) => !(typeof(val) == "object"))) { + return _validate_flat_steps(steps); + } + // Else the top-level of the JSON should have keys that are a subset of ["steps", "options", "headers", "index", "available_header"] + // and a superset of ["steps"]. + // The value for "steps" should be a valid flat JSON. + // We do not validate options here. + if (Object.values(steps).some((val) => typeof(val) == "object")) { + if (!_validate_top_level_keys_JSON(steps, ["steps", "options", "headers", "index", "available_header"], ["steps"])) { return false; } - if (!_validate_proof_steps(proofSteps["steps"])) { + if (!_validate_flat_steps(steps["steps"])) { return false; } return true; } - // TO-DO : we are missing one case here in depth 2 case and unclear how to catch it: - // if an author writes {"any string" : {#stackjson_stringify(proof_steps)#}}, - // then this should throw an error } /** - * Validate the structure of proof steps. + * Validate the structure of a flat steps JSON. * - * The function checks the structure of the provided proof steps (`proofSteps`) + * The function checks the structure of the provided steps (`steps`) * to ensure that all values are strings. * - * If the proof steps are provided as a Maxima variable (string of form '[["key", "value"], ...]'), they are converted + * If the steps are provided as a Maxima variable (string of form '[["key", "value"], ...]'), they are converted * to a JSON object using the `_stackstring_objectify` function before validation. * - * @param {string|Object} proofSteps - The proof steps to be validated. If a string, + * @param {string|Object} steps - The flat JSON to be validated. If a string, * it is assumed to be a Maxima variable and will be converted to a JSON object. - * @returns {boolean} - Returns true if all values in the proof steps are strings, false otherwise. + * @returns {boolean} - Returns true if all values in `steps` are strings, false otherwise. * * @example * // Returns true - * _validate_proof_steps({ - * step1: "Proof step 1", - * step2: "Proof step 2" + * _validate_flat_steps({ + * step1: "step 1 text", + * step2: "step 2 text" * }); * * @example * // Returns true - * _validate_proof_steps('["step1", "Proof step 1"], ["step2", "Proof step 2"]]'); + * _validate_flat_steps('["step1", "step 1 text"], ["step2", "step 2 text"]]'); * * @example * // Returns false - * _validate_proof_steps({ - * step1: "Proof step 1", + * _validate_flat_steps({ + * step1: "step 1 text", * step2: 123 // Not a string * }); */ -function _validate_proof_steps(proofSteps) { - // Case when proof steps are coming from a Maxima variable: convert to a JSON - if (typeof(proofSteps) == 'string') { - proofSteps = _stackstring_objectify(proofSteps); +function _validate_flat_steps(steps) { + // Case when steps are coming from a Maxima variable: convert to a JSON + if (typeof(steps) == "string") { + steps = _stackstring_objectify(steps); } - return Object.values(proofSteps).every((val) => typeof(val) == 'string'); + return Object.values(steps).every((val) => typeof(val) == "string"); } /** - * Flips the orientation of specified used and available lists, and the bin (if present) in the UI. - * The function toggles between 'list-group row' and 'list-group col' classes for the specified elements. - * The bin element (if present) is expected to have ID 'bin'. - * - * @param {string} usedId - The ID of the used list element. - * @param {string} availableId - The ID of the available list element. - * @returns {void} - * - * @example - * // HTML structure: - * // <div id="usedList" class="list-group row">...</div> - * // <div id="availableList" class="list-group row">...</div> - * // <div id="bin" class="list-group row">...</div> + * Validates the top-level keys of a JSON object by checking they are a subset of `validKeys` + * and a superset of `requiredKeys`. * - * // JavaScript usage: - * _flip_orientation('usedList', 'availableList'); + * @param {Object} JSON - The JSON object to validate. + * @param {Array} validKeys - An array of valid top-level keys. + * @param {Array} requiredKeys - An array of top-level keys that are required. + * @returns {boolean} - True if the JSON object passes validation, otherwise false. */ -function _flip_orientation(usedId, availableId) { - var usedList = document.getElementById(usedId); - var availableList = document.getElementById(availableId); - var newClass = usedList.className == 'list-group row' ? 'list-group col' : 'list-group row'; - usedList.setAttribute('class', newClass); - availableList.setAttribute('class', newClass); -} - -/** - * Adds an event listener to a button element with the specified ID to trigger the flipping - * of orientation between 'list-group row' and 'list-group col' classes for specified UI elements. - * This event will also change the orientation of the bin element (if present), which is expected - * to have ID 'bin'. - * - * @param {string} buttonId - The ID of the button element to which the event listener is added. - * @param {string} usedId - The ID of the used list element. - * @param {string} availableId - The ID of the available list element. - * @returns {void} - * - * @example - * // HTML structure: - * // <button id="toggleButton">Toggle Orientation</button> - * // <div id="usedList" class="list-group row">...</div> - * // <div id="availableList" class="list-group row">...</div> - * - * // JavaScript usage: - * add_orientation_listener('toggleButton', 'usedList', 'availableList'); - */ -export function add_orientation_listener(buttonId, usedId, availableId) { - const button = document.getElementById(buttonId); - button.addEventListener('click', () => _flip_orientation(usedId, availableId)); +function _validate_top_level_keys_JSON(JSON, validKeys, requiredKeys) { + const keys = Object.keys(JSON); + const missingRequiredKeys = requiredKeys.filter((key) => !keys.includes(key)); + const invalidKeys = keys.filter((key) => !validKeys.includes(key)); + return invalidKeys.length === 0 && missingRequiredKeys.length === 0; } /** @@ -273,89 +248,366 @@ export function get_iframe_height() { } /** - * Class for for managing Sortable lists for Parson's proof questions in STACK. + * Class for for managing Sortable lists for Parson's block questions in STACK. * * @class - * @param {Object} proofSteps - Object containing proof steps. - * @param {string} availableId - ID of the available list element. - * @param {string} usedId - ID of the used list element. - * @param {string|null} inputId - ID of the input element for storing state (optional). - * @param {Object|null} options - Custom options for sortable lists (optional). - * @param {boolean} clone - Flag indicating whether to clone elements during drag-and-drop. + * @param {Object} steps - The flat steps object. + * @param {string|null} inputId - The ID of the input element storing the state data. + * @param {Object|null} options - Options for the Sortable lists. + * @param {boolean} clone - Indicates whether clone mode is being used in the `parsons` block. + * @param {number} columns - The number of columns being used, default is 1. + * @param {number|null} rows - The number of rows. + * @param {string} orientation - The orientation of the Parsons object. Default is "col". + * @param {string} index - The index for the proof steps. Default is an empty string. + * @param {boolean} grid - False if the `parsons` block is being used for proof, true if for grouping and matching. + * Affects styling. + * @param {string|null} item_height - The height of each item in the sortable lists (including headers and indexes). + * @param {string|null} item_width - The width of each item in the sortable lists (including headers and indexes). * - * @property {Object} proofSteps - Object containing proof steps. + * @property {Object} steps - Object containing all steps. * @property {string} inputId - ID of the input element for storing state (optional). * @property {Object} state - Current state of used and available items. * @property {Object} userOptions - User-defined options merged with default options. * @property {boolean} clone - Flag indicating whether to clone elements during sorting. * @property {Object} options - Final options for sortable lists. + * @property {String} columns - Number of columns being used. + * @property {String} rows - Number of rows being used. + * @property {Array} index - List of index items. + * @property {boolean} use_index - Whether an index has been passed to the constructor or not. + * @property {boolean} grid - Whether grid styling is to be applied (i.e., false if using for proof). + * @property {string} item_class - The style class to use, different for proof vs. matching. + * @property {Object} item_height_width - If item_height and item_width are passed to the constructor, object containing them. + * @property {Object} container_height_width - Add padding to this.item_height_width to allow custom heights/widths to work. + * @property {Object} ids - Contains DOM ids for used and available lists. + * @property {Array} usedId - Two-dimensional array containing used list DOM ids. + * @property {string} availableId - String containing the available list DOM id. + * @property {Object} defaultOptions - Default sortable options. * + * @method add_dblclick_listeners - Add listeners that moves items on double-click and updates state for proofs only. + * @method add_delete_all_listener - Adds a listener that deletes all from the used list and updates state. + * @method add_headers - Adds header elements to the used and available lists. + * @method add_index - Add an index column if passed to the constructor. + * @method add_reorientation_button - Adds a button that allows user to flip between orientations on question page. + * @method create_row_col_divs - Generates the HTML row and column divs according to how columns and rows are passed to constructor. * @method generate_available - Generates the available list based on the current state. * @method generate_used - Generates the used list based on the current state. - * @method add_headers - Adds header elements to the used and available lists. * @method update_state - Updates the state based on changes in the used and available lists. - * @method add_dblclick_listeners - Add listeners that moves items on double-click and updates state. - * @method add_delete_all_listener - Adds a listener that deletes all from the used list and updates state. + * @method validate_options - Validates the sortable user options. * * @example - * // Creating a StackSortable instance: + * // Creating a basic StackSortable instance for proof: * const sortable = new stack_sortable({ * "step1": "Step 1", * "step2": "Step 2", * // ... - * }, "availableList", "usedList", "stateInput", { used: { animation: 100 }, available: { animation: 100 } }, false); + * }, "ans1", { used: { animation: 100 }, available: { animation: 100 } }, false); * * // Generating lists and adding headers: * sortable.generate_available(); * sortable.generate_used(); - * sortable.add_headers({ used: { header: "Used Header" }, available: { header: "Available Header" } }); + * sortable.add_headers(); * - * // Updating state on changes: - * sortable.update_state(newUsedList, newAvailableList); + * // Create the Sortable answer lists. + * var sortableUsed = + * stackSortable.ids.used.map((idList) => + * idList.map((usedId) => Sortable.create(document.getElementById(usedId), stackSortable.options.used))); * - * // Updating state on double-click events: - * sortable.update_state_dblclick(newUsedList, newAvailableList); + * // Create the Sortable available list. + * var sortableAvailable = Sortable.create(availableList, stackSortable.options.available); + * + * // Add the state callback function for all the created sortables. + * sortableUsed.forEach((sortableList) => + * sortableList.forEach((sortable) => + * sortable.option("onSort", () => { + * stackSortable.update_state(sortableUsed, sortableAvailable);}) + * ) + * ); + * sortableAvailable.option("onSort", () => {stackSortable.update_state(sortableUsed, sortableAvailable);}); * * @exports stack_sortable */ -export const stack_sortable = class { +export const stack_sortable = class stack_sortable { /** * Constructor for the StackSortable class. * * @constructor - * @param {Object} proofSteps - Object containing proof steps. - * @param {string} availableId - ID of the available list element. - * @param {string} usedId - ID of the used list element. - * @param {string|null} inputId - ID of the input element for storing state (optional). - * @param {Object|null} options - Custom options for sortable lists - * of form {used: UsedOptions, available: AvailableOptions} (optional). - * @param {boolean} clone - Flag indicating whether to clone elements during sorting. + * @param {Object} steps - The flat steps object. + * @param {string|null} inputId - The ID of the input element storing the state data. + * @param {Object|null} options - Options for the Sortable lists. + * @param {boolean} clone - Indicates whether clone mode is being used in the `parsons` block. + * @param {number} columns - The number of columns being used, default is 1. + * @param {number|null} rows - The number of rows. + * @param {string} orientation - The orientation of the Parsons object. Default is "col". + * @param {string} index - The index for the proof steps. Default is an empty string. + * @param {boolean} grid - False if the `parsons` block is being used for proof, true if for grouping and matching. + * Affects styling. + * @param {string|null} item_height - The height of each item in the sortable lists (including headers and indexes). + * @param {string|null} item_width - The width of each item in the sortable lists (including headers and indexes). */ - constructor(proofSteps, availableId, usedId, inputId = null, options = null, clone = false) { - this.proofSteps = proofSteps; + constructor(steps, + inputId = null, + options = null, + clone = false, + columns = 1, + rows = null, + orientation = "col", + index = "", + grid = false, + item_height = null, + item_width = null) { + this.steps = steps; this.inputId = inputId; - this.state = this._generate_state(this.proofSteps, this.inputId); + this.orientation = orientation; + this.columns = ((this.orientation === "col") ? columns : rows); + this.rows = ((this.orientation === "col") ? rows : columns); + this.index = index; + this.use_index = this.index !== ""; + this.grid = grid; + this.item_class = ( + this.grid ? (this.orientation === "row" ? "grid-item-rigid" : "grid-item") : "list-group-item" + ); + this.item_height_width = {"style" : ""}; + for (const [key, val] of [["height", item_height], ["width", item_width]]) { + if (val !== "") {this.item_height_width["style"] += `${key}:${val}px;`}; + }; + this.item_height_width = (this.item_height_width["style"] === "") ? {} : this.item_height_width; + this.container_height_width = (this.item_height_width["style"] !== "") ? + {"style" : this.item_height_width["style"] + "margin: 12px;"} : {}; + this.state = this._generate_state(this.steps, inputId, Number(this.columns), Number(this.rows)); if (inputId !== null) { this.input = document.getElementById(this.inputId); this.submitted = this.input.getAttribute("readonly") === "readonly" } - this.availableId = availableId; - this.available = document.getElementById(this.availableId); - this.usedId = usedId; - this.used = document.getElementById(this.usedId); + this.ids = this._create_ids(this.rows, this.columns); + this.availableId = this.ids.available; + this.usedId = this.ids.used; this.clone = clone; - - // TO-DO : additional default options? this.defaultOptions = {used: {animation: 50}, available: {animation: 50}}; // Merges user options and default, overwriting default with user options if they clash this.userOptions = this._set_user_options(options); // Create overall options from this.userOptions by setting ghostClass and group to required options - // and overwriting them if they appear in userOptions. This also disables the list if they have been + // and overwriting them if they appear in userOptions. This also disables the list if they have been // submitted. this.options = this._set_ghostClass_group_and_disabled_options(); } + /** + * Adds double-click listeners to move items upon double-click and updates the state accordingly. + * Only supported for proofmode + * + * @method + * @param {Object} newUsed - Updated used list. + * @param {Object} newAvailable - Updated available list. + * @returns {void} + */ + add_dblclick_listeners(newUsed, newAvailable) { + this.available.addEventListener("dblclick", (e) => { + if (this._double_clickable(e.target)) { + // get highest-level parent + var li = this._get_moveable_parent_li(e.target); + li = (this.clone === "true") ? li.cloneNode(true) : this.available.removeChild(li); + this.used[0][0].append(li); + this.update_state(newUsed, newAvailable); + } + }); + this.used[0][0].addEventListener("dblclick", (e) => { + if (this._double_clickable(e.target)) { + // get highest-level parent + var li = this._get_moveable_parent_li(e.target); + this.used[0][0].removeChild(li); + if (this.clone !== "true") { + this.available.insertBefore(li, this.available.children[1]); + } + this.update_state(newUsed, newAvailable); + } + }); + } + + /** + * Add a click event listener to a button to delete all items from the "used" list and + * updates the state accordingly. + * + * @method + * @param {string} buttonId - ID of the button element to attach the listener. + * @param {Object} newUsed - Updated "used" list. + * @param {Object} newAvailable - Updated "available" list. + * @returns {void} + */ + add_delete_all_listener(buttonId, newUsed, newAvailable) { + const button = document.getElementById(buttonId); + button.addEventListener("click", () => { + this._delete_all_from_used(); this.update_state(newUsed, newAvailable);}); + } + + /** + * Adds header elements to the used and available lists. + * + * @method + * @param {Object} headers - Object containing header text for used and available lists. + * @returns {void} + */ + add_headers(headers, available_header) { + for (const [i, value] of headers.entries()) { + var parentEl = document.getElementById(`usedList_${i}`); + var header = this._create_header(value, `usedHeader_${i}`, this.item_height_width); + parentEl.insertBefore(header, parentEl.firstChild); + } + var parentEl = document.getElementById("availableList"); + parentEl.insertBefore( + this._create_header(available_header, "availableHeader", this.item_height_width), parentEl.firstChild); + } + + /** + * Adds index elements to the DOM based on the provided index array. + * + * @param {Array} index - The array containing index values to be added. + */ + add_index(index) { + for (const [i, value] of index.entries()) { + // Deal with the item in both header and index separately + if (i === 0) { + var idx = this._create_index(value, `usedIndex_${i}`, this.item_height_width); + var addClass = this.orientation === "col" ? "header" : "index"; + idx.classList.add(addClass); + } else { + var idx = this._create_index(value, `usedIndex_${i}`, this.item_height_width); + } + document.getElementById("index").append(idx); + } + } + + /** + * Adds a reorientation button to the document body. + * + * The button allows users to change the orientation of sortable lists between vertical + * and horizontal. + */ + add_reorientation_button() { + var btn = document.createElement("button"); + btn.id = "orientation"; + btn.setAttribute("class", "parsons-button"); + var icon = document.createElement("i"); + icon.setAttribute("class", "fa fa-refresh"); + btn.append(icon); + btn.addEventListener("click", () => this._flip_orientation()); + document.body.insertBefore(btn, document.getElementById("containerRow")); + } + + /** + * Populates the DOM with row and column div elements to the document based + * on how many columns and rows are being passed to the instance. + * + * How this occurs depends on various configurations. + * - Lists should contain the `"row"` or `"col"` class according to the orientation. + * - If the class is being used for proof (i.e., `this.grid === false`), then the list class should + * also contain `"list-group"`. + * - Items class depends only on the orientation. + */ + create_row_col_divs() { + var usedClassList = (!this.grid || this.orientation === "col") ? + (["list-group", this.orientation, "usedList"]) : + ([this.orientation, "usedList"]); + var itemClass = (this.orientation === "col") ? "row" : "col"; + var itemClassList = [itemClass, "usedList"]; + var availClassList = (!this.grid || this.orientation === "col") ? + ["list-group", this.orientation] : + [this.orientation]; + var container = document.getElementById("containerRow"); + + if (this.use_index) { + var indexCol = document.createElement("div"); + indexCol.id = "index"; + indexCol.classList.add(...usedClassList); + container.append(indexCol); + } + this.colIds.forEach((id) => + { + var colDiv = document.createElement("ul"); + colDiv.id = id; + // In matching mode, we need to add rigid styles to colDivs if orientation === "row" + colDiv.classList.add(...usedClassList); + container.append(colDiv); + }); + + // if rows are specified then add the row divs + if (this.rows !== "") { + this.colIds.forEach((colId) => { + var colDiv = document.getElementById(colId); + colDiv.classList.add("container"); + this.rowColIds[colId].forEach((rowColId) => { + var divRowCol = document.createElement("li"); + divRowCol.id = rowColId; + // In matching mode, we need to add rigid styles to colDivs if orientation === "row" + if (this.orientation === "row") { + divRowCol.classList.add(...itemClassList, ...["col-rigid"]); + } else { + divRowCol.classList.add(...itemClassList); + } + colDiv.append(divRowCol); + }) + }) + }; + + var availDiv = document.createElement("ul"); + availDiv.id = this.ids.available; + availDiv.classList.add(...availClassList); + if (this.orientation === "col") { + container.append(availDiv); + } else { + container.insertBefore(availDiv, container.firstChild); + } + + this.used = this.usedId.map(idList => idList.map(id => document.getElementById(id))); + this.available = document.getElementById(this.availableId); + } + + /** + * Generates the available list based on the current state. + * + * @method + * @returns {void} + */ + generate_available() { + this.state.available.forEach(key => this.available.append(this._create_li(key, this.item_height_width))); + } + + /** + * Generates the used list based on the current state. + * + * @method + * @returns {void} + */ + generate_used() { + for (const [i, value] of this.state.used.entries()) { + if (this.rows !== "" && this.columns !== "") { + for (const [j, val] of value.entries()) { + this._apply_attrs(this.used[i][j], this.container_height_width); + val.forEach(key => this.used[i][j].append(this._create_li(key, this.item_height_width))); + } + } else { + value[0].forEach(key => this.used[i][0].append(this._create_li(key, this.item_height_width))); + } + } + } + + /** + * Updates the state based on changes in the used and available lists. + * + * @method + * @param {Object} newUsed - Updated used list. + * @param {Object} newAvailable - Updated available list. + * @returns {void} + */ + update_state(newUsed, newAvailable) { + var newState = {used: newUsed.map((usedList) => usedList.map((used) => used.toArray())), available: newAvailable.toArray()}; + if (this.inputId !== null) { + this.input.value = JSON.stringify(newState); + this.input.dispatchEvent(new Event("change")); + } + this.state = newState; + } + /** * Validate user options against a list of possible option keys. * @@ -373,7 +625,7 @@ export const stack_sortable = class { * will appear on the question page. */ validate_options(possibleOptionKeys, unknownErr, overwrittenErr) { - var err = ''; + var err = ""; var keysRecognised = true; var invalidKeys = []; // If option is not recognised warn user @@ -416,154 +668,320 @@ export const stack_sortable = class { } /** - * Generates the available list based on the current state. + * Applies attributes to an HTML element. * - * @method - * @returns {void} + * @param {HTMLElement} el - The HTML element to which attributes will be applied. + * @param {Object} opts - An object containing attribute-value pairs to be applied. */ - generate_available() { - this.state.available.forEach(key => this.available.append(this._create_li(key))); + _apply_attrs(el, opts) { + for (const [key, value] of Object.entries(opts)) { + el.setAttribute(key, value); + } } /** - * Generates the used list based on the current state. + * Creates a header element with specified inner HTML, ID, and other attributes. * - * @method - * @returns {void} + * @param {string} innerHTML - The inner HTML content of the header element. + * @param {string} id - The ID attribute of the header element. + * @param {Object} attrs - An object containing additional attributes for the header element. + * @returns {HTMLElement} - The created header element. */ - generate_used() { - this.state.used.forEach(key => this.used.append(this._create_li(key))); + _create_header(innerHTML, id, attrs) { + let i = document.createElement("i"); + i.innerHTML = innerHTML; + var addClass = (this.grid && this.orientation !== "col") ? + [this.item_class, "index"] : [this.item_class, "header"]; + i.classList.add(...addClass); + this._apply_attrs(i, {...{"id" : id}, ...attrs}); + return i; } /** - * Adds header elements to the used and available lists. + * Creates and organizes identifiers for rows and columns. If only columns are passed, then + * the used IDs will just be a flat list `["usedList_0", ..., "usedList_n"]`, where `columns = "n + 1"`. + * If both rows and columns have non-null values, then this will be a two-dimensional array + * `[["usedList_00", "usedList_01", ..., "usedList_0n"], ["usedList_10", ...], ...]`. + * In the two-dimensional case, a mapping between the column IDs `["usedList_0", ...]` and the + * two-dimensional array of item IDs is contained in the object `this.rowColIds`, that is + * `this.rowColIds["usedList_0"] = ["usedList_00", "usedList_01", ...]`. + * + * @param {number} rows - The number of rows. + * @param {number} columns - The number of columns. + * @returns {Object} - An object containing identifiers for used and available elements. + */ + _create_ids(rows, columns) { + var colIdx = Array.from({length: columns}, (_, i) => i); + var rowIdx = Array.from({length: rows}, (_, j) => j); + this.colIds = colIdx.map((idx) => `usedList_${idx}`); + this.rowColIds = {} + colIdx.forEach((i) => this.rowColIds[this.colIds[i]] = rowIdx.map((j) => `usedList_${j}${i}`)); + var usedIds = (rows === "") ? + this.colIds.map((id) => [id]) : + Object.values(this.rowColIds); + + return { + used: usedIds, + available: "availableList" + }; + } + + /** + * Creates an index element with specified inner HTML, ID, and additional attributes. + * + * @param {string} innerHTML - The inner HTML content of the index element. + * @param {string} id - The ID attribute of the index element. + * @param {Object} attrs - An object containing additional attributes for the index element. + * @returns {HTMLElement} - The created index element. + */ + _create_index(innerHTML, id, attrs) { + let i = document.createElement("i"); + i.innerHTML = innerHTML; + var addClass = (this.orientation === "col") ? + [this.item_class, "index"] : [this.item_class, "header"]; + i.classList.add(...addClass); + this._apply_attrs(i, {...{"id" : id}, ...attrs}); + return i; + } + + /** + * Creates a list item (li) element containing the value of the specified key from `this.steps` and attributes. + * + * @param {string} stepKey - The key whose HTML to get from `this.steps`. + * @param {Object} attrs - An object containing additional attributes for the list item element. + * @returns {HTMLElement} - The created list item (li) element. + */ + _create_li(stepKey, attrs) { + let li = document.createElement("li"); + li.innerHTML = this.steps[stepKey]; + this._apply_attrs(li, {...{"data-id" : stepKey}, ...attrs}); + li.className = this.item_class; + return li; + } + + /** + * Checks if a list item (li) is deletable. + * + * @param {HTMLElement} li - The list item (li) element to check. + * @returns {boolean} - True if the list item is deletable, otherwise false. + */ + _deletable_li(li) { + return !li.matches(".header") && !li.matches(".index") && !this._is_empty_li(li); + } + + /** + * Delete all non-header items from the "used" list. * * @method - * @param {Object} headers - Object containing header text for used and available lists. + * @private * @returns {void} */ - add_headers(headers) { - this.used.append(this._create_header(headers.used.header, "usedHeader")); - this.available.append(this._create_header(headers.available.header, "availableHeader")); + _delete_all_from_used() { + const lis = document.querySelectorAll(".usedList li[data-id]"); + lis.forEach(li => {if (this._deletable_li(li)) {this._delete_li(li);}}); } /** - * Updates the state based on changes in the used and available lists. + * Deletes a list item (li) from its parent node. + * + * @param {HTMLElement} li - The list item (li) element to delete. + */ + _delete_li(li) { + li.parentNode.removeChild(li); + } + + /** + * Display a warning message on the question page. * * @method - * @param {Object} newUsed - Updated used list. - * @param {Object} newAvailable - Updated available list. + * @private + * @param {string} msg - The message to be displayed in the warning. * @returns {void} */ - update_state(newUsed, newAvailable) { - var newState = {used: newUsed.toArray(), available: newAvailable.toArray()}; - if (this.inputId !== null) { - this.input.value = JSON.stringify(newState); - this.input.dispatchEvent(new Event('change')); - } - this.state = newState; + _display_warning(msg) { + var warning = document.createElement("div"); + warning.className = "sortable-warning"; + var exclamation = document.createElement("i"); + exclamation.className = "icon fa fa-exclamation-circle text-danger fa-fw"; + warning.append(exclamation); + var warningMessage = document.createElement("span"); + warningMessage.textContent = msg; + warning.append(warningMessage); + document.body.insertBefore(warning, document.getElementById("containerRow")); } /** - * Adds double-click listeners to move items upon double-click and updates the state accordingly. + * Check if an HTML element is double-clickable (i.e., it is not a header or index element). + * + * This private method is called on items inside the used or available list. * * @method - * @param {Object} newUsed - Updated used list. - * @param {Object} newAvailable - Updated available list. - * @returns {void} + * @private + * @param {HTMLElement} item - The HTML element to check for double-clickability. + * @returns {boolean} - Returns true if the element is double-clickable, false otherwise. */ - add_dblclick_listeners(newUsed, newAvailable) { - this.available.addEventListener('dblclick', (e) => { - if (this._double_clickable(e.target)) { - // get highest-level parent - var li = this._get_moveable_parent_li(e.target); - li = (this.clone === "true") ? li.cloneNode(true) : this.available.removeChild(li); - this.used.append(li); - this.update_state(newUsed, newAvailable); - } - }); - this.used.addEventListener('dblclick', (e) => { - if (this._double_clickable(e.target)) { - // get highest-level parent - var li = this._get_moveable_parent_li(e.target); - this.used.removeChild(li); - if (this.clone !== "true") { - this.available.insertBefore(li, this.available.children[1]); - } - this.update_state(newUsed, newAvailable); - } - }); + _double_clickable(item) { + return !item.matches(".header") && !item.matches(".index"); } /** - * Add a click event listener to a button to delete all items from the "used" list and - * updates the state accordingly. + * Flips the question between vertical and horizontal orientations. * * @method - * @param {string} buttonId - ID of the button element to attach the listener. - * @param {Object} newUsed - Updated "used" list. - * @param {Object} newAvailable - Updated "available" list. * @returns {void} */ - add_delete_all_listener(buttonId, newUsed, newAvailable) { - const button = document.getElementById(buttonId); - button.addEventListener('click', () => {this._delete_all_from_used(); this.update_state(newUsed, newAvailable);}); + _flip_orientation() { + // Define CSS classes based on orientation and whether using for proof or grouping or matching. + var addClass = (this.orientation === "row") ? ["list-group", "col"] : ["row"]; + if (this.grid) { + // Current classes we need to remove. + var removeClass = (this.orientation === "row") ? ["list-group", "row"] : ["list-group", "col"]; + // Current grid class being used. + var currGridClass = (this.orientation === "row") ? "grid-item-rigid" : "grid-item"; + // Grid class to add. + var gridAddClass = (this.orientation === "row") ? "grid-item" : "grid-item-rigid" + // Get all grid items and replace their classes + var gridItems = document.querySelectorAll(`.${currGridClass}`); + gridItems.forEach((item) => { + item.classList.remove(currGridClass); + item.classList.add(gridAddClass); + }) + + // In matching mode, we need to add rigid styles to columns as well as items. + if (this.rows !== "") { + [].concat(...this.used).forEach((div) => { + if (this.orientation === "col") { + div.classList.remove("row"); + div.classList.add("col", "col-rigid"); + } else { + div.classList.remove("col", "col-rigid"); + div.classList.add("row"); + } + }) + } + } else { + // In proof mode just switch between row and col. + var removeClass = (this.orientation === "row") ? ["row"] : ["col"]; + } + + // Now classes have been defined appropriately according to case, we replace classes in answer lists. + this.colIds.forEach((colId) => { + var ul = document.getElementById(colId); + ul.classList.remove(...removeClass); + ul.classList.add(...addClass); + } + ); + + // Do the same for the available list. + this.available.classList.remove(...removeClass); + this.available.classList.add(...addClass); + + // Move position of available list to above in horizontal, or to the right in vertical. + if (this.orientation === "col") { + this.available.parentNode.insertBefore(this.available, this.available.parentNode.firstChild); + } else { + this.available.parentNode.append(this.available); + } + + // In grid mode (either matching or grouping) headers become indices and vice versa. + if (this.grid) { + // Headers to index + if (this.orientation === "col") { + document.querySelectorAll(".header").forEach((header) => { + if (!header.classList.contains("index")) { + header.classList.remove("header"); + header.classList.add("index"); + } + }); + } else { + document.querySelectorAll(".index").forEach((index) => { + if (!index.classList.contains("header")) { + index.classList.remove("index"); + index.classList.add("header"); + } + }) + } + + // Index to headers (if index is being used). + if (this.use_index) { + var indexDiv = document.getElementById("index"); + indexDiv.classList.remove(...removeClass); + indexDiv.classList.add(...addClass); + if (this.orientation === "col") { + document.querySelectorAll("#index > .index").forEach((idx) => { + if (!idx.classList.contains("header")) { + idx.classList.remove("index"); + idx.classList.add("header"); + } + }) + } else { + document.querySelectorAll("#index > .header").forEach((header) => { + if (!header.classList.contains("index")) { + header.classList.remove("header"); + header.classList.add("index"); + } + }) + } + } + }; + + // Keep track of current orientation. + this.orientation = (this.orientation === "row") ? "col" : "row"; } /** - * Generates the initial state of used and available items based on the provided proof steps and input ID. + * Generates the initial state of used and available items based on the provided steps, input ID, + * and number of columns and rows used. The shape of the used state will be `(1, 1, ?)` if in proof + * mode, `(n, 1, ?)` if `n` columns are specified and `(n, m, 1)` if `n` columns and `m` rows are specified. * * @method * @private - * @param {Object} proofSteps - Object containing proof steps. + * @param {Object} steps - Object containing steps. * @param {string} inputId - ID of the input element for storing state. * @returns {Object} The initial state object with used and available lists. */ - _generate_state(proofSteps, inputId) { + _generate_state(steps, inputId, columns, rows) { + const usedState = (rows === 0 || columns === 0) ? + Array(columns).fill().map(() => [[]]) : + Array(columns).fill().map(() => Array(rows).fill([])); let stateStore = document.getElementById(inputId); if (stateStore === null) { - return {used: [], available: [...Object.keys(proofSteps)]}; + return {used: usedState, available: [...Object.keys(steps)]}; } return (stateStore.value && stateStore.value != "") ? JSON.parse(stateStore.value) : - {used: [], available: [...Object.keys(proofSteps)]}; + {used: usedState, available: [...Object.keys(steps)]}; } /** - * Validate if a given option key is among the possible option keys. + * Get the nearest moveable parent list item for a given HTML element. + * + * This private method traverses the DOM hierarchy starting from the provided HTML + * element and finds the nearest parent list item with the class ".list-group-item". + * It is useful for identifying the moveable parent when doubling clicking on child + * elements (for example MathJax display elements) inside list items. * * @method * @private - * @param {string} key - The option key to validate. - * @param {string[]} possibleOptionKeys - List of possible option keys. - * @returns {boolean} - Returns true if the option key is valid, false otherwise. + * @param {HTMLElement} target - The HTML element for which to find the moveable parent list item. + * @returns {HTMLElement|null} - The nearest parent list item with class ".list-group-item", or null if not found. */ - _validate_option_key(key, possibleOptionKeys) { - return possibleOptionKeys.includes(key); + _get_moveable_parent_li(target) { + var li = target; + while (!li.matches(".list-group-item")) { + li = li.parentNode; + } + return li; } /** - * Set and merge user-provided options with default options. - * - * This private method sets user options for both "used" and "available" lists - * by merging the provided options with the default options. If no options are - * provided, it returns the default options. + * Checks if a list item (li) element is empty. * - * @method - * @private - * @param {Object|null} options - Custom options for sortable lists - * of form {used: UsedOptions, available: AvailableOptions} (optional). - * @returns {Object} - Merged user options for "used" and "available" lists. + * @param {HTMLElement} li - The list item (li) element to check. + * @returns {boolean} - True if the list item is empty, otherwise false. */ - _set_user_options(options) { - var userOptions; - if (options === null) { - userOptions = this.defaultOptions; - } else { - userOptions = {used: Object.assign(this.defaultOptions.used, options.used), - available: Object.assign(this.defaultOptions.available, options.available)}; - } - return userOptions; + _is_empty_li(li) { + return li.textContent.trim() === "" && li.children.length === 0; } /** @@ -579,46 +997,51 @@ export const stack_sortable = class { * @returns {Object} - Options containing ghostClass and group settings for both lists. */ _set_ghostClass_group_and_disabled_options() { - var group_val = { - used: { - name: "sortableUsed", - pull: true, + var group_val = {}; + group_val.used = (this.rows === "") ? + { + name: "sortableUsed", + pull: true, put: true - } - }; - + } : + { + name: "sortableUsed", + pull: true, + put: (to) => to.el.children.length < 1 + }; + group_val.available = (this.clone === "true") ? { - name: "sortableAvailable", - pull: "clone", - revertClone: true, + name: "sortableAvailable", + pull: "clone", + revertClone: true, put: false } : { - name: "sortableAvailable", + name: "sortableAvailable", put: true }; - + var options_to_assign = this.submitted ? { used : { - ghostClass: "list-group-item-info", - group: group_val.used, + ghostClass: "list-group-item-info", + group: group_val.used, disabled: true - }, + }, available : { - ghostClass: "list-group-item-info", - group: group_val.available, + ghostClass: "list-group-item-info", + group: group_val.available, disabled: true } - } : + } : { used : { - ghostClass: "list-group-item-info", + ghostClass: "list-group-item-info", group: group_val.used - }, + }, available : { - ghostClass: "list-group-item-info", + ghostClass: "list-group-item-info", group: group_val.available } } @@ -638,105 +1061,41 @@ export const stack_sortable = class { } /** - * Display a warning message on the question page. - * - * @method - * @private - * @param {string} msg - The message to be displayed in the warning. - * @returns {void} - */ - _display_warning(msg) { - var warning = document.createElement("div"); - warning.className = "sortable-warning"; - var exclamation = document.createElement("i"); - exclamation.className = "icon fa fa-exclamation-circle text-danger fa-fw"; - warning.append(exclamation); - var warningMessage = document.createElement("span"); - warningMessage.textContent = msg; - warning.append(warningMessage); - document.body.insertBefore(warning, document.getElementById("sortableContainer")); - } - - /** - * Create an HTML list item element based on keys in `this.proofSteps`. - * - * @method - * @private - * @param {string} proofKey - The key associated with the proof content in 'proofSteps'. - * @returns {HTMLElement} - The created list item element. - */ - _create_li(proofKey) { - let li = document.createElement("li"); - li.innerHTML = this.proofSteps[proofKey]; - li.setAttribute("data-id", proofKey); - li.className = "list-group-item"; - return li; - } - - /** - * Creates a header element. - * - * @method - * @private - * @param {string} innerHTML - Inner HTML content of the header. - * @param {string} id - ID of the header element. - * @returns {HTMLElement} The created header element. - */ - _create_header(innerHTML, id) { - let i = document.createElement("i"); - i.innerHTML = innerHTML; - i.className = "list-group-item header"; - i.setAttribute("id", id); - return i; - } - - /** - * Check if an HTML element is double-clickable (i.e., it is not a header element). - * - * This private method is called on items inside the used or available list. - * - * @method - * @private - * @param {HTMLElement} item - The HTML element to check for double-clickability. - * @returns {boolean} - Returns true if the element is double-clickable, false otherwise. - */ - _double_clickable(item) { - return !item.matches(".header"); - } - - /** - * Get the nearest moveable parent list item for a given HTML element. + * Set and merge user-provided options with default options. * - * This private method traverses the DOM hierarchy starting from the provided HTML - * element and finds the nearest parent list item with the class ".list-group-item". - * It is useful for identifying the moveable parent when doubling clicking on child - * elements (for example MathJax display elements) inside list items. + * This private method sets user options for both "used" and "available" lists + * by merging the provided options with the default options. If no options are + * provided, it returns the default options. * * @method * @private - * @param {HTMLElement} target - The HTML element for which to find the moveable parent list item. - * @returns {HTMLElement|null} - The nearest parent list item with class ".list-group-item", or null if not found. + * @param {Object|null} options - Custom options for sortable lists + * of form {used: UsedOptions, available: AvailableOptions} (optional). + * @returns {Object} - Merged user options for "used" and "available" lists. */ - _get_moveable_parent_li(target) { - var li = target; - while (!li.matches(".list-group-item")) { - li = li.parentNode; + _set_user_options(options) { + var userOptions; + if (options === null) { + userOptions = this.defaultOptions; + } else { + userOptions = {used: Object.assign(this.defaultOptions.used, options.used), + available: Object.assign(this.defaultOptions.available, options.available)}; } - return li; + return userOptions; } /** - * Delete all non-header items from the "used" list. + * Validate if a given option key is among the possible option keys. * * @method * @private - * @returns {void} + * @param {string} key - The option key to validate. + * @param {string[]} possibleOptionKeys - List of possible option keys. + * @returns {boolean} - Returns true if the option key is valid, false otherwise. */ - _delete_all_from_used() { - const lis = document.querySelectorAll(`#${this.usedId} > .list-group-item`); - lis.forEach(li => {if (!li.matches(".header")) {li.parentNode.removeChild(li);}}); + _validate_option_key(key, possibleOptionKeys) { + return possibleOptionKeys.includes(key); } - }; export default {stack_sortable}; diff --git a/corsscripts/stacksortable.min.js b/corsscripts/stacksortable.min.js index 3996c9807b828b058047e611b93a70df5330eff0..a6211622495424789aef421ac3422a6a3f08ba2f 100644 --- a/corsscripts/stacksortable.min.js +++ b/corsscripts/stacksortable.min.js @@ -1,44 +1,65 @@ -export const SUPPORTED_CALLBACK_FUNCTIONS=["onChoose","onUnchoose","onStart","onEnd","onAdd","onUpdate","onSort","onRemove","onFilter","onMove","onClone","onChange"];export function preprocess_steps(proofSteps,blockUserOpts,sortableUserOpts){if(typeof proofSteps==="string"){proofSteps=_stackstring_objectify(proofSteps);} -var valid=_validate_parsons_JSON(proofSteps);if(JSON.stringify(Object.keys(proofSteps))===JSON.stringify(["steps","options"])){var userOpts=proofSteps["options"];proofSteps=proofSteps["steps"];if(userOpts.header!==undefined){blockUserOpts={used:{header:userOpts.header[0]},available:{header:userOpts.header[1]}};} -delete userOpts.header;sortableUserOpts={used:userOpts,available:userOpts};} -if(typeof proofSteps==="string"){proofSteps=_stackstring_objectify(proofSteps);} -return[proofSteps,blockUserOpts,sortableUserOpts,valid];} +export const SUPPORTED_CALLBACK_FUNCTIONS=["onChoose","onUnchoose","onStart","onEnd","onAdd","onUpdate","onSort","onRemove","onFilter","onMove","onClone","onChange"];export function preprocess_steps(steps,sortableUserOpts,headers,available_header,index){if(typeof steps==="string"){steps=_stackstring_objectify(steps);} +var valid=_validate_parsons_JSON(steps);if(_validate_top_level_keys_JSON(steps,["steps","options","headers","index","available_header"],["steps"])){var sortableUserOpts={used:steps["options"],available:steps["options"]};if("headers"in steps){headers=steps["headers"];} +if("available_header"in steps){available_header=steps["available_header"];} +index=steps["index"];steps=steps["steps"];} +if(typeof steps==="string"){steps=_stackstring_objectify(steps);} +return[steps,sortableUserOpts,headers,available_header,index,valid];} function _stackstring_objectify(stackjson_array_string){return Object.fromEntries(new Map(Object.values(JSON.parse(stackjson_array_string))));} -function _validate_parsons_JSON(proofSteps){if(Object.values(proofSteps).every((val)=>!(typeof(val)=='object'))){return _validate_proof_steps(proofSteps);} -if(Object.values(proofSteps).some((val)=>typeof(val)=="object")){if(JSON.stringify(Object.keys(proofSteps))!==JSON.stringify(["steps","options"])){return false;} -if(!_validate_proof_steps(proofSteps["steps"])){return false;} +function _validate_parsons_JSON(steps){if(Object.values(steps).every((val)=>!(typeof(val)=="object"))){return _validate_flat_steps(steps);} +if(Object.values(steps).some((val)=>typeof(val)=="object")){if(!_validate_top_level_keys_JSON(steps,["steps","options","headers","index","available_header"],["steps"])){return false;} +if(!_validate_flat_steps(steps["steps"])){return false;} return true;}} -function _validate_proof_steps(proofSteps){if(typeof(proofSteps)=='string'){proofSteps=_stackstring_objectify(proofSteps);} -return Object.values(proofSteps).every((val)=>typeof(val)=='string');} -function _flip_orientation(usedId,availableId){var usedList=document.getElementById(usedId);var availableList=document.getElementById(availableId);var newClass=usedList.className=='list-group row'?'list-group col':'list-group row';usedList.setAttribute('class',newClass);availableList.setAttribute('class',newClass);} -export function add_orientation_listener(buttonId,usedId,availableId){const button=document.getElementById(buttonId);button.addEventListener('click',()=>_flip_orientation(usedId,availableId));} +function _validate_flat_steps(steps){if(typeof(steps)=="string"){steps=_stackstring_objectify(steps);} +return Object.values(steps).every((val)=>typeof(val)=="string");} +function _validate_top_level_keys_JSON(JSON,validKeys,requiredKeys){const keys=Object.keys(JSON);const missingRequiredKeys=requiredKeys.filter((key)=>!keys.includes(key));const invalidKeys=keys.filter((key)=>!validKeys.includes(key));return invalidKeys.length===0&&missingRequiredKeys.length===0;} export function get_iframe_height(){return document.documentElement.offsetHeight;} -export const stack_sortable=class{constructor(proofSteps,availableId,usedId,inputId=null,options=null,clone=false){this.proofSteps=proofSteps;this.inputId=inputId;this.state=this._generate_state(this.proofSteps,this.inputId);if(inputId!==null){this.input=document.getElementById(this.inputId);this.submitted=this.input.getAttribute("readonly")==="readonly"} -this.availableId=availableId;this.available=document.getElementById(this.availableId);this.usedId=usedId;this.used=document.getElementById(this.usedId);this.clone=clone;this.defaultOptions={used:{animation:50},available:{animation:50}};this.userOptions=this._set_user_options(options);this.options=this._set_ghostClass_group_and_disabled_options();} -validate_options(possibleOptionKeys,unknownErr,overwrittenErr){var err='';var keysRecognised=true;var invalidKeys=[];Object.keys(this.options.used).forEach(key=>{if(!this._validate_option_key(key,possibleOptionKeys)){keysRecognised=false;if(!invalidKeys.includes(key)){invalidKeys.push(key);}}});Object.keys(this.options.available).forEach(key=>{if(!this._validate_option_key(key,possibleOptionKeys)){keysRecognised=false;if(!invalidKeys.includes(key)){invalidKeys.push(key);}}});if(!keysRecognised){err+=unknownErr+invalidKeys.join(", ")+". ";} +export const stack_sortable=class stack_sortable{constructor(steps,inputId=null,options=null,clone=false,columns=1,rows=null,orientation="col",index="",grid=false,item_height=null,item_width=null){this.steps=steps;this.inputId=inputId;this.orientation=orientation;this.columns=((this.orientation==="col")?columns:rows);this.rows=((this.orientation==="col")?rows:columns);this.index=index;this.use_index=this.index!=="";this.grid=grid;this.item_class=(this.grid?(this.orientation==="row"?"grid-item-rigid":"grid-item"):"list-group-item");this.item_height_width={"style":""};for(const[key,val]of[["height",item_height],["width",item_width]]){if(val!==""){this.item_height_width["style"]+=`${key}:${val}px;`};};this.item_height_width=(this.item_height_width["style"]==="")?{}:this.item_height_width;this.container_height_width=(this.item_height_width["style"]!=="")?{"style":this.item_height_width["style"]+"margin: 12px;"}:{};this.state=this._generate_state(this.steps,inputId,Number(this.columns),Number(this.rows));if(inputId!==null){this.input=document.getElementById(this.inputId);this.submitted=this.input.getAttribute("readonly")==="readonly"} +this.ids=this._create_ids(this.rows,this.columns);this.availableId=this.ids.available;this.usedId=this.ids.used;this.clone=clone;this.defaultOptions={used:{animation:50},available:{animation:50}};this.userOptions=this._set_user_options(options);this.options=this._set_ghostClass_group_and_disabled_options();} +add_dblclick_listeners(newUsed,newAvailable){this.available.addEventListener("dblclick",(e)=>{if(this._double_clickable(e.target)){var li=this._get_moveable_parent_li(e.target);li=(this.clone==="true")?li.cloneNode(true):this.available.removeChild(li);this.used[0][0].append(li);this.update_state(newUsed,newAvailable);}});this.used[0][0].addEventListener("dblclick",(e)=>{if(this._double_clickable(e.target)){var li=this._get_moveable_parent_li(e.target);this.used[0][0].removeChild(li);if(this.clone!=="true"){this.available.insertBefore(li,this.available.children[1]);} +this.update_state(newUsed,newAvailable);}});} +add_delete_all_listener(buttonId,newUsed,newAvailable){const button=document.getElementById(buttonId);button.addEventListener("click",()=>{this._delete_all_from_used();this.update_state(newUsed,newAvailable);});} +add_headers(headers,available_header){for(const[i,value]of headers.entries()){var parentEl=document.getElementById(`usedList_${i}`);var header=this._create_header(value,`usedHeader_${i}`,this.item_height_width);parentEl.insertBefore(header,parentEl.firstChild);} +var parentEl=document.getElementById("availableList");parentEl.insertBefore(this._create_header(available_header,"availableHeader",this.item_height_width),parentEl.firstChild);} +add_index(index){for(const[i,value]of index.entries()){if(i===0){var idx=this._create_index(value,`usedIndex_${i}`,this.item_height_width);var addClass=this.orientation==="col"?"header":"index";idx.classList.add(addClass);}else{var idx=this._create_index(value,`usedIndex_${i}`,this.item_height_width);} +document.getElementById("index").append(idx);}} +add_reorientation_button(){var btn=document.createElement("button");btn.id="orientation";btn.setAttribute("class","parsons-button");var icon=document.createElement("i");icon.setAttribute("class","fa fa-refresh");btn.append(icon);btn.addEventListener("click",()=>this._flip_orientation());document.body.insertBefore(btn,document.getElementById("containerRow"));} +create_row_col_divs(){var usedClassList=(!this.grid||this.orientation==="col")?(["list-group",this.orientation,"usedList"]):([this.orientation,"usedList"]);var itemClass=(this.orientation==="col")?"row":"col";var itemClassList=[itemClass,"usedList"];var availClassList=(!this.grid||this.orientation==="col")?["list-group",this.orientation]:[this.orientation];var container=document.getElementById("containerRow");if(this.use_index){var indexCol=document.createElement("div");indexCol.id="index";indexCol.classList.add(...usedClassList);container.append(indexCol);} +this.colIds.forEach((id)=>{var colDiv=document.createElement("ul");colDiv.id=id;colDiv.classList.add(...usedClassList);container.append(colDiv);});if(this.rows!==""){this.colIds.forEach((colId)=>{var colDiv=document.getElementById(colId);colDiv.classList.add("container");this.rowColIds[colId].forEach((rowColId)=>{var divRowCol=document.createElement("li");divRowCol.id=rowColId;if(this.orientation==="row"){divRowCol.classList.add(...itemClassList,...["col-rigid"]);}else{divRowCol.classList.add(...itemClassList);} +colDiv.append(divRowCol);})})};var availDiv=document.createElement("ul");availDiv.id=this.ids.available;availDiv.classList.add(...availClassList);if(this.orientation==="col"){container.append(availDiv);}else{container.insertBefore(availDiv,container.firstChild);} +this.used=this.usedId.map(idList=>idList.map(id=>document.getElementById(id)));this.available=document.getElementById(this.availableId);} +generate_available(){this.state.available.forEach(key=>this.available.append(this._create_li(key,this.item_height_width)));} +generate_used(){for(const[i,value]of this.state.used.entries()){if(this.rows!==""&&this.columns!==""){for(const[j,val]of value.entries()){this._apply_attrs(this.used[i][j],this.container_height_width);val.forEach(key=>this.used[i][j].append(this._create_li(key,this.item_height_width)));}}else{value[0].forEach(key=>this.used[i][0].append(this._create_li(key,this.item_height_width)));}}} +update_state(newUsed,newAvailable){var newState={used:newUsed.map((usedList)=>usedList.map((used)=>used.toArray())),available:newAvailable.toArray()};if(this.inputId!==null){this.input.value=JSON.stringify(newState);this.input.dispatchEvent(new Event("change"));} +this.state=newState;} +validate_options(possibleOptionKeys,unknownErr,overwrittenErr){var err="";var keysRecognised=true;var invalidKeys=[];Object.keys(this.options.used).forEach(key=>{if(!this._validate_option_key(key,possibleOptionKeys)){keysRecognised=false;if(!invalidKeys.includes(key)){invalidKeys.push(key);}}});Object.keys(this.options.available).forEach(key=>{if(!this._validate_option_key(key,possibleOptionKeys)){keysRecognised=false;if(!invalidKeys.includes(key)){invalidKeys.push(key);}}});if(!keysRecognised){err+=unknownErr+invalidKeys.join(", ")+". ";} var overwrittenKeys=[];var keysPreserved=true;["ghostClass","group","onSort"].forEach(key=>{if(Object.keys(this.userOptions.used).includes(key)||Object.keys(this.userOptions.available).includes(key)) {keysPreserved=false;overwrittenKeys.push(key);}});if(!keysPreserved){err+=overwrittenErr+overwrittenKeys.join(", ")+".";} if(!keysRecognised||!keysPreserved){this._display_warning(err);}} -generate_available(){this.state.available.forEach(key=>this.available.append(this._create_li(key)));} -generate_used(){this.state.used.forEach(key=>this.used.append(this._create_li(key)));} -add_headers(headers){this.used.append(this._create_header(headers.used.header,"usedHeader"));this.available.append(this._create_header(headers.available.header,"availableHeader"));} -update_state(newUsed,newAvailable){var newState={used:newUsed.toArray(),available:newAvailable.toArray()};if(this.inputId!==null){this.input.value=JSON.stringify(newState);this.input.dispatchEvent(new Event('change'));} -this.state=newState;} -add_dblclick_listeners(newUsed,newAvailable){this.available.addEventListener('dblclick',(e)=>{if(this._double_clickable(e.target)){var li=this._get_moveable_parent_li(e.target);li=(this.clone==="true")?li.cloneNode(true):this.available.removeChild(li);this.used.append(li);this.update_state(newUsed,newAvailable);}});this.used.addEventListener('dblclick',(e)=>{if(this._double_clickable(e.target)){var li=this._get_moveable_parent_li(e.target);this.used.removeChild(li);if(this.clone!=="true"){this.available.insertBefore(li,this.available.children[1]);} -this.update_state(newUsed,newAvailable);}});} -add_delete_all_listener(buttonId,newUsed,newAvailable){const button=document.getElementById(buttonId);button.addEventListener('click',()=>{this._delete_all_from_used();this.update_state(newUsed,newAvailable);});} -_generate_state(proofSteps,inputId){let stateStore=document.getElementById(inputId);if(stateStore===null){return{used:[],available:[...Object.keys(proofSteps)]};} -return(stateStore.value&&stateStore.value!="")?JSON.parse(stateStore.value):{used:[],available:[...Object.keys(proofSteps)]};} -_validate_option_key(key,possibleOptionKeys){return possibleOptionKeys.includes(key);} -_set_user_options(options){var userOptions;if(options===null){userOptions=this.defaultOptions;}else{userOptions={used:Object.assign(this.defaultOptions.used,options.used),available:Object.assign(this.defaultOptions.available,options.available)};} -return userOptions;} -_set_ghostClass_group_and_disabled_options(){var group_val={used:{name:"sortableUsed",pull:true,put:true}};group_val.available=(this.clone==="true")?{name:"sortableAvailable",pull:"clone",revertClone:true,put:false}:{name:"sortableAvailable",put:true};var options_to_assign=this.submitted?{used:{ghostClass:"list-group-item-info",group:group_val.used,disabled:true},available:{ghostClass:"list-group-item-info",group:group_val.available,disabled:true}}:{used:{ghostClass:"list-group-item-info",group:group_val.used},available:{ghostClass:"list-group-item-info",group:group_val.available}} -var options={used:Object.assign(Object.assign({},this.userOptions.used),options_to_assign.used),available:Object.assign(Object.assign({},this.userOptions.available),options_to_assign.available)};return options;} -_display_warning(msg){var warning=document.createElement("div");warning.className="sortable-warning";var exclamation=document.createElement("i");exclamation.className="icon fa fa-exclamation-circle text-danger fa-fw";warning.append(exclamation);var warningMessage=document.createElement("span");warningMessage.textContent=msg;warning.append(warningMessage);document.body.insertBefore(warning,document.getElementById("sortableContainer"));} -_create_li(proofKey){let li=document.createElement("li");li.innerHTML=this.proofSteps[proofKey];li.setAttribute("data-id",proofKey);li.className="list-group-item";return li;} -_create_header(innerHTML,id){let i=document.createElement("i");i.innerHTML=innerHTML;i.className="list-group-item header";i.setAttribute("id",id);return i;} -_double_clickable(item){return!item.matches(".header");} +_apply_attrs(el,opts){for(const[key,value]of Object.entries(opts)){el.setAttribute(key,value);}} +_create_header(innerHTML,id,attrs){let i=document.createElement("i");i.innerHTML=innerHTML;var addClass=(this.grid&&this.orientation!=="col")?[this.item_class,"index"]:[this.item_class,"header"];i.classList.add(...addClass);this._apply_attrs(i,{...{"id":id},...attrs});return i;} +_create_ids(rows,columns){var colIdx=Array.from({length:columns},(_,i)=>i);var rowIdx=Array.from({length:rows},(_,j)=>j);this.colIds=colIdx.map((idx)=>`usedList_${idx}`);this.rowColIds={} +colIdx.forEach((i)=>this.rowColIds[this.colIds[i]]=rowIdx.map((j)=>`usedList_${j}${i}`));var usedIds=(rows==="")?this.colIds.map((id)=>[id]):Object.values(this.rowColIds);return{used:usedIds,available:"availableList"};} +_create_index(innerHTML,id,attrs){let i=document.createElement("i");i.innerHTML=innerHTML;var addClass=(this.orientation==="col")?[this.item_class,"index"]:[this.item_class,"header"];i.classList.add(...addClass);this._apply_attrs(i,{...{"id":id},...attrs});return i;} +_create_li(stepKey,attrs){let li=document.createElement("li");li.innerHTML=this.steps[stepKey];this._apply_attrs(li,{...{"data-id":stepKey},...attrs});li.className=this.item_class;return li;} +_deletable_li(li){return!li.matches(".header")&&!li.matches(".index")&&!this._is_empty_li(li);} +_delete_all_from_used(){const lis=document.querySelectorAll(".usedList li[data-id]");lis.forEach(li=>{if(this._deletable_li(li)){this._delete_li(li);}});} +_delete_li(li){li.parentNode.removeChild(li);} +_display_warning(msg){var warning=document.createElement("div");warning.className="sortable-warning";var exclamation=document.createElement("i");exclamation.className="icon fa fa-exclamation-circle text-danger fa-fw";warning.append(exclamation);var warningMessage=document.createElement("span");warningMessage.textContent=msg;warning.append(warningMessage);document.body.insertBefore(warning,document.getElementById("containerRow"));} +_double_clickable(item){return!item.matches(".header")&&!item.matches(".index");} +_flip_orientation(){var addClass=(this.orientation==="row")?["list-group","col"]:["row"];if(this.grid){var removeClass=(this.orientation==="row")?["list-group","row"]:["list-group","col"];var currGridClass=(this.orientation==="row")?"grid-item-rigid":"grid-item";var gridAddClass=(this.orientation==="row")?"grid-item":"grid-item-rigid" +var gridItems=document.querySelectorAll(`.${currGridClass}`);gridItems.forEach((item)=>{item.classList.remove(currGridClass);item.classList.add(gridAddClass);}) +if(this.rows!==""){[].concat(...this.used).forEach((div)=>{if(this.orientation==="col"){div.classList.remove("row");div.classList.add("col","col-rigid");}else{div.classList.remove("col","col-rigid");div.classList.add("row");}})}}else{var removeClass=(this.orientation==="row")?["row"]:["col"];} +this.colIds.forEach((colId)=>{var ul=document.getElementById(colId);ul.classList.remove(...removeClass);ul.classList.add(...addClass);});this.available.classList.remove(...removeClass);this.available.classList.add(...addClass);if(this.orientation==="col"){this.available.parentNode.insertBefore(this.available,this.available.parentNode.firstChild);}else{this.available.parentNode.append(this.available);} +if(this.grid){if(this.orientation==="col"){document.querySelectorAll(".header").forEach((header)=>{if(!header.classList.contains("index")){header.classList.remove("header");header.classList.add("index");}});}else{document.querySelectorAll(".index").forEach((index)=>{if(!index.classList.contains("header")){index.classList.remove("index");index.classList.add("header");}})} +if(this.use_index){var indexDiv=document.getElementById("index");indexDiv.classList.remove(...removeClass);indexDiv.classList.add(...addClass);if(this.orientation==="col"){document.querySelectorAll("#index > .index").forEach((idx)=>{if(!idx.classList.contains("header")){idx.classList.remove("index");idx.classList.add("header");}})}else{document.querySelectorAll("#index > .header").forEach((header)=>{if(!header.classList.contains("index")){header.classList.remove("header");header.classList.add("index");}})}}};this.orientation=(this.orientation==="row")?"col":"row";} +_generate_state(steps,inputId,columns,rows){const usedState=(rows===0||columns===0)?Array(columns).fill().map(()=>[[]]):Array(columns).fill().map(()=>Array(rows).fill([]));let stateStore=document.getElementById(inputId);if(stateStore===null){return{used:usedState,available:[...Object.keys(steps)]};} +return(stateStore.value&&stateStore.value!="")?JSON.parse(stateStore.value):{used:usedState,available:[...Object.keys(steps)]};} _get_moveable_parent_li(target){var li=target;while(!li.matches(".list-group-item")){li=li.parentNode;} return li;} -_delete_all_from_used(){const lis=document.querySelectorAll(`#${this.usedId}>.list-group-item`);lis.forEach(li=>{if(!li.matches(".header")){li.parentNode.removeChild(li);}});}};export default{stack_sortable}; \ No newline at end of file +_is_empty_li(li){return li.textContent.trim()===""&&li.children.length===0;} +_set_ghostClass_group_and_disabled_options(){var group_val={};group_val.used=(this.rows==="")?{name:"sortableUsed",pull:true,put:true}:{name:"sortableUsed",pull:true,put:(to)=>to.el.children.length<1};group_val.available=(this.clone==="true")?{name:"sortableAvailable",pull:"clone",revertClone:true,put:false}:{name:"sortableAvailable",put:true};var options_to_assign=this.submitted?{used:{ghostClass:"list-group-item-info",group:group_val.used,disabled:true},available:{ghostClass:"list-group-item-info",group:group_val.available,disabled:true}}:{used:{ghostClass:"list-group-item-info",group:group_val.used},available:{ghostClass:"list-group-item-info",group:group_val.available}} +var options={used:Object.assign(Object.assign({},this.userOptions.used),options_to_assign.used),available:Object.assign(Object.assign({},this.userOptions.available),options_to_assign.available)};return options;} +_set_user_options(options){var userOptions;if(options===null){userOptions=this.defaultOptions;}else{userOptions={used:Object.assign(this.defaultOptions.used,options.used),available:Object.assign(this.defaultOptions.available,options.available)};} +return userOptions;} +_validate_option_key(key,possibleOptionKeys){return possibleOptionKeys.includes(key);}};export default{stack_sortable}; \ No newline at end of file diff --git a/doc/en/Authoring/Matching.md b/doc/en/Authoring/Matching.md new file mode 100644 index 0000000000000000000000000000000000000000..91bb5945c6a8019e21f91ae3fcaa8507880ca326 --- /dev/null +++ b/doc/en/Authoring/Matching.md @@ -0,0 +1,181 @@ +# Authoring drag-and-drop matching and grid problems + +The drag-and-drop functionality developed for [Parson's problems for proof](Parsons.md) has been extended to be used for general matching and grid problems. To author these, use the `[[parsons]] ... [[/parsons]]` block as one would for Parson's problems, but specify either `columns = "n"` or `columns="n"` _and_ `rows="m"`, which will set up a drag-and-drop grouping or grid layout with the number columns and rows as specified. You cannot specify `rows` without specifying `columns`. + +The combinations of these two parameters define three possible layout configurations as follows: +1. **Proof** (`[[parsons]] [[/parsons]]`) : if both `columns` and `rows` are unspecified then this will give the traditional Parson's proof layout with an answer list that users must drag to and an available list that users must drag from. Refer to [the guide](Parsons.md) for writing Parson's proof questions. +2. **Column grouping** (`[[parsons columns="n"]] [[/parsons]]`) : If `columns="n"` is specified and `rows` is unspecified, this will lay out `n` _vertically arranged_ answer lists that items must be dragged to and an additional vertical available list that items must be dragged from. In this case, the lists can be arbitrary length and must be grown from the top downwards just as in the **Proof** layout. Via the reorientation button, the student is able to switch orientation between this and a row grouping setting (where answer lists are arranged as rows that are arbitrary length and grow from the left rightwards). +4. **Grid** (`[[parsons columns="n" rows="m"]] [[/parsons]]`) : If both `columns="n"` and `rows="m"` are specified, then this will lay out an `m` by `n` answer grid that items must be dragged to and a vertical available list that items must be dragged from. In this case, individual items can be passed to any position in the grid. The user also has the option to re-orient the grid to have `m` columns and `n` rows via the reorientation button. + +The basic usage of all four modes are the exact same as [the Proof case](Parsons.md#authoring-json-within-the-question-text-itself), one can just modify the block parameters as specified. For example +``` +[[parsons columns="2"]] +{ + "f" : "\\(y = x^2\\)", + "g" : "\\(y = x^3\\)", + "quad" : "Quadratic", + "cubic" : "Cubic", +} +[[/parsons]] +``` +## Clone mode + +We emphasise that items can be re-used by setting `clone="true"` in the block header (as in `[[parsons columns="n" clone="true"]][[/parsons]]`). This is more likely to be needed for grouping and grid setups. + +## Transposing on load + +The re-orientation button allows the student to switch between vertical and horizontal orientation as they wish, but on load the default is for the columns to be displayed vertically. Using `transpose="true"` in the header (as in `[[parsons columns="n" transpose="true"]][[/parsons]]`) will change this so that the horizontal orientation will display on load. + +## Headers + +By default, answer lists in groupings and grid layouts will get default headers indexed by positive whole numbers. The available list will get a default header of "Drag from here:". These will become row indexes in **Row grouping** layout, or when the user presses the re-orient button. + +Answer list headers can be changed by assigning the key `"headers"` key an an array of strings containing the new headers. The single header for the available list can be changed by assigning the `"available_header"` key to a string. +``` +[[parsons columns="2"]] +{ + "steps": { + "f" : "\\(y = x^2\\)", + "g" : "\\(y = x^3\\)", + "quad" : "Quadratic", + "cubic" : "Cubic", + }, + "headers" : ["Equation", "Type"], + "available_header" : "Available items" +} +[[/parsons]] +``` + +Note that `headers.` must be a list of the same length as the number of columns and `available` must be a string. +Beware that long headers may overflow boxes when using several columns so it is best to keep them short. + +## Index + +By default in **Column grouping** and **grid** layouts no index is used. In **Row grouping** mode the headers are an index, and in this case no headers exist by default. + +To change this, one can pass an index to the JSON as follows: +``` +[[parsons columns="1" rows="2"]] +{ + "steps": { + "quad" : "Quadratic", + "cubic" : "Cubic", + }, + "headers" : ["Type"] + "available_header" : "Available items" + "index" : ["Equation", "\\(y = x^2\\)", "\\(y = x^3\\)"] +} +[[/parsons]] +``` + +Note that the length of the index must be the same as `rows + 1`. You can simply pass an empty string to the first position if no index header is required. + +## Sortable options + +The final JSON key allowed inside the `parsons` block is `"options"` whose value can be a JSON containing options that can be used to customise the functionality of the drag-and-drop list. See [the Parsons guide](Parsons.md) for how to include these, and [the Sortable library](https://github.com/SortableJS/Sortable#options) for further details on possible customisations. + +## Full list of block parameters + +See [the Parsons authoring guide](Parsons.md#block-parameters) for a full list of supported block parameters. + +## Troubleshooting + +If your matching problem is not displaying properly, in particular if the all the items are displayed in a single yellow block, then +double-check that you have spelled the keys of the JSON inside the Parsons block correctly as described below. They should be a subset of +``` +{"steps", "options", "headers", "available_header", "index"} +``` +and a superset of +``` +{"steps"} +``` +For technical reasons this is one error that we are unable to validate currently. + +## State + +The state of the problem at any given point in time during question answer takes on the following format: +``` +{used: usedState, available: availableState} +``` +where `usedState` and `availableState` are arrays containing the keys specified in `steps` of the JSON in the answer. In all cases, `availableState` is a flat array of variable length. The shape of `usedState`` depends on which of the four layouts is being used. We give examples below. + +1. **Proof**: In this case `usedState` will have shape `(1, 1, ?)`, where `?` indicates the variable dimension. For example: +```` +[[parsons input="ans1"]] +{ + "1":"Assume that \\(n\\) is odd.", + "2":"Then there exists an \\(m\\in\\mathbb{Z}\\) such that \\(n=2m+1\\).", + "3":"\\[ n^2 = (2m+1)^2 = 2(2m^2+2m)+1.\\]", + "4":"Define \\(M=2m^2+2m\\in\\mathbb{Z}\\) then \\(n^2=2M+1\\).", +} +[[/parsons]] +```` +might have, at a given time, a state that looks like: +``` +{ + used : [ + [ + ["1", "3"] + ] + ] + available : + ["2", "4"] +} +``` +2. **Column grouping**: In this case `usedState` will have shape `(n, 1, ?)`, where `n` is the number of columns and `?` indicates the variable dimension. For example: +``` +[[parsons columns="2"]] +{ + "f" : "\\(y = x^2\\)", + "g" : "\\(y = x^3\\)", + "quad" : "Quadratic", + "cubic" : "Cubic", +} +[[/parsons]] +``` +might have, at a given time, a state that looks like: +``` +{ + used : [ + [ + ["f"] + ], + [ + ["quad", "cubic"] + ] + ], + available : ["g"] +} +``` +3. **Row grouping** : In this case `usedState` will have shape `(m, 1, ?)`, where `m` is the number of rows and `?` indicates the variable dimension. The state of **Row grouping** is just the same as **Column grouping** if `m` and `n` are the same. +4. **Grid** : In this case `usedState` will have shape `(n, m, 1)`, where `n` is the number of columns and `m` is the number of rows. For example: +``` +[[parsons columns="2" rows="3"]] +{ + "f" : "\\(y = x^2\\)", + "g" : "\\(y = x^3\\)", + "h" : "\\(y = x^4\\)", + "quad" : "Quadratic", + "cubic" : "Cubic", + "quart" : "Quartic" +} +[[/parsons]] +``` +might have, at a given time, a state that looks like: +``` +{ + used : [ + [ + ["f"], + ["g"], + [], + ], + [ + ["quad"], + [], + ["quart"] + ] + ], + available : ["h", "cubic"] +} +``` \ No newline at end of file diff --git a/doc/en/Authoring/Parsons.md b/doc/en/Authoring/Parsons.md index 30178c351852ccb417e832dda7e86117ab8893fc..41836413e07cade0683ab7a4ef7e7e0ed1eba514 100644 --- a/doc/en/Authoring/Parsons.md +++ b/doc/en/Authoring/Parsons.md @@ -24,7 +24,7 @@ Here is a basic example of use: Assume the question author writes a list `proof_steps` of pairs `["key", "string"]` in Maxima (as in the examples), in the question variables with both the correct and incorrect strings. ```` -[parsons input="ans1" ]] +[[parsons input="ans1" ]] {# stackjson_stringify(proof_steps) #} [[/parsons]] ```` @@ -55,10 +55,8 @@ The `[[parsons]]` block is a wrapper for the javascript library "Sortable.js", o ```` [[parsons input="ans1"]] { "steps": {# stackjson_stringify(proof_steps) #}, - "options": {"header" : ["Custom header for the answer list", "Custom header for the available steps"], - "sortable option 1" : value, - ... - "sortable option n" : value} + "options": {"sortable option 1" : value, ..., "sortable option n" : value}, + "headers" : ["Custom header for the answer list"], } [[/parsons]] ```` @@ -72,15 +70,28 @@ A list of all Sortable.js options can be found [here](https://github.com/Sortabl ```` Most other Sortable options can be modified, except for `ghostClass`, `group` and `onSort` as these are required to be set for basic functionality. -The only non-Sortable option that may currently be customised is the `header` option. The default for these are: +Note that if you enter an unknown sortable option or if an attempt to pass `ghostClass`, `group`, or `onSort` is made, then these will simply be ignored. A warning will be displayed on the question page to signify this situation. + +The default for "headers" and "available_header" are: ```` { - "header": ["Construct your solution here:", "Drag from here:"] + "headers": ["Construct your solution here:"], + "available_header": ["Drag from here:"] } ```` -To modify these pass an array of length two, with first entry corresponding to the header for the answer list and the second entry corresponding to the header for the list of available steps. -Note that if you enter an unknown sortable option or if an attempt to pass `ghostClass`, `group`, or `onSort` is made, then these will simply be ignored. A warning will be displayed on the question page to signify this situation. +#### Troubleshooting + +If your Parson's problem is not displaying properly, in particular if the all the items are displayed in a single yellow block, then +double-check that you have spelled the keys of the JSON inside the Parsons block correctly as described above. They should be a subset of +``` +{"steps", "options", "headers", "available_header"} +``` +and a superset of +``` +{"steps"} +``` +For technical reasons this is one error that we are unable to validate currently. ### Block parameters @@ -91,10 +102,12 @@ Functionality and styling can be customised through the use of block parameters. 3. `width`: string containing a positive float + a valid CSS unit (e.g.`"480px"`, `"100%"`, ...). Default is `"100%"`. This fixes the width of the window containing the drag-and-drop lists. 4. `aspect-ratio`: string, containing a float between 0 and 1. This can be used with `height`/`length` _or_ `width` (not both) and automatically determines the value of the un-used parameter in accordance with the value of `aspect-ratio`; unset by default. An error will occur if one sets values for `aspect-ratio`, `width`, `height` _or_ `aspect-ratio`, `width`, `length`. 5. `clone`: string of the form `"true"` or `"false"`. It is `"false"` by default. When `"false"` there are two lists and each proof step is "single-use", here the author must write all necessary proof steps even if they repeat; when `"true"` all proof steps are re-usable with no restrictions on how many times they are used, steps can only be dragged from the available list into the answer list, and there is a bin to tidy up steps. -6. `orientation`: string of the form `"horizontal"` or `"vertical"`. This can be used to fix the initial orientation shown to the user, `"horizontal"` will show lists side-by-side and `"vertical"` will show lists on top of each other. Note that there is a button on the page in which the user may switch the orientation to their preference while answering the question, so the `"orientation"` block parameter only determines the initial layout. It is `"horizontal"` by default. -7. `override-css`: string containing the location of a local CSS file contained in `question/type/stack/corsscripts/` directory in the format `cors://file-name` or a href to an external CSS stylesheet. This will override all CSS styling of the drag-and-drop listing, so it should be used with care. However, it can be used to customise the styling of the lists by writing one's own custom CSS file and passing in the location of that file to this parameter. This parameter is unset by default. -8. `override-js`: string containing a local JavaScript library or a href to a cdn of a JavaScript library. This will overwrite the Sortable library used with the library identified by the string. This should be used if one wishes to use an updated version of the Sortable library, or adding functionality with a custom library. Note that the custom library will need to either extend or import the base Sortable functionality. Unset by default. -9. `version`: string of the form `"local"` or `"cdn"`. Whether to use STACK's local copy of the Sortable library or whether to pull version 1.15.0 of Sortable from cdn. This is `"local"` by default. +6. `override-css`: string containing the location of a local CSS file contained in `question/type/stack/corsscripts/` directory in the format `cors://file-name` or a href to an external CSS stylesheet. This will override all CSS styling of the drag-and-drop listing, so it should be used with care. However, it can be used to customise the styling of the lists by writing one's own custom CSS file and passing in the location of that file to this parameter. This parameter is unset by default. +7. `override-js`: string containing a local JavaScript library or a href to a cdn of a JavaScript library. This will overwrite the Sortable library used with the library identified by the string. This should be used if one wishes to use an updated version of the Sortable library, or adding functionality with a custom library. Note that the custom library will need to either extend or import the base Sortable functionality. Unset by default. +8. `version`: string of the form `"local"` or `"cdn"`. Whether to use STACK's local copy of the Sortable library or whether to pull version 1.15.0 of Sortable from cdn. This is `"local"` by default. +9. `columns` : string containing an integer `"n"`. How many vertical answer lists to display. By default, this is not used. If it is specified, then the styling will change to a grid-format with multiple vertical answer lists of unspecified length. +10. `rows` : string containing an integer `"m"`. How many horizontal answer lists to display. By default, this is not used. If it is specified and `columns` is _not_ specified, this will change to a grid-format with multiple horizontal answer lists of unspecified width. If both `columns` and `rows` are specified then this will provide a fixed length and width grid format, where items can be dragged to any position in the grid in any order. You cannot specify `rows` without specifying `columns`. +11. `transpose` : `"true"` or `"false"`; `"false"` by default. While the student is able to re-orient between vertical and horizontal as they wish, the default on load is for columns to be vertical. If you wish them to default to being horizontal, then pass `transpose="true"`. ## Random generation of `proof_step` order diff --git a/doc/en/Developer/Development_track.md b/doc/en/Developer/Development_track.md index 985d8f7773032c0d709181c25810e4c648b1db06..b29c359e27aa5494273168b75deb64e6561691e5 100644 --- a/doc/en/Developer/Development_track.md +++ b/doc/en/Developer/Development_track.md @@ -34,27 +34,24 @@ TO-DO: ## Parson's block development track -Next (v4.6.0) +Next (v4.7.0) -1. Grid arrangement, e.g. fill in a 2*2 grid (for matching problems). +1. Nested lists (flat list vs. nested/tree) and different proof types -- iff, induction, etc. how do we indicate the different scaffolding for this? 2. Use syntax hint to set up a non-empty starting point. -3. Nested lists (flat list vs. nested/tree) -4. CSS styling fix for automated feedback +3. Create templates from the start for different proof types Later -1. Different proof types -- iff, induction, etc. how do we indicate the different scaffolding for this? -2. Create templates from the start for different proof types -3. Restrict blocks to fixed number of steps -4. Allow student to select proof style (e.g. iff, contradiction) and pre-structure answer list accordingly -5. Allow some strings in the correct answer to be optional. Allow authors to input a weight for each item and use weighted D-L distance, e.g., weight of 0 indicates that a step is not required, but will not be considered incorrect if included. -6. Making use of third item in other ways? Hover over a proof step to reveal more information (e.g., this could come from the third item in the list and give a hint/definition) -7. Allow students to mark items (e.g. as used or unneeded) or tick used items -8. Confirmation for delete all? -9. Alternative styling/signalling for clone mode? -10. Better support (and documentation) for bespoke grading functions. -11. Hashing keys -12. Check sortable for keyboard accessibility (SM: Not built-in to Sortable currently: https://github.com/SortableJS/Sortable/issues/1951; however, it looks like it is do-able with some work https://robbymacdonell.medium.com/refactoring-a-sortable-list-for-keyboard-accessibility-2176b34a07f4) +1. Restrict blocks to fixed number of steps +2. Allow student to select proof style (e.g. iff, contradiction) and pre-structure answer list accordingly +3. Allow some strings in the correct answer to be optional. Allow authors to input a weight for each item and use weighted D-L distance, e.g., weight of 0 indicates that a step is not required, but will not be considered incorrect if included. +4. Making use of third item in other ways? Hover over a proof step to reveal more information (e.g., this could come from the third item in the list and give a hint/definition) +5. Allow students to mark items (e.g. as used or unneeded) or tick used items +6. Confirmation for delete all? +7. Alternative styling/signalling for clone mode? +8. Better support (and documentation) for bespoke grading functions. +9. Hashing keys +10. Check sortable for keyboard accessibility (SM: Not built-in to Sortable currently: https://github.com/SortableJS/Sortable/issues/1951; however, it looks like it is do-able with some work https://robbymacdonell.medium.com/refactoring-a-sortable-list-for-keyboard-accessibility-2176b34a07f4) ## For "inputs 2"? diff --git a/doc/en/Topics/Matching.md b/doc/en/Topics/Matching.md new file mode 100644 index 0000000000000000000000000000000000000000..55b80fb0e73949e8d4eb568f128d47044d7320af --- /dev/null +++ b/doc/en/Topics/Matching.md @@ -0,0 +1,447 @@ +# Authoring drag and drop matching problems in STACK + +The drag-and-drop functionality of the [Parson's](Parsons.md) block in STACK can also be used to write matching and other grid-based problems, which can be answered by drag and drop. + +This page provides a quick-start guide to authoring matching problems with the `parsons` block. +As of STACK v4.6.0, the `parsons` block has three main configurations (each of which support further customisation) which can be achieved by setting appropriate block header parameters `columns` and `rows` as appropriate: +1. **Proof** (Example usage: `[[parsons]] ... [[/parsons]]`) : This was introduced in STACK v4.5.0, and a full guide can be found [here](Parsons.md). +2. **Grouping** (Example usage: `[[parsons columns="3"]] ... [[/parsons]]`) : +This will set up a number of columns, each of which behave similarly to the single left-hand column of the **Proof** configuration, where the student may drag and drop items starting at the top of each column. +This is useful when we are only interesting in grouping items together, and specific row positions do not matter, or when each column may have variable length. +3. **Grid** (Example usage: `[[parsons columns="3" rows="2"]] ... [[/parsons]]`) : +This will set up a grid of shape `(columns, rows)`, where the student may drag and drop items to any position in the grid. + +Note that many **Grid** style questions can also be written using the **Grouping** setup. +The main difference between them is that **Grid** allows the student to drag any item to any position in the grid, regardless +of whether the item above it has been filled; **Grouping** on the other hand only allows students to drag items to the +end of the list within a column. + +## Troubleshooting + +If your matching problem is not displaying properly, in particular if the all the items are displayed in a single yellow block, then +double-check that you have spelled the keys of the JSON inside the Parsons block correctly as described below. They should be a subset of +``` +{"steps", "options", "headers", "available_header", "index"} +``` +and a superset of +``` +{"steps"} +``` +For technical reasons this is one error that we are unable to validate currently. + +## Switching orientation + +Parsons blocks will display columns vertically by default. +The student has the option to flip the orientation so that the columns become horizontal rows, and back again, through a button on the question page. +If you wish the columns to be horizontal rows as the question display default, then simply add `transpose="true"` to the block header (e.g., `[[parsons columns="3" transpose="true"]] ... [[/parsons]]`, will load a question with 3 horizontal rows). + +## Providing a model answer + +_The format of the model answer is fixed and independent of the orientation discussed in the previous section_. +It should be defined in _Question variables_ as a two-dimensional array that is always column-grouped. +For example if a model answer looks like +``` + a | b | c +---|---|--- + d | | e +---|---|--- + f | | +``` +Then this should be defined as +``` +ans: [["A", "D", "F"], ["B"], ["C", "E"]]; +``` +where, e.g., `"A"` is the key identifier for string `"a"`. + +## Cloning items + +The `clone` header parameter has been available in `parsons` since v4.5.0 of STACK. +This can be set to `"true"` to allow items to be used more than once (e.g., `[[parsons columns="3" clone="true"]]...[[/parsons]]`). + +## Headers and index + +Column headers default to `0`, `1`, ..., `columns` when using **Grouping** or **Grid** configurations of the Parson's block, so we recommend to always set custom headers for these cases. +See the examples below for how to do this. + +There is also the option of setting the index, which is possible in both **Grouping** and **Grid** configurations, but is more likely to be useful for the latter. +This will appear as a specially styled left-most column and fixes labels for the rows. +The index does not count as a column, so you should _decrease columns by one in the header parameters_. +The item that appears top-left in both the index and header should be included in the _index only_. +See the examples below for more details. + +## Item style + +In **Grouping** and **Grid** configurations, the height and width of individual items can be changed via the `item-height` and `item-width` header parameters. Parameter values should be a string containing a number, and this will set the pixels of the height/width. +The default is `50px` (`item-height="50"`) for height, and the width is automatically deduced from the page layout and number of columns for the vertical orientation, or it is `100px` (`item-width="100"`) for the horizontal configuration. +This may be needed if you have images or other large overflowing items (including header titles). + +## The matching library + +The `matchlib.mac` Maxima library contains a small number of functions that are required for basic functionality of assessing matching problems in STACK. +Essentially they translate the author answers to and back from JSON format expected by the drag-and-drop engine. +Be sure to include this and make use of it as detailed in the examples below. + +We include some basic helper functions that can allow the author to specify whether they care or not about the order within and between rows or columns as follows. +- `ans: [["A", "B"], ["C", "D"], ["E", "F"]]` +- `match_column_set` : I don't care about the order within a column -> +``` +match_column_set(ans) = [{"A", "B"}, {"C", "D"}, {"E", "F"}] +``` +- `match_row_set` : I don't care about the order within a row (unlikely to be required but included for completeness) -> +``` +match_row_set(ans) = [{"A", "C", "E"}, {"B", "D", "F"}] +``` +- `match_set_column` : I don't care about the order of the columns (unlikely to be required but included for completeness) -> +``` +match_set_column(ans) = {["A", "B"], ["C", "D"], ["E", "F"]} +``` +- `match_set_row` : I don't care about the order of the rows -> +``` +match_set_rows(ans) = {["A", "C", "E"], ["B", "D", "F"]} +``` +- `match_transpose` : I would like to turn my answer into a row-grouped array -> +``` +match_transpose(ans) = [["A", "C", "E"], ["B", "D", "F"]] +``` + +## Example 1 : Grouping example + +In our first example, the student is asked to place functions, given as equations, into columns with the categories "Differentiable", "Continuous, not differentiable", "Discontinuous". + +### Question variables + +As a minimum it is recommended to include: +- Load the matching library. +- Define all items in the available list as a two-dimensional array, where each item is an array of the form `["<ID>", "<actual item contents>"]`. +You will use the `"<ID>"` string to write solutions and assess student inputs; the second item is what is displayed to the student. +- Randomly permute the available items. +- The headers that will appear on top of the answer columns. +- The correct answer as a two-dimensional array. +This should be column grouped. + +For our example, the _Question variables_ field looks as follows. +``` +stack_include("contribl://matchlib.mac"); + +steps : [ + ["sq", "\\(f(x) = x^2\\)"], + ["sin", "\\(f(x) = \\sin(x)\\)"], + ["abs", "\\(f(x) = |x|\\)"], + ["sqrt", "\\(f(x) = \\sqrt(x)\\)"], + ["rec", "\\(f(x) = 1/x\\)"], + ["sgn", "\\(f(x) = \\text{sgn}(x)\\)"] +]; + +steps: random_permutation(steps); + +headers: [ + "Differentiable", + "Continuous, not differentiable", + "Discontinuous" +]; + +ans: [ + ["sq", "sin"], + ["abs", "sqrt"], + ["rec", "sgn"] +]; +``` + +### Question text + +Here we should: +- Write the question text itself. +- Open the `parsons` block with `input` and `columns` header parameters. +- Transfer the variables from _Question variables_ into a JSON inside the `parsons` block as appropriate. +- Close the `parsons` block. +- Set `style="display:none"` in the input div to hide the messy state from the student. + +``` +<p>Recall that a function may be differentiable, continuous but +not differentiable, or discontinuous. Drag the functions +to their appropriate category. </p> +[[parsons input="ans1" columns="3"]] +{ + "steps" : {#stackjson_stringify(steps)#}, + "headers" : {#headers#} +} +[[/parsons]] +<p style="display:none">[[input:ans1]] [[validation:ans1]]</p> +``` + +### Question note + +A question note is required due to the random permutation of `steps`. We use: +``` +{@map(first, steps)@} +``` + +### Input: ans1 + +1. The _Input type_ field should be **String**. +2. The _Model answer_ field should construct a JSON object from the teacher's answer `ta` using `match_correct(ans, steps)`. +3. Set the option _Student must verify_ to "no". +4. Set the option _Show the validation_ to "no". +5. Add `hideanswer` to _Extra options_. + +Steps 3, 4 and 5 are strongly recommended, otherwise the student will see unhelpful code representing the underlying state of their answer. + +### Potential response tree: prt1 + +Define the feedback variable +``` +sans: match_interpret(ans1); +``` +This provides the student response as a two-dimensional array of the same format as `ans`. +At this point the author may choose to assess by comparing `sans` and `ans` as they see fit. +In this case, the order _within_ a column really doesn't matter, but the order of the columns does of course. +So we may convert the columns of `sans` and `ans` to sets in feedback variables using `match_column_set` from `matchlib.mac`. +``` +sans: match_column_set(sans); +ans: match_column_set(ans); +``` +We can then do a regular algebraic equivalence test between `sans` and `ans`. You should turn the node to `Quiet: Yes`, otherwise the student will see unhelpful code if they the answer wrong. + +## Example 2 : Grid example + +Here, the student is asked to drag functions and their derivatives to relevant columns and rows. +This particular example could work as a grouping example in the vein of Example 1 above, however the key difference +here is that the student can drag an item to any position in the grid, whereas in grouping items can only be added +to the end of a growing column list. + +Much of this example is very similar to Example 1 above, with the following key differences: +- The `parsons` block should include a specified `rows` parameter. +- The `match_correct` function should use `true` as a third parameter inside _Model answer_. +- The `match_interpret` function should use `true` as a third parameter inside the PRT. +- We also define our PRT answer test differently, since we care only about the order within a row being preserved. +However this difference is not _required_ and is due only to the nature of the question (i.e., what we want to assess from this question is +different from the one in Example 1), rather than from any system requirements. + +### Question variables + +As a minimum it is recommended to include: +- Load the matching library. +- Define all items in the available list as a two-dimensional array, where each item is an array of the form `["<ID>", "<actual item contents>"]`. +You will use the `"<ID>"` string to write solutions and assess student inputs; the second item is what is displayed to the student. +- Randomly permute the available items. +- The headers that will appear on top of the answer columns. +- The correct answer as a two-dimensional array. This should always be column grouped. + +For our example, the _Question variables_ field looks as follows. +``` +stack_include("contribl://matchlib.mac"); + +steps : [ + ["f", "\\(y = x^2\\)"], + ["g", "\\(y = x^3\\)"], + ["dfdx", "\\(y' = 2x\\)"], + ["dgdx", "\\(y' = 3x^2\\)"], + ["df2d2x", "\\(y'' = 2\\)"], + ["dg2d2x", "\\(y'' = 6x\\)"] +]; + +steps: random_permutation(steps); + +headers: [ + "Function", + "\\(d/dx\\)", + "\\(d^2/d^2x\\)" +]; + +ans: [ + ["f", "g"], + ["dfdx", "dgdx"], + ["df2d2x", "dg2d2x"] +]; +``` + +### Question text + +Here we should: +- Write the question text itself. +- Open the `parsons` block with `input`, `columns` and `rows` header parameters. +- Transfer the variables from _Question variables_ into a JSON inside the `parsons` block as appropriate. +- Close the `parsons` block. +- Set `style="display:none"` in the input div to hide the messy state from the student. + +``` +<p>Drag the items to match up the functions with their derivatives. </p> +[[parsons input="ans1" columns="3" rows="2"]] +{ + "steps" : {#stackjson_stringify(steps)#}, + "headers" : {#headers#} +} +[[/parsons]] +<p style="display:none">[[input:ans1]] [[validation:ans1]]</p> +``` + +### Question note + +A question note is required due to the random permutation of `steps`. We use: +``` +{@map(first, steps)@} +``` + +### Input: ans1 + +1. The _Input type_ field should be **String**. +2. The _Model answer_ field should construct a JSON object from the teacher's answer `ta` using `match_correct(ans, steps, true)`. +3. Set the option _Student must verify_ to "no". +4. Set the option _Show the validation_ to "no". +5. Add `hideanswer` to _Extra options_. + +Steps 3, 4 and 5 are strongly recommended, otherwise the student will see unhelpful code representing the underlying state +of their answer. + +### Potential response tree: prt1 + +Define the feedback variable +``` +sans: match_interpret(ans1, true); +``` +This provides the student response as a two-dimensional array of the same format as `ans`. +At this point the author may choose to assess by comparing `sans` and `ans` as they see fit. In this case, the _order of the rows themselves_ really doesn't matter, but the order of the rows does of course. So we may convert the list of rows of `sans` and `ans` to a set in feedback variables using `match_set_row` from `matchlib.mac`. +``` +sans: match_set_row(sans); +ans: match_set_row(ans); +``` +We can then do a regular algebraic equivalence test between `sans` and `ans`. +You should turn the node to `Quiet: Yes`, otherwise the student will see unhelpful code if they the answer wrong. + +## Example 3 : Grid example with an index + +One can add a left-hand index to the grid in Example 2 by defining an `index` array in _Question variables_ and passing this through in the JSON inside the `parsons` block. +This will fix the row order and simplify assessment. + +Important points: +- An item that appears in both the header and the index is **required**. +This item should appear in the index and not in the header. +- Reduce the value of the `columns` parameter in the `parsons` block by one: this corresponds only to the number of answer columns. +- Pass the index as the value of key `"index"` inside the JSON within the `parsons` block. + +### Question variables + +The question variables for Example 2 with an index is as follows. +``` +stack_include("contribl://matchlib.mac"); + +steps : [ + ["dfdx", "\\(y' = 2x\\)"], + ["dgdx", "\\(y' = 3x^2\\)"], + ["df2d2x", "\\(y'' = 2\\)"], + ["dg2d2x", "\\(y'' = 6x\\)"] +]; + +steps: random_permutation(steps); + +headers: [ + "\\(d/dx\\)", + "\\(d^2/d^2x\\)" +]; + +index: [ + "Function", + "\\(y = x^2\\)", + "\\(y = x^3\\)" +] + +ans: [ + ["dfdx", "dgdx"], + ["df2d2x", "dg2d2x"] +]; +``` + +### Question text + +``` +<p>Drag the items to match up the functions with their derivatives. </p> +[[parsons input="ans1" columns="2" rows="2"]] +{ + "steps" : {#stackjson_stringify(steps)#}, + "headers" : {#headers#}, + "index" : {#index#} +} +[[/parsons]] +<p style="display:none">[[input:ans1]] [[validation:ans1]]</p> +``` + +### Question note + +A question note is required due to the random permutation of `steps`. We use: +``` +{@map(first, steps)@} +``` + +### Input + +This is exactly the same as Example 2. + +1. The _Input type_ field should be **String**. +2. The _Model answer_ field should construct a JSON object from the teacher's answer `ta` using `match_correct(ans, steps, true)`. +3. Set the option _Student must verify_ to "no". +4. Set the option _Show the validation_ to "no". +5. Add `hideanswer` to _Extra options_. + +### PRT + +As in Example 2, we first extract the two-dimensional array of used items from the students input. +``` +sans: match_interpret(ans1, true); +``` +At this point the author may choose to assess by comparing `sans` and `ans` as they see fit. +Since we have fixed the order of both dimensions, there is only one correct answer which is given by `ans`. +Hence we have a basic PRT which tests only algebraic equivalence between `sans` and `ans`. +As always, turn the node to `Quiet: Yes`, otherwise the student will see unhelpful code if they the answer wrong. + +## Example 4 : Using images + +Through the use of STACK's `plot` function, which wraps Maxima's `plot2d`, static images can also be included within items. +Apart from modifying the content of the steps of Example 2, the key difference here is that the width and height of the items must also be specified in the block parameter, to make sure that plots fit inside. +This can be done by specifying the `item-width` and `item-height` parameters within the block header of `parsons`. +Because of this, it is recommended to always specify the `[size, x, y]` option within `plot`, and add some padding to `x` and `y` to define the values of `item-width` and `item-height`. +Example 2 with plots rather than equations is given below. + +### Question variables + +``` +stack_include("contribl://matchlib.mac"); + +steps: [ + ["f", plot(x^2,[x,-1,1], [size, 200, 200])], + ["g", plot(x^3,[x,-1,1], [size, 200, 200])], + ["dfdx", plot(2*x,[x,-1,1], [size, 200, 200])], + ["dgdx", plot(3*x^2,[x,-1,1], [size, 200, 200])], + ["df2d2x", plot(2,[x,-1,1], [size, 200, 200])], + ["dg2d2x", plot(6*x,[x,-1,1], [size, 200, 200])] +]; + +steps: random_permutation(steps); + +headers: ["Function", "\\(d/dx\\)", "\\(d^2/d^2x\\)"]; + +ans: [ + ["f", "g"], + ["dfdx", "dgdx"], + ["df2d2x", "dg2d2x"] +]; +``` + +### Question text + +``` +<p>Drag the items to match up the functions with their derivatives. </p> +[[parsons input="ans1" columns="3" rows="2" item-height="250" item-width="250"]] +{ + "steps" : {#stackjson_stringify(steps)#}, + "headers" : {#headers#}, +} +[[/parsons]] +<p style="display:none">[[input:ans1]] [[validation:ans1]]</p> +``` + +### Question note, Inputs and PRT + +These are exactly the same as Example 2. + + + diff --git a/doc/en/Topics/Parsons.md b/doc/en/Topics/Parsons.md index 3733d0539a6b4bc4f803242bf909c65e99befa2f..115802bbf53f088c87ae49f3b077c735e2ca6019 100644 --- a/doc/en/Topics/Parsons.md +++ b/doc/en/Topics/Parsons.md @@ -33,6 +33,19 @@ Notes * Lists are a special case of a tree with one root (the list creation function) and an arbitrary number of nodes in order. Hence our design explicitly includes traditional Parson's problems as a special case. * Teachers who do not want to scaffold explicit block structures (e.g. signal types of proof blocks) can choose to restrict students to (i) flat lists, or (ii) lists of lists. +## Troubleshooting + +If your Parson's problem is not displaying properly, in particular if the all the items are displayed in a single yellow block, then +double-check that you have spelled the keys of the JSON inside the Parsons block correctly as described below. They should be a subset of +``` +{"steps", "options", "headers", "available_header"} +``` +and a superset of +``` +{"steps"} +``` +For technical reasons this is one error that we are unable to validate currently. + # Example 1: a minimal Parson's question The following is a minimal Parson's question where there student is expected to create a list in one and only one order. diff --git a/lang/en/qtype_stack.php b/lang/en/qtype_stack.php index 2472e204f78fd3fc9c1ec5613df096f8c5deccbf..0ef0872ec48b2c0716184692bebd17286afe57c8 100644 --- a/lang/en/qtype_stack.php +++ b/lang/en/qtype_stack.php @@ -949,9 +949,22 @@ $string['stackBlock_parsons_unknown_named_version'] = 'The Parson\'s block only $string['stackBlock_parsons_unknown_mathjax_version'] = 'The Parson\'s block only supports MathJax versions {$a->mjversion} for the mathjax parameter.'; $string['stackBlock_parsons_ref'] = 'The Parson\'s block only supports referencing inputs present in the same CASText section \'{$a->var}\' does not exist here.'; $string['stackBlock_parsons_param'] = 'The Parson\'s block supports only these parameters in this context: \'{$a->param}\'.'; -$string['stackBlock_parsons_contents'] = 'The contents of a Parson\'s block must be a JSON of the form {#stackjson_stringify(proof_steps)#}. If you are passing custom objects then the Parson\'s block contents should be a JSON of the form {steps: {#stackjson_stringify(proof_steps)#}, options: {JSON containing Sortable options}}. Alternatively, the contents of the Parsons block may contain raw JSON equivalents. Make sure that the proof_steps Maxima variable is of the correct format. Note that all proof steps must be strings. See the documentation for details.'; +$string['stackBlock_parsons_contents'] = 'The contents of a Parson\'s block must be a either a JSON of the form {#stackjson_stringify(steps)#}, where `steps` is the two-dimensional Maxima array containing key, value pairs of items, or of the form {\'steps\' : {#stackjson_stringify(steps)#}, \'options\' : {JSON containing Sortable options}, \'header\' : [List of headers], \'available_header\' : \'string containing header for the available list\', \'index\' : [List containing the index]}, where the \'options\', \'header\', \'available_header\', and \'index\' keys are optional. Alternatively, the contents of the Parsons block may contain raw JSON equivalents. Make sure that the `steps` Maxima variable is of the correct format. Note that all steps must be strings. See https://docs.stack-assessment.org/en/Authoring/Parsons/ for details.'; +$string['stackBlock_incorrect_header_length'] = 'The list of headers should have the same length as the number of columns passed to the block header.'; +$string['stackBlock_incorrect_available_header_type'] = 'The header for the available list should be passed as a string or a list of length one.'; +$string['stackBlock_incorrect_index_length'] = 'The length of the index should be one more than the number of rows passed to the block header. An item in the top-left corner should always go in the index'; +$string['stackBlock_incorrect_index_type'] = 'Index should be an array containing strings.'; +$string['stackBlock_incorrect_header_type'] = 'Headers should be an array containing strings.'; +$string['stackBlock_parsons_invalid_columns_value'] = 'The value of `columns` in the Parson\'s block header should be a string containing a positive integer.'; +$string['stackBlock_parsons_invalid_rows_value'] = 'The value of `rows` in the Parson\'s block header should be a string containing a positive integer.'; +$string['stackBlock_parsons_invalid_item-height_value'] = 'The value of `item-height` in the Parson\'s block header should be a string containing a positive integer.'; +$string['stackBlock_parsons_invalid_item-width_value'] = 'The value of `item-width` in the Parson\'s block header should be a string containing a positive integer.'; $string['stackBlock_unknown_sortable_option'] = 'Unknown Sortable options found, the following are being ignored: '; $string['stackBlock_overwritten_sortable_option'] = 'Unchangeable Sortable options found, the following are being ignored: '; +$string['stackBlock_parsons_unknown_transpose_value'] = 'Transpose must be one of \'true\' or \'false\'.'; +$string['stackBlock_parsons_underdefined_grid'] = 'When defining `rows` for a Parson\'s block one must also define `columns`.'; +$string['stackBlock_proof_mode_index'] = 'The use of \'index\' is not supported when using the Parson\'s block for proof assessment.'; +$string['stackBlock_proof_incorrect_header_length'] = 'Headers should be an array containing a single header; use \'available_header\' to update the header for the available list.'; // Define the stackBlock GeoGebra strings. $string['stackBlock_geogebra_width'] = 'The width of a GeoGebra Applet must use a known CSS-length unit.'; diff --git a/stack/cas/castext2/blocks/parsons.block.php b/stack/cas/castext2/blocks/parsons.block.php index 673be738cdc32d27e5bfc6537e0af2454e1fa350..0c17c8e4537e2feeeb2db3553492f20455dfe198 100644 --- a/stack/cas/castext2/blocks/parsons.block.php +++ b/stack/cas/castext2/blocks/parsons.block.php @@ -34,7 +34,7 @@ class stack_cas_castext2_parsons extends stack_cas_castext2_block { ], 'local' => [ 'css' => 'cors://sortable.min.css', - 'js' => 'cors://sortable.min.js', + 'js' => 'cors://sortablecore.min.js', ], ]; @@ -43,12 +43,44 @@ class stack_cas_castext2_parsons extends stack_cas_castext2_block { // Define iframe params. $xpars = []; - $inputs = []; // From inputname to variable name. - $clone = "false"; // Whether to have all keys in available list cloned. - $mathjaxversion = "2"; // MathJax version (either "2" or "3"). + + // Input identifiers. + $inputs = []; + + // Whether to have all keys in available list cloned. + $clone = 'false'; + + // MathJax version (either "2" or "3"). + $mathjaxversion = '2'; + + // Number of available columns. + $columns = null; + + // Number of available rows. + $rows = null; + + // Tranpose. + $transpose = false; + + // Item height. + $itemheight = null; + + // Item width. + $itemwidth = null; + foreach ($this->params as $key => $value) { if ($key === 'clone') { $clone = $value; + } else if ($key === 'columns') { + $columns = $value; + } else if ($key === 'rows') { + $rows = $value; + } else if ($key === 'transpose') { + $transpose = ($value === 'true'); + } else if ($key === 'item-height') { + $itemheight = $value; + } else if ($key === 'item-width') { + $itemwidth = $value; } else if ($key !== 'input') { $xpars[$key] = $value; } else { @@ -65,9 +97,6 @@ class stack_cas_castext2_parsons extends stack_cas_castext2_block { if (isset($xpars['overridejs'])) { unset($xpars['overridejs']); } - if (isset($xpars['orientation'])) { - unset($xpars['orientation']); - } // Set default width and height here. // We want to push forward to overwrite the iframe defaults if they are not provided in the block parameters. @@ -126,15 +155,19 @@ class stack_cas_castext2_parsons extends stack_cas_castext2_block { } } + // Identify default proof mode based on block header params + // Note that proof mode behaves the same as the general mode, but we just + // need to redefine columns. + $proofmode = ($columns === null && $rows === null); + $gridmode = !$proofmode; + $columns = $proofmode ? '1' : $columns; // Add correctly oriented container divs for the proof lists to be accessed by sortable. - $orientation = isset($this->params['orientation']) ? $this->params['orientation'] : 'horizontal'; - $outer = $orientation === 'horizontal' ? 'row' : 'col'; - $inner = $orientation === 'horizontal' ? 'col' : 'row'; - $innerui = '<ul class="list-group ' . $inner . '" id="usedList"></ul> - <ul class="list-group ' . $inner . '" id="availableList"></ul>'; - - $r->items[] = new MP_String("<button type='button' class='parsons-button' id='orientation'> - <i class='fa fa-refresh'></i></button>"); + $orientation = $transpose ? 'row' : 'col'; + $ogcolumns = $columns; + $ogrows = $rows; + $columns = $transpose ? $ogrows : $ogcolumns; + $rows = $transpose ? $ogcolumns : $ogrows; + $r->items[] = new MP_String("<button type='button' class='parsons-button' id='resize'> <i class='fa fa-expand'></i></button>"); if ($clone === 'true') { @@ -144,28 +177,21 @@ class stack_cas_castext2_parsons extends stack_cas_castext2_block { <i class="fa fa-times-circle "></i></div>'); } - $r->items[] = new MP_String('<div class="container" id="sortableContainer" style="' . $astyle . '"> - <div class=row>' . $innerui . ' - </div> - </div>'); + $r->items[] = new MP_String('<div class="container row" id="containerRow" style="' . $astyle . '"></div>'); // JS script. $r->items[] = new MP_String('<script type="module">'); $importcode = "\nimport {stack_js} from '" . stack_cors_link('stackjsiframe.min.js') . "';\n"; - $importcode .= "import {Sortable} from '" . stack_cors_link('sortable.min.js') . "';\n"; + $importcode .= "import {Sortable} from '" . stack_cors_link('sortablecore.min.js') . "';\n"; $importcode .= "import {preprocess_steps, stack_sortable, - add_orientation_listener, get_iframe_height, SUPPORTED_CALLBACK_FUNCTIONS } from '" . stack_cors_link('stacksortable.min.js') . "';\n"; $r->items[] = new MP_String($importcode); - // Add flip orientation listener to the orientation button. - // TO-DO: automatically set orientation based on device? - $r->items[] = new MP_String('add_orientation_listener("orientation", "usedList", "availableList");' . "\n"); // Add the resize button listener. $r->items[] = new MP_String('document.getElementById("resize").addEventListener( "click", () => {stack_js.resize_containing_frame("' . $width . '", get_iframe_height() + "px");});' . "\n"); @@ -189,16 +215,63 @@ class stack_cas_castext2_parsons extends stack_cas_castext2_block { } $r->items[] = new MP_String(";\n"); - // Parse steps and Sortable options separately if they exist. Invalid JSON will be identified by preprocess_steps function. - $code = 'var headers = {used: {header: "' . stack_string('stackBlock_parsons_used_header') . '"}, - available: {header: "' . stack_string('stackBlock_parsons_available_header') . '"}};' . "\n"; + // Define default headers. + if ($proofmode) { + $code = 'var headers = ["' . stack_string('stackBlock_parsons_used_header') . '"];' . "\n"; + } else { + $code = 'var headers = [' . implode(', ', range(1, intval($ogcolumns))) . '];' . "\n"; + } + $code .= 'var available_header = "' . stack_string('stackBlock_parsons_available_header') . '";' . "\n"; + + // Parse steps, Sortable options, headers and index separately if they exist. + // Invalid JSON will be identified by preprocess_steps function. $code .= 'var sortableUserOpts = {};' . "\n"; - $code .= 'var valid;' . "\n"; - $code .= '[proofSteps, headers, sortableUserOpts, valid] = preprocess_steps(proofSteps, headers, sortableUserOpts);' . "\n"; + $code .= 'var valid, index;' . "\n"; - // If the author's JSON has invalid format throw an error. + $code .= '[proofSteps, sortableUserOpts, headers, available_header, index, valid] = + preprocess_steps(proofSteps, sortableUserOpts, headers, available_header, index);' . "\n"; + + // If the author's JSON has invalid structure throw an error. $code .= 'if (valid === false) {stack_js.display_error("' . stack_string('stackBlock_parsons_contents') . '");}' . "\n"; + + // More specific pieces of validation + // Check typing of headers, it should be an array containing strings. + $code .= 'if (!(Array.isArray(headers))) + {stack_js.display_error("' . stack_string('stackBlock_incorrect_header_type') . '");}' . "\n"; + + // If the length of headers does not match the number of columns expected throw an error. + // Error is different for proof vs. matching + $code .= 'if (headers.length !== ' . $ogcolumns . ') {stack_js.display_error("'; + if ($proofmode) { + $code .= stack_string('stackBlock_proof_incorrect_header_length') . '");}' . "\n"; + } else { + $code .= stack_string('stackBlock_incorrect_header_length') . '");}' . "\n"; + } + + // Validate available headers. It + // is either a string or an array containing a single string. + $code .= 'if (!(typeof(available_header) === "string" || + (Array.isArray(available_header) && available_header.length === 1 && typeof(available_header[0]) === "string"))) + {stack_js.display_error("' . stack_string('stackBlock_incorrect_available_header_type') . '");}' . "\n"; + // Extract available header if it is an array containing a single string + $code .= 'if (Array.isArray(available_header)) {available_header = available_header[0]};' . "\n"; + + // If index is passed then it should be an array containing strings. + $code .= 'if (index !== undefined && !(Array.isArray(index) && index.every((idx) => typeof(idx) === "string"))) + {stack_js.display_error("' . stack_string('stackBlock_incorrect_index_type') . '");}' . "\n"; + + // If rows and index are passed then the length of index should match the value of rows + 1 + if ($ogrows !== null) { + $code .= 'if (index !== undefined && index.length !== ' . $ogrows + 1 . ') + {stack_js.display_error("' . stack_string('stackBlock_incorrect_index_length') . '");}' . "\n"; + } + + // Index cannot be used in proof mode due to styling issues + if ($proofmode) { + $code .= 'if (index !== undefined) + {stack_js.display_error("' . stack_string('stackBlock_proof_mode_index') . '");}' . "\n"; + } // Link up to STACK inputs. if (count($inputs) > 0) { @@ -210,22 +283,37 @@ class stack_cas_castext2_parsons extends stack_cas_castext2_block { }; // Instantiate STACK sortable helper class. - $code .= 'const stackSortable = new stack_sortable(proofSteps, "availableList", "usedList", id, sortableUserOpts, "' . - $clone .'");' . "\n"; - // Generate the two lists in HTML. - $code .= 'stackSortable.add_headers(headers);' . "\n"; + $code .= 'const stackSortable = new stack_sortable(proofSteps, id, sortableUserOpts, "' . + $clone .'", "' . $columns .'", "' . $rows . '", "' . $orientation . '", index, "' . $gridmode . '", + "' . $itemheight . '", "' . $itemwidth . '");' . "\n"; + // Generate the two lists, headers and index in HTML. + $code .= 'stackSortable.add_reorientation_button();' . "\n"; + $code .= 'stackSortable.create_row_col_divs();' . "\n"; + $code .= 'if (index !== undefined) {stackSortable.add_index(index);};' . "\n"; + $code .= 'stackSortable.add_headers(headers, available_header);' . "\n"; $code .= 'stackSortable.generate_used();' . "\n"; $code .= 'stackSortable.generate_available();' . "\n"; // Create the Sortable objects. // First, instantiate with default options first in order to extract all possible options for validation. - $code .= 'var sortableUsed = Sortable.create(usedList);' . "\n"; - $code .= 'var possibleOptionKeys = Object.keys(sortableUsed.options).concat(SUPPORTED_CALLBACK_FUNCTIONS);' . "\n"; + $code .= 'var sortableUsed = + stackSortable.ids.used.map((idList) => + idList.map((usedId) => Sortable.create(document.getElementById(usedId))));' . "\n"; + $code .= 'var possibleOptionKeys = Object.keys(sortableUsed[0][0].options).concat(SUPPORTED_CALLBACK_FUNCTIONS);' . "\n"; // Now set appropriate options. - $code .= 'Object.entries(stackSortable.options.used).forEach(([key, val]) => sortableUsed.option(key, val));' . "\n"; + + $code .= 'sortableUsed.forEach((sortableList) => + sortableList.forEach((sortable) => + Object.entries(stackSortable.options.used).forEach( + ([key, val]) => sortable.option(key, val))));' . "\n"; $code .= 'var sortableAvailable = Sortable.create(availableList, stackSortable.options.available);' . "\n"; // Add the onSort option in order to link up to input and overwrite user onSort if passed. - $code .= 'sortableUsed.option("onSort", () => {stackSortable.update_state(sortableUsed, sortableAvailable);});' . "\n"; + $code .= 'sortableUsed.forEach((sortableList) => + sortableList.forEach((sortable) => + sortable.option("onSort", () => { + stackSortable.update_state(sortableUsed, sortableAvailable);}) + ) + );' . "\n"; $code .= 'sortableAvailable.option("onSort", () => {stackSortable.update_state(sortableUsed, sortableAvailable);});' . "\n"; // Options can now be validated since sortable objects have been instantiated, we throw warnings only. @@ -241,8 +329,10 @@ class stack_cas_castext2_parsons extends stack_cas_castext2_block { $code .= 'stackSortable.add_delete_all_listener("delete-all", sortableUsed, sortableAvailable);' . "\n"; } - // Add double-click events. - $code .= 'stackSortable.add_dblclick_listeners(sortableUsed, sortableAvailable);' . "\n"; + // Add double-click events for proof. + if ($proofmode) { + $code .= 'stackSortable.add_dblclick_listeners(sortableUsed, sortableAvailable);' . "\n"; + } // Typeset MathJax. MathJax 2 uses Queue, whereas 3 works with promises. $code .= ($mathjaxversion === "2") ? @@ -291,8 +381,7 @@ class stack_cas_castext2_parsons extends stack_cas_castext2_block { $height = array_key_exists('height', $this->params) ? $this->params['height'] : '400px'; // NOTE! List ordered by length. For the trimming logic. - $validunits = [ - + $validunits = [ 'vmin', 'vmax', 'rem', 'em', 'ex', 'px', 'cm', 'mm', 'in', 'pt', 'pc', 'ch', 'vh', 'vw', '%', ]; @@ -370,18 +459,66 @@ class stack_cas_castext2_parsons extends stack_cas_castext2_block { } } + // Check value of transpose is only "true" or "false" + if (array_key_exists('transpose', $this->params)) { + if (!in_array($this->params['transpose'], ['true', 'false'])) { + $valid = false; + $err[] = stack_string('stackBlock_parsons_unknown_transpose_value'); + } + } + + // Check value of columns is a string containing a numeric positive integer + if (array_key_exists("columns", $this->params)) { + if (!(preg_match('/^\d+$/', $this->params["columns"]) && intval($this->params["columns"]) > 0)) { + $valid = false; + $err[] = stack_string("stackBlock_parsons_invalid_columns_value"); + } + } + + // Check value of rows is a string containing a numeric positive integer + if (array_key_exists("rows", $this->params)) { + if (!(preg_match('/^\d+$/', $this->params["rows"]) && intval($this->params["rows"]) > 0)) { + $valid = false; + $err[] = stack_string("stackBlock_parsons_invalid_rows_value"); + } + } + + // Check we cannot have rows specified without columns + if (array_key_exists("rows", $this->params) && !array_key_exists("columns", $this->params)) { + $valid = false; + $err[] = stack_string("stackBlock_parsons_underdefined_grid"); + } + + // Check value of `item-height` is a string containing a positive integer + if (array_key_exists("item-height", $this->params)) { + if (!(preg_match('/^\d+$/', $this->params["item-height"]) && intval($this->params["item-height"]) > 0)) { + $valid = false; + $err[] = stack_string("stackBlock_parsons_invalid_item-height_value"); + } + } + + // Check value of `item-width` is a string containing a positive integer + if (array_key_exists("item-width", $this->params)) { + if (!(preg_match('/^\d+$/', $this->params["item-width"]) && intval($this->params["item-width"]) > 0)) { + $valid = false; + $err[] = stack_string("stackBlock_parsons_invalid_item-width_value"); + } + } + // Check that only valid parameters are passed to block header. $valids = null; foreach ($this->params as $key => $value) { if ($key !== 'width' && $key !== 'height' && $key !== 'aspect-ratio' && - $key !== 'version' && $key !== 'overridecss' && $key !== 'input' && - $key !== 'orientation' && $key !== 'clone') { + $key !== 'version' && $key !== 'overridecss' && $key !== 'input' + && $key !== 'clone' && $key !== 'columns' && $key !== 'rows' && + $key !== 'transpose' && $key !== 'item-height' && $key !== 'item-width') { $err[] = "Unknown parameter '$key' for Parson's block."; $valid = false; if ($valids === null) { $valids = [ 'width', 'height', 'aspect-ratio', 'version', 'overridecss', - 'overridejs', 'input', 'orientation', 'clone', + 'overridejs', 'input', 'clone', 'columns', 'rows', 'transpose', 'item-height', + 'item-width', ]; $err[] = stack_string('stackBlock_parsons_param', [ 'param' => implode(', ', $valids), diff --git a/stack/maxima/contrib/matchlib.mac b/stack/maxima/contrib/matchlib.mac new file mode 100644 index 0000000000000000000000000000000000000000..4127b47312972d357359ace5c3d57652419602a4 --- /dev/null +++ b/stack/maxima/contrib/matchlib.mac @@ -0,0 +1,160 @@ +/* Author Salvatore Mercuri + University of Edinburgh + Copyright (C) 2024 Salvatore Mercuri + + This program is free software: you can redistribute it or modify + it under the terms of the GNU General Public License version two. + + This program 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 details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see <http://www.gnu.org/licenses/>. */ + +/******************************************************************/ +/* Functions for extracting data from matching problems */ +/* in STACK to a format that can be assessed by the author. */ +/* Should be used when providing model answers and writing */ +/* PRTs for matching problems using the `parsons` block. */ +/* */ +/* Salvatore Mercuri, <smercuri@ed.ac.uk> */ +/* V1.0 May 2024 */ +/* */ +/******************************************************************/ + +/* To use these functions load the library via one of the following +two commands inside `Question variables`. + +stack_include("https://raw.githubusercontent.com/maths/moodle-qtype_stack/proof-builder/stack/maxima/contrib/matchlib.mac"); +stack_include("contribl://matchlib.mac"); +*/ + +/******************************************************************/ +/* */ +/* Assessment helper functions */ +/* */ +/******************************************************************/ + +/* + * Use this to extract an answer from the student's input of desirable format + * for assessing. + * + * Take the JSON from STACK Parson's block when using `columns` and/or + * `rows` header parameter, and returns a two-dimensional array corresponding to + * the answer keys in the JSON. + * + * If only `columns` has been specified in the `parsons` block, then use + * this function as `match_interpret(ans1)`. This will return an + * array of shape `(columns, ?)` if, where `?` represents variable dimension. + * + * If both `rows` and `columns` have been specified in the `parson` block, then + * use this function as `match_interpret(ans1, true)`. This will + * return an array of shape `(columns, rows)`. + */ +match_interpret(st, [rows]) := block([js, arr], + js: stackjson_parse(st), + arr: stackmap_get(js, "used"), + if rows=[] then arr:map(lambda([keys], first(keys)), arr) + else arr:map(lambda([keys], map(lambda([k], first(k)), keys)), arr), + return(arr) +); + +/* + * Auxiliary function. + * + * Takes a list of matched keys and returns the keys not used. + * Needed to create a "teacher's answer" in JSON format, including unused text. + */ +match_keys_used_unused(ans, steps) := block([tkeys], + tkeys:map(first, steps), + return([ans, listdifference(tkeys, ev(unique(flatten(ans)), simp))]) +); + +/* + * Use this to transform the teacher's answer into the shape expected by the Parson's block. + * Returns an array of `[answer_keys, unused_keys]`, where `unused_keys` is always a flat + * list of keys that are in the question but not inside `ans`. + * + * If only `columns` has been specified in the `parsons` block, then use + * this function as `match_reshape(ans1)`. This will return `answer_keys` as an + * array of shape `(columns, 1, ?)` if, where `?` represents variable dimension. + * + * If both `rows` and `columns` have been specified in the `parson` block, then + * use this function as `match_interpret(ans1, true)`. This will + * return `answer_keys` as an array of shape `(columns, rows, 1)`. + */ +match_reshape(ans, steps, [rows]) := block([tkeys, akeys], + tkeys: match_keys_used_unused(ans, steps), + if rows=[] then akeys: map(lambda([keys], [keys]), first(tkeys)) + else akeys:map(lambda([keys], map(lambda([k], [k]), keys)), first(tkeys)), + return([akeys, second(tkeys)]) +); + +/* + * Use this to transform the teacher's answer into the JSON format expected by the `Model answer` field. + * + * If only `columns` has been specified in the `parsons` block, then use + * this function as `match_correct(ans1)`. + * + * If both `rows` and `columns` have been specified in the `parson` block, then + * use this function as `match_correct(ans1, true)`. + */ +match_correct(ans, steps, [rows]) := block([akeys, ukeys], + if rows=[] then [akeys, ukeys]: match_reshape(ans, steps) + else [akeys, ukeys]: match_reshape(ans, steps, rows), + sconcat("{\"used\":", stackjson_stringify(akeys), ", \"available\":", stackjson_stringify(ukeys), "}") +); + +/* + * Use this to turn a row-grouped answer into a column-grouped answer and vice-versa. + * + * Note that model answers for matching problems in STACK should always be written by grouping + * the columns, that is they should be a two-dimensional array of shape `(columns, rows)`. Authors + * may prefer to use the row-grouped answer in PRTs. This function will move between them. + */ +match_transpose(ans) := block( + return(args(transpose(apply(matrix, ans)))) +); + +/* + * Use this on both the model answer and the student input + * when you do not care about the order within a column. + * + * It will turn `[[a, b], [c, d], [e, f]]` into `[{a, b}, {c, d}, {e, f}]`. + */ +match_column_set(ans) := block( + return(map(lambda([col], apply(set, col)), ans)) +); + +/* + * Use this on both the model answer and the student input + * when you do not care about the order within a row. + * + * It will turn `[[a, b], [c, d], [e, f]]` into `[{a, c, e}, {b, d, f}]`. + */ +match_row_set(ans) := block( + return(match_column_set(match_transpose(ans))) +); + +/* + * Use this on both the model answer and the student input + * when you do not care about the order between columns. + * + * It will turn `[[a, b], [c, d], [e, f]]` into `{[a, b], [c, d], [e, f]}`. + */ +match_set_column(ans) := block( + return(apply(set, ans)) +); + +/* + * Use this on both the model answer and the student input + * when you do not care about the order between rows. + * + * It will turn `[[a, b], [c, d], [e, f]]` into `{[a, c, e], [b, d, f]}`. + */ +match_set_row(ans) := block( + return(match_set_column(match_transpose(ans))) +); + diff --git a/stack/maxima/contrib/prooflib.mac b/stack/maxima/contrib/prooflib.mac index 0f27ea6fde3303bede9836e261d2227bb1e6a97a..e48f225e9bbac44ca5c6b98a24fc5dbfd305edaf 100644 --- a/stack/maxima/contrib/prooflib.mac +++ b/stack/maxima/contrib/prooflib.mac @@ -177,8 +177,9 @@ proof_remove_nullproof(ex):= block( */ proof_parsons_interpret(st) := block([pf], pf:stackjson_parse(st), - pf:apply(proof, stackmap_get(pf, "used")) + pf:apply(proof, first(first(stackmap_get(pf, "used")))) ); +s_test_case(proof_parsons_interpret("{\"used\":[[[\"0\",\"3\",\"5\"]]],\"available\":[\"1\",\"2\",\"4\",\"6\",\"7\"]}"), proof("0","3","5")); /* * Takes a proof, and proof steps list and returns the keys not used in the proof_steps. @@ -197,7 +198,7 @@ proof_parsons_key_json(proof_ans, proof_steps) := block([pkeys], /* Ensure all keys are string keys. */ if not(emptyp(proof_steps)) then proof_ans:proof_keys_sub(proof_ans, proof_steps), pkeys:proof_parsons_keys_used_unused(proof_ans, proof_steps), - sconcat("{\"used\":", stackjson_stringify(first(pkeys)), ", \"available\":", stackjson_stringify(second(pkeys)), "}") + sconcat("{\"used\":", stackjson_stringify([[first(pkeys)]]), ", \"available\":", stackjson_stringify(second(pkeys)), "}") ); /******************************************************************/ @@ -435,8 +436,9 @@ proof_assessment_display(saa, pf) := block([st, k], /* Turn the st list of lists into a string to display. */ st:dl_disp(st), for k:1 thru length(saa) do block( - st[k]:proof_line_disp(proof_column_disp(first(st[k])), proof_column_disp2(second(st[k]))) + st[k]:proof_line_disp(proof_column_disp(first(st[k])), proof_column_disp(second(st[k]))) ), st:apply(sconcat, st), sconcat("<div class='proof'>", st, "</div>") ); + diff --git a/tests/parsons_block_test.php b/tests/parsons_block_test.php index cac3f66345fd62988d3a9c2ff7633a0257f6d098..d8ad6819e47db98ed4054aac5bab9495e6ff51a8 100644 --- a/tests/parsons_block_test.php +++ b/tests/parsons_block_test.php @@ -283,7 +283,7 @@ class parsons_block_test extends qtype_stack_testcase { $invalidparameters = ['bad_param', 'HEIGHT', 'Height', 'override-css']; $validparameters = [ 'width', 'height', 'aspect-ratio', 'version', 'overridecss', - 'overridejs', 'input', 'orientation', 'clone', + 'overridejs', 'input', 'clone', 'columns', 'rows', 'transpose', 'item-height', 'item-width' ]; foreach ($invalidparameters as $param) { diff --git a/thirdpartylibs.xml b/thirdpartylibs.xml index 120b7ea35c75e7619a711a0d4c49ec7c11a88547..b20a8aa8a3ee1ca5ccd0bfa1cb88f94a5048cffa 100644 --- a/thirdpartylibs.xml +++ b/thirdpartylibs.xml @@ -18,5 +18,11 @@ <version>As for STACK</version> <license>MIT License</license> </library> + <library> + <location>corsscripts/sortablecore.min.js</location> + <name>Sortable</name> + <version>1.15.0</version> + <license>MIT License</license> + </library> </libraries>