diff --git a/koha-tmpl/opac-tmpl/bootstrap/js/datatables.js b/koha-tmpl/opac-tmpl/bootstrap/js/datatables.js index 0fc33651d1..b34dd3cb43 100644 --- a/koha-tmpl/opac-tmpl/bootstrap/js/datatables.js +++ b/koha-tmpl/opac-tmpl/bootstrap/js/datatables.js @@ -1,4 +1,3 @@ -/* global __ */ // These default options are for translation but can be used // for any other datatables settings // To use it, write: @@ -37,9 +36,36 @@ var dataTablesDefaults = { excel: __("Excel"), }, }, - dom: "t", - buttons: ["clearFilter", "copy", "csv", "print"], - paging: false, + dom: '<"dt-info"i><"top pager"<"table_entries"lp><"table_controls"fB>>tr<"bottom pager"ip>', + buttons: [ + { + fade: 100, + className: "dt_button_clear_filter", + titleAttr: __("Clear filter"), + enabled: false, + text: + ' ' + + __("Clear filter") + + "", + available: function (dt) { + // The "clear filter" button is made available if this test returns true + if (dt.settings()[0].aanFeatures.f) { + // aanFeatures.f is null if there is no search form + return true; + } + }, + action: function (e, dt, node) { + dt.search("").draw("page"); + node.addClass("disabled"); + }, + }, + ], + lengthMenu: [ + [10, 20, 50, 100, -1], + [10, 20, 50, 100, __("All")], + ], + pageLength: 20, + fixedHeader: true, initComplete: function (settings) { var tableId = settings.nTable.id; state = settings.oLoadedState; @@ -61,12 +87,175 @@ function toggledClearFilter(searchText, tableId) { } } +// Sorting on html contains +// bar sort on 'bar' +function dt_overwrite_html_sorting_localeCompare() { + jQuery.fn.dataTableExt.oSort["html-asc"] = function (a, b) { + a = a.replace(/<.*?>/g, "").replace(/\s+/g, " "); + b = b.replace(/<.*?>/g, "").replace(/\s+/g, " "); + if (typeof (a.localeCompare == "function")) { + return a.localeCompare(b); + } else { + return a > b ? 1 : a < b ? -1 : 0; + } + }; + + jQuery.fn.dataTableExt.oSort["html-desc"] = function (a, b) { + a = a.replace(/<.*?>/g, "").replace(/\s+/g, " "); + b = b.replace(/<.*?>/g, "").replace(/\s+/g, " "); + if (typeof (b.localeCompare == "function")) { + return b.localeCompare(a); + } else { + return b > a ? 1 : b < a ? -1 : 0; + } + }; + + jQuery.fn.dataTableExt.oSort["num-html-asc"] = function (a, b) { + var x = a.replace(/<.*?>/g, ""); + var y = b.replace(/<.*?>/g, ""); + x = parseFloat(x); + y = parseFloat(y); + return x < y ? -1 : x > y ? 1 : 0; + }; + + jQuery.fn.dataTableExt.oSort["num-html-desc"] = function (a, b) { + var x = a.replace(/<.*?>/g, ""); + var y = b.replace(/<.*?>/g, ""); + x = parseFloat(x); + y = parseFloat(y); + return x < y ? 1 : x > y ? -1 : 0; + }; +} + +$.fn.dataTableExt.oSort["num-html-asc"] = function (a, b) { + var x = a.replace(/<.*?>/g, ""); + var y = b.replace(/<.*?>/g, ""); + x = parseFloat(x); + y = parseFloat(y); + return x < y ? -1 : x > y ? 1 : 0; +}; + +$.fn.dataTableExt.oSort["num-html-desc"] = function (a, b) { + var x = a.replace(/<.*?>/g, ""); + var y = b.replace(/<.*?>/g, ""); + x = parseFloat(x); + y = parseFloat(y); + return x < y ? 1 : x > y ? -1 : 0; +}; + +(function () { + /* + * Natural Sort algorithm for Javascript - Version 0.7 - Released under MIT license + * Author: Jim Palmer (based on chunking idea from Dave Koelle) + * Contributors: Mike Grier (mgrier.com), Clint Priest, Kyle Adams, guillermo + * See: http://js-naturalsort.googlecode.com/svn/trunk/naturalSort.js + */ + function naturalSort(a, b) { + var re = /(^-?[0-9]+(\.?[0-9]*)[df]?e?[0-9]?$|^0x[0-9a-f]+$|[0-9]+)/gi, + sre = /(^[ ]*|[ ]*$)/g, + dre = + /(^([\w ]+,?[\w ]+)?[\w ]+,?[\w ]+\d+:\d+(:\d+)?[\w ]?|^\d{1,4}[\/\-]\d{1,4}[\/\-]\d{1,4}|^\w+, \w+ \d+, \d{4})/, + hre = /^0x[0-9a-f]+$/i, + ore = /^0/, + // convert all to strings and trim() + x = a.toString().replace(sre, "") || "", + y = b.toString().replace(sre, "") || "", + // chunk/tokenize + xN = x + .replace(re, "\0$1\0") + .replace(/\0$/, "") + .replace(/^\0/, "") + .split("\0"), + yN = y + .replace(re, "\0$1\0") + .replace(/\0$/, "") + .replace(/^\0/, "") + .split("\0"), + // numeric, hex or date detection + xD = + parseInt(x.match(hre), 10) || + (xN.length != 1 && x.match(dre) && Date.parse(x)), + yD = + parseInt(y.match(hre), 10) || + (xD && y.match(dre) && Date.parse(y)) || + null; + // first try and sort Hex codes or Dates + if (yD) + if (xD < yD) return -1; + else if (xD > yD) return 1; + // natural sorting through split numeric strings and default strings + for ( + var cLoc = 0, numS = Math.max(xN.length, yN.length); + cLoc < numS; + cLoc++ + ) { + // find floats not starting with '0', string or 0 if not defined (Clint Priest) + var oFxNcL = + (!(xN[cLoc] || "").match(ore) && parseFloat(xN[cLoc])) || + xN[cLoc] || + 0; + var oFyNcL = + (!(yN[cLoc] || "").match(ore) && parseFloat(yN[cLoc])) || + yN[cLoc] || + 0; + // handle numeric vs string comparison - number < string - (Kyle Adams) + if (isNaN(oFxNcL) !== isNaN(oFyNcL)) return isNaN(oFxNcL) ? 1 : -1; + // rely on string comparison if different types - i.e. '02' < 2 != '02' < '2' + else if (typeof oFxNcL !== typeof oFyNcL) { + oFxNcL += ""; + oFyNcL += ""; + } + if (oFxNcL < oFyNcL) return -1; + if (oFxNcL > oFyNcL) return 1; + } + return 0; + } + + jQuery.extend(jQuery.fn.dataTableExt.oSort, { + "natural-asc": function (a, b) { + return naturalSort(a, b); + }, + + "natural-desc": function (a, b) { + return naturalSort(a, b) * -1; + }, + }); +})(); + +/* Plugin to allow sorting on data stored in a span's title attribute + * + * Ex: [% formatted_date %] + * + * In DataTables config: + * "data": [ + * { "type": "title-string" }, + * ] + * http://datatables.net/plug-ins/sorting#hidden_title_string + */ +jQuery.extend(jQuery.fn.dataTableExt.oSort, { + "title-string-pre": function (a) { + var m = a.match(/title="(.*?)"/); + if (null !== m && m.length) { + return m[1].toLowerCase(); + } + return ""; + }, + + "title-string-asc": function (a, b) { + return a < b ? -1 : a > b ? 1 : 0; + }, + + "title-string-desc": function (a, b) { + return a < b ? 1 : a > b ? -1 : 0; + }, +}); + (function () { /* Plugin to allow text sorting to ignore articles * * In DataTables config: - * "aoColumns": [ - * { "sType": "anti-the" }, + * "data": [ + * { "type": "anti-the" }, * ] * Based on the plugin found here: * http://datatables.net/plug-ins/sorting#anti_the @@ -79,7 +268,7 @@ function toggledClearFilter(searchText, tableId) { if (config_exclude_articles_from_sort) { var articles = config_exclude_articles_from_sort.split(" "); var rpattern = ""; - for (var i = 0; i < articles.length; i++) { + for (i = 0; i < articles.length; i++) { rpattern += "^" + articles[i] + " "; if (i < articles.length - 1) { rpattern += "|"; @@ -106,12 +295,521 @@ function toggledClearFilter(searchText, tableId) { }); })(); +// Remove string between NSB NSB characters +$.fn.dataTableExt.oSort["nsb-nse-asc"] = function (a, b) { + var pattern = new RegExp("\x88.*\x89"); + a = a.replace(pattern, ""); + b = b.replace(pattern, ""); + return a > b ? 1 : a < b ? -1 : 0; +}; +$.fn.dataTableExt.oSort["nsb-nse-desc"] = function (a, b) { + var pattern = new RegExp("\x88.*\x89"); + a = a.replace(pattern, ""); + b = b.replace(pattern, ""); + return b > a ? 1 : b < a ? -1 : 0; +}; + +/* Define two custom functions (asc and desc) for basket callnumber sorting */ +jQuery.fn.dataTableExt.oSort["callnumbers-asc"] = function (x, y) { + var x_array = x.split("
"); + var y_array = y.split("
"); + + /* Pop the first elements, they are empty strings */ + x_array.shift(); + y_array.shift(); + + x_array = jQuery.map(x_array, function (a) { + return parse_callnumber(a); + }); + y_array = jQuery.map(y_array, function (a) { + return parse_callnumber(a); + }); + + x_array.sort(); + y_array.sort(); + + x = x_array.shift(); + y = y_array.shift(); + + if (!x) { + x = ""; + } + if (!y) { + y = ""; + } + + return x < y ? -1 : x > y ? 1 : 0; +}; + +jQuery.fn.dataTableExt.oSort["callnumbers-desc"] = function (x, y) { + var x_array = x.split("
"); + var y_array = y.split("
"); + + /* Pop the first elements, they are empty strings */ + x_array.shift(); + y_array.shift(); + + x_array = jQuery.map(x_array, function (a) { + return parse_callnumber(a); + }); + y_array = jQuery.map(y_array, function (a) { + return parse_callnumber(a); + }); + + x_array.sort(); + y_array.sort(); + + x = x_array.pop(); + y = y_array.pop(); + + if (!x) { + x = ""; + } + if (!y) { + y = ""; + } + + return x < y ? 1 : x > y ? -1 : 0; +}; + +function parse_callnumber(html) { + var array = html.split(''); + if (array[1]) { + array = array[1].split(""); + return array[0]; + } else { + return ""; + } +} + +// see http://www.datatables.net/examples/advanced_init/footer_callback.html +function footer_column_sum(api, column_numbers) { + // Remove the formatting to get integer data for summation + var intVal = function (i) { + if (typeof i === "number") { + if (isNaN(i)) return 0; + return i; + } else if (typeof i === "string") { + var value = i.replace(/[a-zA-Z ,.]/g, "") * 1; + if (isNaN(value)) return 0; + return value; + } + return 0; + }; + + for (var indice = 0; indice < column_numbers.length; indice++) { + var column_number = column_numbers[indice]; + + var total = 0; + var cells = api + .column(column_number, { page: "current" }) + .nodes() + .to$() + .find("span.total_amount"); + var budgets_totaled = []; + $(cells).each(function () { + budgets_totaled.push($(this).data("self_id")); + }); + $(cells).each(function () { + if ( + $(this).data("parent_id") && + $.inArray($(this).data("parent_id"), budgets_totaled) > -1 + ) { + return; + } else { + total += intVal($(this).html()); + } + }); + total /= 100; // Hard-coded decimal precision + + // Update footer + $(api.column(column_number).footer()).html(total.format_price()); + } +} + +function filterDataTable(table, column, term) { + if (column) { + table.column(column).search(term).draw("page"); + } else { + table.search(term).draw("page"); + } +} + +jQuery.fn.dataTable.ext.errMode = function (settings, note, message) { + if (settings && settings.jqXHR) { + console.log( + "Got %s (%s)".format( + settings.jqXHR.status, + settings.jqXHR.statusText + ) + ); + alert( + __( + "Something went wrong when loading the table.\n%s: %s. \n%s" + ).format( + settings.jqXHR.status, + settings.jqXHR.statusText, + settings.jqXHR.responseJSON && + settings.jqXHR.responseJSON.errors + ? settings.jqXHR.responseJSON.errors + .map(m => m.message) + .join("\n") + : "" + ) + ); + } else { + alert(__("Something went wrong when loading the table.")); + } + console.log(message); +}; + +function _dt_default_ajax(params) { + let default_filters = params.default_filters; + let options = params.options; + + if ( + !options.criteria || + ["contains", "starts_with", "ends_with", "exact"].indexOf( + options.criteria.toLowerCase() + ) === -1 + ) + options.criteria = "contains"; + options.criteria = options.criteria.toLowerCase(); + + return { + type: "GET", + cache: true, + dataSrc: "data", + beforeSend: function (xhr, settings) { + this._xhr = xhr; + if (options.embed) { + xhr.setRequestHeader( + "x-koha-embed", + Array.isArray(options.embed) + ? options.embed.join(",") + : options.embed + ); + } + }, + dataFilter: function (data, type) { + var json = { data: JSON.parse(data) }; + if ((total = this._xhr.getResponseHeader("x-total-count"))) { + json.recordsTotal = total; + json.recordsFiltered = total; + } + if ((total = this._xhr.getResponseHeader("x-base-total-count"))) { + json.recordsTotal = total; + } + if ((draw = this._xhr.getResponseHeader("x-koha-request-id"))) { + json.draw = draw; + } + + return JSON.stringify(json); + }, + data: function (data, settings) { + var length = data.length; + var start = data.start; + + let api = new $.fn.dataTable.Api(settings); + const global_search = api.search(); + + var dataSet = { + _page: Math.floor(start / length) + 1, + _per_page: length, + }; + + function build_query(col, value) { + var parts = []; + var attributes = col.data.split(":"); + for (var i = 0; i < attributes.length; i++) { + var part = {}; + var attr = attributes[i]; + let default_build = true; + let criteria = options.criteria; + if (value.match(/^\^(.*)\$$/)) { + value = value.replace(/^\^/, "").replace(/\$$/, ""); + criteria = "exact"; + } else { + // escape SQL LIKE special characters % + value = value.replace(/(\%|\\)/g, "\\$1"); + } + + let built_value; + if (col.type == "date") { + let rfc3339 = $date_to_rfc3339(value); + if (rfc3339 != "Invalid Date") { + built_value = rfc3339; + } + } + + if (col.datatype !== undefined) { + default_build = false; + let coded_datatype = + col.datatype.match(/^coded_value:(.*)/); + if (col.datatype == "related-object") { + let query_term = value; + + if (criteria != "exact") { + query_term = { + like: + (["contains", "ends_with"].indexOf( + criteria + ) !== -1 + ? "%" + : "") + + value + + (["contains", "starts_with"].indexOf( + criteria + ) !== -1 + ? "%" + : ""), + }; + } + + part = { + [col.related + "." + col.relatedKey]: + col.relatedValue, + [col.related + "." + col.relatedSearchOn]: + query_term, + }; + } else if ( + coded_datatype && + coded_datatype.length > 1 + ) { + if (global_search.length || value.length) { + coded_datatype = coded_datatype[1]; + const search_value = value.length + ? value + : global_search; + const regex = new RegExp( + `^${search_value}`, + "i" + ); + if ( + coded_values && + coded_values.hasOwnProperty(coded_datatype) + ) { + let codes = [ + ...coded_values[ + coded_datatype + ].entries(), + ] + .filter(([label]) => regex.test(label)) + .map(([, code]) => code); + + if (codes.length) { + part[ + !attr.includes(".") + ? "me." + attr + : attr + ] = codes; + } else { + // Coded value not found using the description, fallback to code + default_build = true; + } + } else { + console.log( + "coded datatype %s not supported yet".format( + coded_datatype + ) + ); + } + } else { + default_build = true; + } + } else { + console.log( + "datatype %s not supported yet".format( + col.datatype + ) + ); + } + } + if (default_build) { + let value_part; + if (criteria === "exact") { + value_part = built_value + ? [value, built_value] + : value; + } else { + let like = { + like: + (["contains", "ends_with"].indexOf( + criteria + ) !== -1 + ? "%" + : "") + + value + + (["contains", "starts_with"].indexOf( + criteria + ) !== -1 + ? "%" + : ""), + }; + value_part = built_value + ? [like, built_value] + : like; + } + + part[!attr.includes(".") ? "me." + attr : attr] = + value_part; + } + + if (Object.keys(part).length) parts.push(part); + } + return parts; + } + + var filter = data.search.value; + // Build query for each column filter + var and_query_parameters = settings.aoColumns + .filter(function (col) { + return ( + col.bSearchable && + typeof col.data == "string" && + data.columns[col.idx].search.value != "" + ); + }) + .map(function (col) { + var value = data.columns[col.idx].search.value; + return build_query(col, value); + }) + .map(function r(e) { + return $.isArray(e) ? $.map(e, r) : e; + }); + + // Build query for the global search filter + var or_query_parameters = settings.aoColumns + .filter(function (col) { + return col.bSearchable && filter != ""; + }) + .map(function (col) { + var value = filter; + return build_query(col, value); + }) + .map(function r(e) { + return $.isArray(e) ? $.map(e, r) : e; + }); + + if (default_filters) { + let additional_filters = {}; + for (f in default_filters) { + let k; + let v; + if (typeof default_filters[f] === "function") { + let val = default_filters[f](); + if (val != undefined && val != "") { + k = f; + v = val; + } + } else { + k = f; + v = default_filters[f]; + } + + // Pass to -or if you want a separate OR clause + // It's not the usual DBIC notation! + if (f == "-or") { + if (v) or_query_parameters.push(v); + } else if (f == "-and") { + if (v) and_query_parameters.push(v); + } else if (v) { + additional_filters[k] = v; + } + } + if (Object.keys(additional_filters).length) { + and_query_parameters.push(additional_filters); + } + } + query_parameters = and_query_parameters; + if (or_query_parameters.length) { + query_parameters.push(or_query_parameters); + } + + if (query_parameters.length) { + query_parameters = JSON.stringify( + query_parameters.length === 1 + ? query_parameters[0] + : { "-and": query_parameters } + ); + dataSet.q = query_parameters; + } + + dataSet._match = options.criteria; + + if (data["draw"] !== undefined) { + settings.ajax.headers = { "x-koha-request-id": data.draw }; + } + + if (options.columns) { + var order = data.order; + var orderArray = new Array(); + order.forEach(function (e, i) { + var order_col = e.column; + var order_by = options.columns[order_col].data; + order_by = order_by.split(":"); + var order_dir = e.dir == "asc" ? "+" : "-"; + Array.prototype.push.apply( + orderArray, + order_by.map( + x => order_dir + (!x.includes(".") ? "me." + x : x) + ) + ); + }); + dataSet._order_by = orderArray + .filter((v, i, a) => a.indexOf(v) === i) + .join(","); + } + + return dataSet; + }, + }; +} + +function build_url_with_state(dt, table_settings) { + let table_key = "DataTables_%s_%s_%s".format( + table_settings.module, + table_settings.page, + table_settings.table + ); + + let state = JSON.stringify(dt.state()); + delete state.time; + let searchParams = new URLSearchParams(window.location.search); + searchParams.set(table_key + "_state", btoa(state)); + + return ( + window.location.origin + + window.location.pathname + + "?" + + searchParams.toString() + + window.location.hash + ); +} + function _dt_buttons(params) { let settings = params.settings || {}; let table_settings = params.table_settings; var exportColumns = ":visible:not(.no-export)"; + const export_format_spreadsheet = { + body: function (data, row, column, node) { + var newnode = $(node); + + if (newnode.find(".no-export").length > 0) { + newnode = newnode.clone(); + newnode.find(".no-export").remove(); + } + let trimmed_str = newnode.text().replace(/\n/g, " ").trim(); + const unsafeCharacters = /^[=+\-@\t\r]/; + if (unsafeCharacters.test(trimmed_str)) { + trimmed_str = "'" + trimmed_str; + } + return trimmed_str; + }, + }; + var export_format = { body: function (data, row, column, node) { var newnode = $(node); @@ -130,14 +828,14 @@ function _dt_buttons(params) { extend: "excelHtml5", exportOptions: { columns: exportColumns, - format: export_format, + format: export_format_spreadsheet, }, }, { extend: "csvHtml5", exportOptions: { columns: exportColumns, - format: export_format, + format: export_format_spreadsheet, }, }, { @@ -156,46 +854,21 @@ function _dt_buttons(params) { }, ]; - let buttons = [ - { - fade: 100, - className: "dt_button_clear_filter", - titleAttr: __("Clear filter"), - enabled: false, - text: - ' ' + - __("Clear filter") + - "", - action: function (e, dt, node, config) { - dt.search("").draw("page"); - node.addClass("disabled"); - }, + let buttons = []; + buttons.push({ + fade: 100, + className: "dt_button_clear_filter", + titleAttr: __("Clear filter"), + enabled: false, + text: + ' ' + + __("Clear filter") + + "", + action: function (e, dt, node, config) { + dt.search("").draw("page"); + node.addClass("disabled"); }, - { - extend: "csvHtml5", - text: __("CSV"), - exportOptions: { - columns: exportColumns, - format: export_format, - }, - }, - { - extend: "copyHtml5", - text: __("Copy"), - exportOptions: { - columns: exportColumns, - format: export_format, - }, - }, - { - extend: "print", - text: __("Print"), - exportOptions: { - columns: exportColumns, - format: export_format, - }, - }, - ]; + }); // Retrieving bKohaColumnsUseNames from the options passed to the constructor, not DT's settings // But ideally should be retrieved using table.data() @@ -223,7 +896,7 @@ function _dt_buttons(params) { className: "columns_controls", titleAttr: __("Columns settings"), text: - ' ' + + ' ' + __("Columns") + "", exportOptions: { @@ -232,6 +905,45 @@ function _dt_buttons(params) { }); } + buttons.push({ + extend: "collection", + autoClose: true, + fade: 100, + className: "export_controls", + titleAttr: __("Export or print"), + text: + ' ' + + __("Export") + + "", + buttons: export_buttons, + }); + + if (table_settings) { + const writeToClipboard = async (text, node) => { + await navigator.clipboard.writeText(text); + $(node) + .tooltip({ trigger: "manual", title: __("Copied!") }) + .tooltip("show"); + }; + buttons.push({ + autoClose: true, + fade: 100, + className: "copyConditions_controls", + titleAttr: __("Copy shareable link"), + text: + ' ' + + __("Copy shareable link") + + "", + action: function (e, dt, node, config) { + const url = build_url_with_state(dt, table_settings); + + if (navigator.clipboard && navigator.clipboard.writeText) { + writeToClipboard(url, node); + } + }, + }); + } + return buttons; } @@ -266,6 +978,329 @@ function _dt_visibility(table_settings, table_dt) { return hidden_ids; } +function _dt_on_visibility(add_filters, table_node, table_dt) { + // FIXME REPLACE ME + if (typeof columnsInit == "function") { + // This function can be created separately and used to trigger + // an event after the DataTable has loaded AND column visibility + // has been updated according to the table's configuration + columnsInit(); + } +} + +function _dt_add_filters(table_node, table_dt, filters_options = {}) { + if (!$(table_node).length) return; + + $(table_node).find("thead tr:eq(1)").remove(); // Remove if one exists already + $(table_node) + .find("thead tr") + .clone() + .appendTo($(table_node).find("thead")); + + let visibility = table_dt.columns().visible(); + let columns = table_dt.settings()[0].aoColumns; + table_dt.columns().every(function () { + var column = this; + let i = column.index(); + var visible_i = table_dt.column.index("fromData", i); + let th = $(table_node).find( + "thead tr:eq(1) th:eq(%s)".format(visible_i) + ); + var is_searchable = columns[i].bSearchable; + $(th) + .removeClass("sorting") + .removeClass("sorting_asc") + .removeClass("sorting_desc"); + if (is_searchable) { + let input_type = "input"; + let existing_search = column.search(); + if ($(th).data("filter") || filters_options.hasOwnProperty(i)) { + input_type = "select"; + let filter_type = $(th).data("filter"); + let select = $( + ''.format( + existing_search + ) + ); + } else { + var search_title = __("%s search").format(title); + $(th).html( + ''.format( + search_title + ) + ); + } + } + } else { + $(th).html(""); + } + }); + _dt_add_delay_filters(table_dt, table_node); + table_dt.fixedHeader.adjust(); +} + +function _dt_add_delay(table_dt, table_node) { + let delay_ms = 500; + + let search = DataTable.util.debounce(function (val) { + table_dt.search(val); + table_dt.draw(); + }, delay_ms); + + $("#" + table_node.attr("id") + "_wrapper") + .find(".dt-input") + .unbind() + .bind("keyup", search(this.value)); +} + +function _dt_add_delay_filters(table_dt, table_node) { + let delay_ms = 500; + + let col_input_search = DataTable.util.debounce(function (i, val) { + table_dt.column(i).search(val).draw(); + }, delay_ms); + let col_select_search = DataTable.util.debounce(function (i, val) { + table_dt.column(i).search(val, true, false).draw(); + }, delay_ms); + + $(table_node) + .find("thead tr:eq(1) th") + .each(function (visible_i) { + var i = table_dt.column.index("fromVisible", visible_i); + $(this) + .find("input") + .unbind() + .bind("keyup change", function (e) { + if (e.keyCode === undefined) return; + col_input_search(i, this.value); + }); + + $(this) + .find("select") + .unbind() + .bind("keyup change", function () { + let value = this.value.length ? "^" + this.value + "$" : ""; + col_select_search(i, this.value); + }); + }); +} + +function _dt_save_restore_state(table_settings, external_filter_nodes = {}) { + let table_key = "DataTables_%s_%s_%s".format( + table_settings.module, + table_settings.page, + table_settings.table + ); + + let default_save_state = table_settings.default_save_state; + let default_save_state_search = table_settings.default_save_state_search; + + let stateSaveCallback = function (settings, data) { + localStorage.setItem(table_key, JSON.stringify(data)); + }; + + function set_default(table_settings, table_dt) { + let columns = new Array(table_dt.columns()[0].length).fill({ + visible: true, + }); + let hidden_ids = _dt_visibility(table_settings, table_dt); + hidden_ids.forEach((id, i) => { + columns[id] = { visible: false }; + }); + // State is not loaded if time is not passed + return { columns, time: new Date() }; + } + let stateLoadCallback = function (settings) { + // Load state from URL + const url = new URL(window.location.href); + let state_from_url = url.searchParams.get(table_key + "_state"); + if (state_from_url) { + $("#" + settings.nTable.id).data("loaded_from_state", true); + return JSON.parse(atob(state_from_url)); + } + + if (!default_save_state) return set_default(table_settings, this.api()); + + let state = localStorage.getItem(table_key); + if (!state) return set_default(table_settings, this.api()); + + state = JSON.parse(state); + + if (default_save_state_search) { + $("#" + settings.nTable.id).data("loaded_from_state", true); + } else { + delete state.search; + state.columns.forEach(c => delete c.search); + } + return state; + }; + + let stateSaveParams = function (settings, data) { + // FIXME Selector in on the whole DOM, we don't know where the filters are + // If others exist on the same page this will lead to unexpected behaviours + // Should be safe so far as: patron search use the same code for the different searches + // but only the main one has the table settings (and so the state saved) + data.external_filters = Object.keys(external_filter_nodes).reduce( + (r, k) => { + let node = $(external_filter_nodes[k]); + let tag_name = node.prop("tagName"); + if (tag_name == "INPUT" && node.prop("type") == "checkbox") { + r[k] = $(external_filter_nodes[k]).prop("checked"); + } else if (tag_name == "INPUT" || tag_name == "SELECT") { + r[k] = $(external_filter_nodes[k]).val(); + } else { + console.log( + "Tag '%s' not supported yet for DT state".format( + tag_name + ) + ); + } + return r; + }, + {} + ); + }; + let stateLoadParams = function (settings, data) { + if (!$("#" + settings.nTable.id).data("loaded_from_state")) return; + + if (data.external_filters) { + Object.keys(external_filter_nodes).forEach((k, i) => { + if (data.external_filters.hasOwnProperty(k)) { + let node = $(external_filter_nodes[k]); + let tag_name = node.prop("tagName"); + let value = data.external_filters[k]; + if ( + tag_name == "INPUT" && + node.prop("type") == "checkbox" + ) { + node.prop("checked", value); + } else if ( + tag_name == "INPUT" && + node.hasClass("flatpickr") + ) { + const fp = document.querySelector( + external_filter_nodes[k] + )._flatpickr; + fp.setDate(value); + } else if (tag_name == "INPUT" || tag_name == "SELECT") { + node.val(value); + } else { + console.log( + "Tag '%s' not supported yet for DT state".format( + tag_name + ) + ); + } + } + }); + } + }; + + return { + stateSave: true, + stateSaveCallback, + stateLoadCallback, + stateSaveParams, + stateLoadParams, + }; +} + +function update_search_description( + table_node, + table_dt, + additional_search_descriptions +) { + let description_node = additional_search_descriptions.node; + let description_fns = + additional_search_descriptions.additional_search_descriptions; + let description = []; + let global_search = table_dt.search(); + if (global_search.length) { + description.push(__("Anywhere: %s").format(global_search)); + } + let additional_searches = []; + for (f in description_fns) { + let d = description_fns[f](); + if (d.length) { + additional_searches.push(d); + } + } + if (additional_searches.length) { + description.push(additional_searches.join(", ")); + } + let column_searches = []; + table_dt + .columns() + .search() + .each((search, i) => { + if (search.length) { + // Retrieve the value from the dropdown list if there is one + var visible_i = table_dt.column.index("fromData", i); + let option = $(table_node).find( + "thead tr:eq(1) th:eq(%s) select option:selected".format( + visible_i + ) + ); + if (option.length) { + search = $(option).text(); + } + + column_searches.push( + "%s=%s".format( + table_dt.column(i).header().innerHTML, + search + ) + ); + } + }); + if (column_searches.length) { + description.push(column_searches.join(", ")); + } + + if (description.length) { + let display = description.length ? "block" : "none"; + let description_str = $(description_node) + .data("prefix") + .format(description.join("
")); + $(description_node).html(description_str).show(); + } else { + $(description_node).html("").hide(); + } +} + (function ($) { /** * Create a new dataTables instance that uses the Koha RESTful API's as a data source @@ -276,11 +1311,33 @@ function _dt_visibility(table_settings, table_dt) { * Supports `contains`, `starts_with`, `ends_with` and `exact` match * @param {string} [options.columns.*.type Data type the field is stored in so we may impose some additional * manipulation to search strings. Supported types are currenlty 'date' + * @param {string} [options.columns.*.datatype Data type the field is stored in so we may impose some additional + * manipulation logic to search. Supported types are currently 'related-object', + * for implimenting joins in api search queries, and 'coded_value:TABLE' to allow + * for clientside translations of description to code to reduce join requirements. + * See bug 39011 for an example implimentation. * @param {Object} table_settings The arrayref as returned by TableSettings.GetTableSettings function * available from the columns_settings template toolkit include + * @param {Boolean} add_filters Add a filters row as the top row of the table + * @param {Object} default_filters Add a set of default search filters to apply at table initialisation + * @param {Object} filters_options Build selects for the filters row, and pass their value. + * eg. { 1 => vendors } will build a select with the vendor list in the second column + * @param {Object} show_search_descriptions Whether or not display the search descriptions when the table is drawn + * Expect a node in which to place the descriptions. It must have a data-prefix attribute to build the description properly. + * @param {Object} aditional_search_descriptions Pass additional descriptions + * eg. { surname => () => { 'Surname with '.format($("#surname_form").val()) } } * @return {Object} The dataTables instance */ - $.fn.kohaTable = function (options = {}, table_settings = undefined) { + $.fn.kohaTable = function ( + options = {}, + table_settings, + add_filters, + default_filters, + filters_options, + external_filter_nodes, + show_search_descriptions, + additional_search_descriptions + ) { // Early return if the node does not exist if (!this.length) return; @@ -293,6 +1350,17 @@ function _dt_visibility(table_settings, table_dt) { dataTablesDefaults.initComplete(settings, json); }; } + + if (options.ajax && !options.bKohaAjaxSVC) { + options.ajax = Object.assign( + {}, + options.ajax, + _dt_default_ajax({ default_filters, options }) + ); + options.serverSide = true; + options.processing = true; + options.pagingType = "full_numbers"; + } } var settings = $.extend( @@ -300,6 +1368,7 @@ function _dt_visibility(table_settings, table_dt) { {}, dataTablesDefaults, { + paging: true, searching: true, language: { emptyTable: options.emptyTable @@ -312,7 +1381,17 @@ function _dt_visibility(table_settings, table_dt) { settings["buttons"] = _dt_buttons({ settings, table_settings }); + if (add_filters) { + settings["orderCellsTop"] = true; + } + if (table_settings) { + let state_settings = _dt_save_restore_state( + table_settings, + external_filter_nodes + ); + settings = { ...settings, ...state_settings }; + if ( table_settings.hasOwnProperty("default_display_length") && table_settings["default_display_length"] != null @@ -343,9 +1422,19 @@ function _dt_visibility(table_settings, table_dt) { } $(this).data("bKohaColumnsUseNames", settings.bKohaColumnsUseNames); + $(this).data("bKohaAjaxSVC", settings.bKohaAjaxSVC); // Must not be used for new tables! var table = $(this).dataTable(settings); var table_dt = table.DataTable(); + if (add_filters) { + _dt_add_filters(this, table_dt, filters_options); + } + + table_dt.on("column-visibility.dt", function () { + if (add_filters) { + _dt_add_filters(this, table_dt, filters_options); + } + }); table_dt.on("search.dt", function (e, settings) { // When the DataTables search function is triggered, @@ -354,18 +1443,18 @@ function _dt_visibility(table_settings, table_dt) { toggledClearFilter(table_dt.search(), settings.nTable.id); }); - let hidden_ids = _dt_visibility(table_settings, table_dt); - table_dt - .on("column-visibility.dt", function () { - if (typeof columnsInit == "function") { - // This function can be created separately and used to trigger - // an event after the DataTable has loaded AND column visibility - // has been updated according to the table's configuration - columnsInit(); - } - }) - .columns(hidden_ids) - .visible(false); + if (show_search_descriptions !== undefined) { + table_dt.on("draw", function (e, settings) { + update_search_description(table, table_dt, { + node: show_search_descriptions, + additional_search_descriptions, + }); + }); + update_search_description(table, table_dt, { + node: show_search_descriptions, + additional_search_descriptions, + }); + } return table; };