diff --git a/corsscripts/stacksortable.js b/corsscripts/stacksortable.js index 2bb75f8632d71d36b77ef99b4e7a7137f06ce5a5..88a0d2fa2fdd08319ee3d1f351dc1009596ece4f 100644 --- a/corsscripts/stacksortable.js +++ b/corsscripts/stacksortable.js @@ -71,8 +71,8 @@ export function preprocess_steps(steps, sortableUserOpts, headers, available_hea // Validate the object var valid = _validate_parsons_JSON(steps); - // 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". + // 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 = steps["options"]; @@ -116,8 +116,8 @@ function _stackstring_objectify(stackjson_array_string) { * 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 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"`. + * 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. * * @param {Object} steps - The Parson's JSON to be validated. @@ -154,7 +154,7 @@ function _stackstring_objectify(stackjson_array_string) { */ 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'))) { + 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"] @@ -205,16 +205,16 @@ function _validate_parsons_JSON(steps) { */ function _validate_flat_steps(steps) { // Case when steps are coming from a Maxima variable: convert to a JSON - if (typeof(steps) == 'string') { + if (typeof(steps) == "string") { steps = _stackstring_objectify(steps); } - return Object.values(steps).every((val) => typeof(val) == 'string'); + return Object.values(steps).every((val) => typeof(val) == "string"); } /** - * Validates the top-level keys of a JSON object by checking they are a subset of `validKeys` + * Validates the top-level keys of a JSON object by checking they are a subset of `validKeys` * and a superset of `requiredKeys`. - * + * * @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. @@ -222,8 +222,8 @@ function _validate_flat_steps(steps) { */ 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)); + const missingRequiredKeys = requiredKeys.filter((key) => !keys.includes(key)); + const invalidKeys = keys.filter((key) => !validKeys.includes(key)); return invalidKeys.length === 0 && missingRequiredKeys.length === 0; } @@ -240,12 +240,18 @@ export function get_iframe_height() { * Class for for managing Sortable lists for Parson's block questions in STACK. * * @class - * @param {Object} steps - Object containing flat steps JSON. - * @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} steps - Object containing all steps. * @property {string} inputId - ID of the input element for storing state (optional). @@ -253,77 +259,109 @@ export function get_iframe_height() { * @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(); + * + * // 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 changes: - * sortable.update_state(newUsedList, newAvailableList); + * // Create the Sortable available list. + * var sortableAvailable = Sortable.create(availableList, stackSortable.options.available); * - * // Updating state on double-click events: - * sortable.update_state_dblclick(newUsedList, newAvailableList); + * // 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} steps - Object containing the flat steps JSON. - * @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(steps, - inputId = null, - options = null, - clone = false, - columns = 1, - rows = null, - orientation = "col", - index = "", - grid = false, - item_height = null, + 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.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_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.item_height = (item_height !== '') ? {'style' : `height:${item_height}px;`} : {}; - this.item_width = (item_width !== '') ? {'style' : `width:${item_width}px;`} : {}; - this.container_height_width = (this.item_height_width['style'] !== '') ? {'style' : this.item_height_width['style'] + 'margin: 12px;'} : {}; + 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); @@ -333,13 +371,13 @@ export const stack_sortable = class { this.availableId = this.ids.available; this.usedId = this.ids.used; this.clone = clone; - + this.defaultOptions = {used: {animation: 50, cancel: ".header"}, available: {animation: 50, cancel: ".header"}}; // 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(); } @@ -354,7 +392,7 @@ export const stack_sortable = class { * @returns {void} */ add_dblclick_listeners(newUsed, newAvailable) { - this.available.addEventListener('dblclick', (e) => { + this.available.addEventListener("dblclick", (e) => { if (this._double_clickable(e.target)) { // get highest-level parent var li = this._get_moveable_parent_li(e.target); @@ -363,7 +401,7 @@ export const stack_sortable = class { this.update_state(newUsed, newAvailable); } }); - this.used[0][0].addEventListener('dblclick', (e) => { + 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); @@ -388,7 +426,7 @@ export const stack_sortable = class { */ add_delete_all_listener(buttonId, newUsed, newAvailable) { const button = document.getElementById(buttonId); - button.addEventListener('click', () => { + button.addEventListener("click", () => { this._delete_all_from_used(); this.update_state(newUsed, newAvailable);}); } @@ -406,12 +444,13 @@ export const stack_sortable = class { parentEl.insertBefore(header, parentEl.firstChild); } var parentEl = document.getElementById("availableList"); - parentEl.insertBefore(this._create_header(available_header, "availableHeader", this.item_height_width), parentEl.firstChild); + 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) { @@ -430,8 +469,8 @@ export const stack_sortable = class { /** * Adds a reorientation button to the document body. - * - * The button allows users to change the orientation of sortable lists between vertical + * + * The button allows users to change the orientation of sortable lists between vertical * and horizontal. */ add_reorientation_button() { @@ -446,9 +485,9 @@ export const stack_sortable = class { } /** - * Populates the DOM with row and column div elements to the document based + * 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 @@ -456,13 +495,13 @@ export const stack_sortable = class { * - 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 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] : + ["list-group", this.orientation] : [this.orientation]; var container = document.getElementById("containerRow"); @@ -472,7 +511,7 @@ export const stack_sortable = class { indexCol.classList.add(...usedClassList); container.append(indexCol); } - this.colIds.forEach((id) => + this.colIds.forEach((id) => { var colDiv = document.createElement("ul"); colDiv.id = id; @@ -547,7 +586,7 @@ export const stack_sortable = class { 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.input.dispatchEvent(new Event("change")); } this.state = newState; } @@ -569,7 +608,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 @@ -613,7 +652,7 @@ export const stack_sortable = class { /** * Applies attributes to an HTML element. - * + * * @param {HTMLElement} el - The HTML element to which attributes will be applied. * @param {Object} opts - An object containing attribute-value pairs to be applied. */ @@ -625,7 +664,7 @@ export const stack_sortable = class { /** * Creates a header element with specified inner HTML, ID, and other attributes. - * + * * @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. @@ -634,22 +673,22 @@ export const stack_sortable = class { _create_header(innerHTML, id, attrs) { let i = document.createElement("i"); i.innerHTML = innerHTML; - var addClass = (this.orientation === "col") ? - [this.item_class, 'header'] : [this.item_class, 'index']; + var addClass = (this.orientation === "col") ? + [this.item_class, "header"] : [this.item_class, "index"]; i.classList.add(...addClass); this._apply_attrs(i, {...{"id" : id}, ...attrs}); return i; } /** - * Creates and organizes identifiers for rows and columns. If only columns are passed, then + * 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 + * `[["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. @@ -660,8 +699,8 @@ export const stack_sortable = class { 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]) : + var usedIds = (rows === "") ? + this.colIds.map((id) => [id]) : Object.values(this.rowColIds); return { @@ -672,7 +711,7 @@ export const stack_sortable = class { /** * 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. @@ -681,8 +720,8 @@ export const stack_sortable = class { _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']; + 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; @@ -690,7 +729,7 @@ export const stack_sortable = class { /** * 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. @@ -705,7 +744,7 @@ export const stack_sortable = class { /** * 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. */ @@ -721,13 +760,13 @@ export const stack_sortable = class { * @returns {void} */ _delete_all_from_used() { - const lis = document.querySelectorAll('.usedList li[data-id]'); + const lis = document.querySelectorAll(".usedList li[data-id]"); lis.forEach(li => {if (this._deletable_li(li)) {this._delete_li(li);}}); } /** * Deletes a list item (li) from its parent node. - * + * * @param {HTMLElement} li - The list item (li) element to delete. */ _delete_li(li) { @@ -755,8 +794,7 @@ export const stack_sortable = class { } /** - * TODO: fix this, it should not be an index or grid-item or grid-item-rigid - * Check if an HTML element is double-clickable (i.e., it is not a header element). + * 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. * @@ -766,22 +804,33 @@ export const stack_sortable = class { * @returns {boolean} - Returns true if the element is double-clickable, false otherwise. */ _double_clickable(item) { - return !item.matches(".header"); + return !item.matches(".header") && !item.matches(".index"); } - /* TODO : simplify this */ + /** + * Flips the question between vertical and horizontal orientations. + * + * @method + * @returns {void} + */ _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") { @@ -794,8 +843,11 @@ export const stack_sortable = class { }) } } 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); @@ -803,15 +855,20 @@ export const stack_sortable = class { } ); + // 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")) { @@ -827,34 +884,37 @@ export const stack_sortable = class { } }) } - }; - 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"); + // 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 steps, input ID, - * and number of columns and rows used. The shape of the used state will be `(1, 1, ?)` if in proof + * 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 @@ -864,8 +924,8 @@ export const stack_sortable = class { * @returns {Object} The initial state object with used and available lists. */ _generate_state(steps, inputId, columns, rows) { - const usedState = (rows === 0 || columns === 0) ? - Array(columns).fill().map(() => [[]]) : + 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) { @@ -899,12 +959,12 @@ export const stack_sortable = class { /** * Checks if a list item (li) element is empty. - * + * * @param {HTMLElement} li - The list item (li) element to check. * @returns {boolean} - True if the list item is empty, otherwise false. */ _is_empty_li(li) { - return li.textContent.trim() === '' && li.children.length === 0; + return li.textContent.trim() === "" && li.children.length === 0; } /** @@ -923,48 +983,48 @@ export const stack_sortable = class { var group_val = {}; group_val.used = (this.rows === "") ? { - name: "sortableUsed", - pull: true, + name: "sortableUsed", + pull: true, put: true - } : + } : { - name: "sortableUsed", - pull: true, - put: (to) => to.el.children.length < 1 + 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 } } diff --git a/corsscripts/stacksortable.min.js b/corsscripts/stacksortable.min.js index a858004880275ba3ab2a9866647b6fdb3a79ca9e..d4441c5f434dda336d5908ef9b8a62f3e8940c9e 100644 --- a/corsscripts/stacksortable.min.js +++ b/corsscripts/stacksortable.min.js @@ -6,57 +6,57 @@ 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(steps){if(Object.values(steps).every((val)=>!(typeof(val)=='object'))){return _validate_flat_steps(steps);} +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_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;} +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(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.item_height=(item_height!=='')?{'style':`height:${item_height}px;`}:{};this.item_width=(item_width!=='')?{'style':`width:${item_width}px;`}:{};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"} +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,cancel:".header"},available:{animation:50,cancel:".header"}};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]);} +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_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);} +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;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'));} +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(", ")+". ";} +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);}} _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.orientation==="col")?[this.item_class,'header']:[this.item_class,'index'];i.classList.add(...addClass);this._apply_attrs(i,{...{"id":id},...attrs});return i;} +_create_header(innerHTML,id,attrs){let i=document.createElement("i");i.innerHTML=innerHTML;var addClass=(this.orientation==="col")?[this.item_class,"header"]:[this.item_class,"index"];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_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_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("sortableContainer"));} -_double_clickable(item){return!item.matches(".header");} +_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";} +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;} -_is_empty_li(li){return li.textContent.trim()===''&&li.children.length===0;} +_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)};}