diff --git a/data-table.js b/data-table.js index 63bc485..7c4bf8e 100644 --- a/data-table.js +++ b/data-table.js @@ -1,31 +1,147 @@ -import { IgniteHtml, IgniteProperty } from "../ignite-html/ignite-html.js"; +import { IgniteHtml, IgniteObject, IgniteProperty } from "../ignite-html/ignite-html.js"; import { IgniteElement } from "../ignite-html/ignite-element.js"; -import { IgniteTemplate, div, table, thead, tbody, tr, th, td, list, pagination, pager, population, nav, ul, li, button, span, input, select, option } from "../ignite-html/ignite-template.js"; +import { IgniteTemplate, div, table, thead, tbody, tr, th, td, list, pagination, pager, population, nav, ul, li, button, span, input, select, option, i } from "../ignite-html/ignite-template.js"; -class DataColumn { +/** + * The outline of a column apart of a DataTable. + */ +class DataColumn extends IgniteObject { /** * The name of the column, this can contain html. * @type {String|Any} * */ - name; + name = null; + + /** + * A selector to extract the value from the data for this column. + * @type {IgniteProperty|Function} + */ + selector = null; + + /** + * A converter to convert the value of this column to a custom component if needed. + * @type {IgniteProperty|Function} + */ + converter = null; + + /** + * A custom comparer used for sorted, default is null. + * @type {IgniteProperty|Function} + */ + comparer = null; + + /** + * A custom function used for searching, default is null. + * @type {IgniteProperty|Function} + */ + searcher = null; /** * Whether or not this column can be searched. * @type {Boolean} */ - search; + searchable = false; /** - * - * @param {String} name The name of the column to display - * @param {Boolean} search Whether or not this column is searchable. + * Whether or not this column can be sorted. + * @type {Boolean} */ - constructor(name, search) { + sortable = false; + + /** + * The current sorting of this column if any, default is null. Possible values are asc, desc, null. + * @type {String} + */ + sort = null; + + /** + * Creates a new DataColumn with a name and options. + * @param {String} name The name of the column to display + * @param {Object} options The options for this data column. + * @param {Boolean|Function} options.searchable Whether or not this column can be searched. Default is false. + * @param {String|Function} options.sortable Whether or not this column can be sorted. Default is false. + * @param {String} options.sort The current sorting of this column. Possible values: asc, desc. Default is null. + * @param {IgniteProperty|Function} options.selector A function to extract the column value from a given data. Default is null. + * @param {IgniteProperty|Function} options.converter A function to convert the column value to a custom component. Default is null. + * @param {IgniteProperty|Function} options.comparer A function to compare two columns for sorting. Default is null. + * @param {IgniteProperty|Function} options.searcher A function to seatch a column. Default is null. + */ + constructor(name, options = null) { + super(); + this.name = name; - this.search = search; + + if (options) { + this.searchable = options.searchable ?? this.searchable; + this.sortable = options.sortable ?? this.sortable; + this.sort = options.sort ?? this.sort; + this.selector = options.selector ?? this.selector; + this.converter = options.converter ?? this.converter; + this.comparer = options.comparer ?? this.comparer; + this.searcher = options.searcher ?? this.searcher; + + if (this.sort == "asc" || this.sort == "desc") { + this.sortable = true; + } + } + + this.update(); } } +/** + * The outline of a row apart of the DataTable. + */ +class DataRow { + /** + * The raw values of this row. + * @type {Array} + */ + values; + + /** + * The columns of this row that will be rendered. + * @type {Array} + */ + columns; + + /** + * Creates a new DataRow from data a set of columns. + * @param {Any} data + * @param {Array { + var value = data; + + if (column.selector && column.selector instanceof IgniteProperty) { + value = column.selector.value(data); + } else if (column.selector && column.selector instanceof Function) { + value = column.selector(data); + } + + var converted = value; + + if (column.converter && column.converter instanceof IgniteProperty) { + converted = column.converter.value(value); + } else if (column.converter && column.converter instanceof Function) { + converted = column.converter(value); + } + + this.values.push(value); + this.columns.push(converted); + }); + } +} + +/** + * A data table component that can render rows and columns and handle + * searching and sorting along with pagination of a data set. + */ class DataTable extends IgniteElement { constructor() { super(); @@ -33,11 +149,17 @@ class DataTable extends IgniteElement { get styles() { return /*css*/` - bt-data-table table thead th:first-child { + /* Prevent selecting of column names */ + bt-data-table table thead th { + user-select: none; + } + + /* Round the corners of the table */ + bt-data-table table thead tr:last-child th:first-child { border-top-left-radius: var(--bs-border-radius); } - bt-data-table table thead th:last-child { + bt-data-table table thead tr:last-child th:last-child { border-top-right-radius: var(--bs-border-radius); } @@ -54,53 +176,50 @@ class DataTable extends IgniteElement { get properties() { return { columns: [], - items: new IgniteProperty([], { - onChange: (oldValue, newValue) => { - if (newValue) { - this.rows = newValue.map(row => this.converter instanceof IgniteProperty ? this.converter.value(row) : this.converter(row)); - } else { - this.rows = []; - } - }, + data: new IgniteProperty([], { + onChange: (oldValue, newValue) => this.rows = (newValue ? newValue.map(row => new DataRow(row, this.columns)) : []), onPop: () => this.rows.pop(), - onPush: (array, items) => this.rows.push(...items.map(row => this.converter instanceof IgniteProperty ? this.converter.value(row) : this.converter(row))), + onPush: (array, data) => this.rows.push(...data.map(row => new DataRow(row, this.columns))), onShift: () => this.rows.shift(), - onSplice: (array, start, deleteCount, items) => this.rows.splice(start, deleteCount, ...items.map(row => this.converter instanceof IgniteProperty ? this.converter.value(row) : this.converter(row))), - onUnshift: (array, items) => this.rows.unshift(...items.map(row => this.converter instanceof IgniteProperty ? this.converter.value(row) : this.converter(row))) + onSplice: (array, start, deleteCount, data) => this.rows.splice(start, deleteCount, ...data.map(row => new DataRow(row, this.columns))), + onUnshift: (array, data) => this.rows.unshift(...data.map(row => new DataRow(row, this.columns))) }), rows: new IgniteProperty([], { - onChange: (oldValue, newValue) => this.filter(), + onChange: () => this.filter(), onPop: () => this.filter(), onPush: () => this.filter(), onShift: () => this.filter(), onSplice: () => this.filter(), onUnshift: () => this.filter() }), - results: new IgniteProperty([], { - onChange: (oldValue, newValue) => this.pageCount = newValue && this.pageSize > 0 ? Math.ceil(this.results.length / this.pageSize) : 0, - onPop: () => this.pageCount = this.results && this.pageSize > 0 ? Math.ceil(this.results.length / this.pageSize) : 0, - onPush: () => this.pageCount = this.results && this.pageSize > 0 ? Math.ceil(this.results.length / this.pageSize) : 0, - onShift: () => this.pageCount = this.results && this.pageSize > 0 ? Math.ceil(this.results.length / this.pageSize) : 0, - onSplice: () => this.pageCount = this.results && this.pageSize > 0 ? Math.ceil(this.results.length / this.pageSize) : 0, - onUnshift: () => this.pageCount = this.results && this.pageSize > 0 ? Math.ceil(this.results.length / this.pageSize) : 0 + filtered: new IgniteProperty([], { + onChange: () => this.pageCount = this.filtered && this.pageSize > 0 ? Math.ceil(this.filtered.length / this.pageSize) : 0, + onPop: () => this.pageCount = this.filtered && this.pageSize > 0 ? Math.ceil(this.filtered.length / this.pageSize) : 0, + onPush: () => this.pageCount = this.filtered && this.pageSize > 0 ? Math.ceil(this.filtered.length / this.pageSize) : 0, + onShift: () => this.pageCount = this.filtered && this.pageSize > 0 ? Math.ceil(this.filtered.length / this.pageSize) : 0, + onSplice: () => this.pageCount = this.filtered && this.pageSize > 0 ? Math.ceil(this.filtered.length / this.pageSize) : 0, + onUnshift: () => this.pageCount = this.filtered && this.pageSize > 0 ? Math.ceil(this.filtered.length / this.pageSize) : 0 }), filterSearch: null, - converter: null, pageSizeOptions: [15, 25, 50, 100, 500], - pageSize: new IgniteProperty(15, { onChange: (oldValue, newValue) => { - //Calculate the new page count based on the page size and items. - if (!this.items || newValue < 1) { - this.pageCount = 0; - } else { - this.pageCount = Math.ceil(this.items.length / newValue); + pageSize: new IgniteProperty(15, { + onChange: (oldValue, newValue) => { + //Calculate the new page count based on the page size and data. + if (!this.data || newValue < 1) { + this.pageCount = 0; + } else { + this.pageCount = Math.ceil(this.data.length / newValue); + } } - }}), - pageCount: new IgniteProperty(0, { onChange: (oldValue, newValue) => { - //If the currentPage is greater than the new page count, set it to the last page. - if (this.currentPage > newValue - 1) { - this.currentPage = newValue - 1; + }), + pageCount: new IgniteProperty(0, { + onChange: (oldValue, newValue) => { + //If the currentPage is greater than the new page count, set it to the last page. + if (this.currentPage > newValue - 1) { + this.currentPage = newValue - 1; + } } - }}), + }), currentPage: 0, searching: false, pendingSearch: null, @@ -126,7 +245,7 @@ class DataTable extends IgniteElement { ), //Row count - new span().show(this.showRowCount).class("bg-white p-2 rounded-2 flex-grow-1 flex-sm-grow-0").innerHTML(this.results, results => results.length == 0 ? "No rows" : `${results.length} ${(results.length < 2 ? "row" : "rows")}`) + new span().show(this.showRowCount).class("bg-white p-2 rounded-2 flex-grow-1 flex-sm-grow-0").innerHTML(this.filtered, filtered => filtered.length == 0 ? "No rows" : `${filtered.length} ${(filtered.length < 2 ? "row" : "rows")}`) ), //Search box @@ -146,26 +265,38 @@ class DataTable extends IgniteElement { ) ) ), - + //Rows new div().class("mb-3").child( new table().class("table table-striped align-middle mb-0 table-borderless").child( //Table columns new thead().class("table-dark").child( new tr().child( - new list(this.columns, column => { - if (column instanceof DataColumn) { - return new th().class("text-nowrap").attribute("scope", "col").child(column.name); - } else { - return new th().class("text-nowrap").attribute("scope", "col").child(column); - } + new list(this.columns, column => { + return new th().class("text-nowrap").class(column.sortable, sortable => sortable ? "cursor-pointer" : null).attribute("scope", "col").child( + new i().show([column.sortable, column.sort], (sortable, sort) => sortable && sort).class("me-2").class(column.sort, sort => sort ? (sort == "asc" ? "fa-solid fa-angle-up" : "fa-solid fa-angle-down") : null), + column.name + ).onClick(() => { + if (column.sortable) { + if (column.sort == "asc") { + column.sort = "desc"; + this.filter(); + } else if (column.sort == "desc") { + column.sort = null; + this.filter(); + } else if (!column.sort) { + column.sort = "asc"; + this.filter(); + } + } + }); }) ) ), //Table rows new tbody().child( - new pagination(this.results, this.pageSize, this.currentPage, row => new tr().child(new list(row, column => new td().child(column)))) + new pagination(this.filtered, this.pageSize, this.currentPage, row => new tr().child(new list(row.columns, column => new td().child(column)))) ) ) ), @@ -185,7 +316,7 @@ class DataTable extends IgniteElement { }), new pager( - this.results, + this.filtered, this.pageSize, this.currentPage, 2, @@ -193,6 +324,7 @@ class DataTable extends IgniteElement { if (filler) { return null; } + return new button() .class("btn text-center flex-grow-1") .class(current, current => current ? "btn-primary" : "btn-secondary") @@ -217,7 +349,7 @@ class DataTable extends IgniteElement { new div().class("input-group flex-nowrap w-auto flex-grow-1 flex-sm-grow-0").child( new select().class("form-select border-0 w-auto").child( new population( - this.pageCount, + this.pageCount, index => new option().attribute("selected", this.currentPage, currentPage => currentPage == index ? "selected" : null).value(index).innerText(`Page ${index + 1}`) ) ).value(this.currentPage, true) @@ -233,7 +365,7 @@ class DataTable extends IgniteElement { if (!this.pendingSearch) { this.pendingSearch = setTimeout(async () => { this.pendingSearch = null; - + this.filter(); this.searching = false; @@ -242,17 +374,18 @@ class DataTable extends IgniteElement { } filter() { - var results = this.rows; + var filtered = []; + + filtered = filtered.concat(this.rows); //First filter the results by the search query if we have one. if (this.filterSearch && this.filterSearch != "" && this.filterSearch != " ") { var regex = new RegExp(this.filterSearch, 'i'); - results = results.filter(result => { + filtered = filtered.filter((/** @type {DataRow} **/ row) => { var found = false; - for (var i = 0; i < this.columns.length; i++) { - if (this.columns[i] instanceof DataColumn && this.columns[i].search && result[i].match(regex)) { + if (this.columns[i].searchable && row.values[i].match(regex)) { found = true; break; } @@ -262,24 +395,44 @@ class DataTable extends IgniteElement { }); } - //Set the results - this.results = results; + //Apply any sorting if needed + filtered.sort((/** @type {DataRow} **/ a, /** @type {DataRow} **/ b) => { + for (var i = 0; i < this.columns.length; i++) { + if (this.columns[i].sortable) { + if (this.columns[i].sort == "asc") { + if (a.values[i] > b.values[i]) { + return 1; + } else if (a.values[i] < b.values[i]) { + return -1; + } + } else if (this.columns[i].sort == "desc") { + if (a.values[i] > b.values[i]) { + return -1; + } else if (a.values[i] < b.values[i]) { + return 11; + } + } + } + } + + return 0; + }); + + this.filtered = filtered; } } class Template extends IgniteTemplate { /** - * - * @param {string|Array} columns The columns of this table - * @param {Array} items The items of this table - * @param {Function|IgniteProperty} converter A converter that converts a row into a series of columns. + * Creates a new data table with the columns and data to display. + * @param {Array} columns The columns of this table + * @param {Array} data The data of this table */ - constructor(columns, items, converter) { + constructor(columns, data, converter) { super("bt-data-table", null); this.property("columns", columns); - this.property("items", items); - this.property("converter", converter); + this.property("data", data); } }