import * as JsSearch from "js-search"; interface GridProps { id?: string; debug?: boolean; data: Array; columns: Array; pageSize?: number; search?: SearchConfiguration; } interface GridConfiguration { id: string; debug: boolean; data: Array; columns: Array; pageSize: number; search: SearchConfiguration; } interface SearchConfiguration { dataIds: Array; } interface GridColumn { dataId?: string; columnName: string | Function; cellValue?: string | Function | Element; width?: string; maxWidth?: string; minWidth?: string; } export default class Grid { private readonly canRender: boolean; private readonly defaultOptions: GridConfiguration = { id: "id", debug: true, columns: [], data: [], pageSize: 0, search: { dataIds: [], }, }; private configuration: GridConfiguration; private domElement: Element; public currentPage: number = 0; private searchIndex: JsSearch.Search; constructor(props: GridProps) { this.setOptions(props); this.validateOptions(); this.canRender = true; } private setOptions(props: GridProps = this.defaultOptions): void { this.configuration = {} as GridConfiguration; this.configuration.columns = []; for (const column of props.columns) { if (isGridColumn(column)) { this.configuration.columns.push(column); } else { this.log("column is not of type GridColumn: " + JSON.stringify(column)); } } this.configuration.data = props.data ?? this.defaultOptions.data; this.configuration.id = props.id ?? this.defaultOptions.id; this.configuration.debug = props.debug ?? this.defaultOptions.debug; this.configuration.pageSize = props.pageSize ?? this.defaultOptions.pageSize; this.configuration.search = props.search ?? this.defaultOptions.search; } private validateOptions(): void { if (this.configuration.data.length === undefined) { throw new GridError("props.data.length is undefined"); } if (this.configuration.columns.length === undefined || this.configuration.columns.length <= 0) { throw new GridError("props.columns.length is undefined or <= 0"); } } private renderCurrentPageIndicator(): void { if (this.configuration.pageSize <= 0) return; this.domElement.querySelectorAll(".pagination .page-item").forEach((el) => { if (el.getAttribute("data-page-number") == this.currentPage.toString()) el.classList.add("active"); else el.classList.remove("active"); }); } private renderPaginator(): void { if (this.configuration.pageSize === 0) return; const nav = document.createElement("nav"); nav.className = "float-right user-select-none"; const ul = document.createElement("ul"); ul.className = "pagination"; const previousItem = document.createElement("li"); previousItem.className = "page-item"; previousItem.onclick = () => this.navigate(this.currentPage - 1); const previousLink = document.createElement("span"); previousLink.style.cursor = "pointer"; previousLink.className = "page-link"; previousLink.innerText = "Forrige"; previousItem.appendChild(previousLink); ul.appendChild(previousItem); for (let i = 0; i < this.configuration.data.length / this.configuration.pageSize; i++) { const item = document.createElement("li"); item.className = "page-item"; item.dataset.pageNumber = i.toString(); item.onclick = () => this.navigate(i); const link = document.createElement("span"); link.className = "page-link"; link.style.cursor = "pointer"; link.innerText = (i + 1).toString(); item.appendChild(link); ul.appendChild(item); } const nextItem = document.createElement("li"); nextItem.className = "page-item"; nextItem.onclick = () => this.navigate(this.currentPage + 1); const nextLink = document.createElement("span"); nextLink.style.cursor = "pointer"; nextLink.className = "page-link"; nextLink.innerText = "Neste"; nextItem.appendChild(nextLink); ul.appendChild(nextItem); nav.appendChild(ul); this.domElement.appendChild(nav); } private renderWrapper(): void { const wrapper = document.createElement("table"); const thead = document.createElement("thead"); const tbody = document.createElement("tbody"); wrapper.className = "table"; wrapper.appendChild(thead); wrapper.appendChild(tbody); this.domElement.appendChild(wrapper); } private renderHead(): void { const wrapper = this.domElement.querySelector("table thead"); wrapper.innerHTML = ""; const row = document.createElement("tr"); for (const col of this.configuration.columns) { const th = document.createElement("th"); th.innerText = this.asString(col.columnName); row.appendChild(th); } wrapper.appendChild(row); } private renderBody(data: Array = null, isSearchResult: boolean = false): void { let wrapper = this.domElement.querySelector("table tbody"); if (isSearchResult) { this.domElement.querySelector("table tbody:not(.search-results)").classList.add("d-none"); this.domElement.querySelector("table tbody.search-results").classList.remove("d-none"); wrapper = this.domElement.querySelector("table tbody.search-results"); } else { this.domElement.querySelector("table tbody.search-results")?.classList.add("d-none"); this.domElement.querySelector("table tbody:not(.search-results)").classList.remove("d-none"); wrapper = this.domElement.querySelector("table tbody:not(.search-results)"); } wrapper.innerHTML = ""; let items = data ?? this.configuration.data; if (this.configuration.pageSize !== 0) { items = items.slice(0, this.configuration.pageSize); } for (const item of items) { const row = document.createElement("tr"); row.dataset.id = item[this.configuration.id]; for (const val of this.configuration.columns) { const cell = document.createElement("td"); if (val.width) cell.style.width = val.width; if (val.maxWidth) cell.style.maxWidth = val.maxWidth; if (val.minWidth) cell.style.minWidth = val.minWidth; if (typeof val.dataId === "string" && val.dataId.length > 0) { val.cellValue = item[val.dataId]; } cell.className = "text-break"; if (typeof val.cellValue === "string" || typeof val.cellValue === "number") { cell.innerText = val.cellValue; } else if (val.cellValue instanceof Element) { cell.appendChild(val.cellValue); } else if (val.cellValue instanceof Function) { const computed = val.cellValue(item); if (computed instanceof Element) cell.appendChild(computed); else if (typeof computed === "string" || typeof computed === "number") cell.innerText = computed as string; } row.appendChild(cell); } wrapper.appendChild(row); } } private asString(val: string | Function, callbackData: any = undefined) { if (val instanceof Function) { return val(callbackData); } else { return val; } } private static id(): string { return Math.random().toString(36).substring(2) + Date.now().toString(36); } private log(message: any): void { if (!this.configuration.debug) return; console.log("Grid Debug: " + message); } private renderSearch() { if (this.configuration.search.dataIds.length < 1) return; const wrapper = document.createElement("div"); const searchInput = document.createElement("input"); searchInput.type = "text"; searchInput.className = "form-control"; searchInput.placeholder = "Søk"; searchInput.oninput = () => this.search(searchInput.value); wrapper.appendChild(searchInput); this.domElement.appendChild(wrapper); } private renderSearchResultWrapper() { if (this.configuration.search.dataIds.length < 1) return; const searchWrapper = document.createElement("tbody"); searchWrapper.className = "search-results"; this.domElement.querySelector("table").appendChild(searchWrapper); } private populateSearchIndex() { if (this.configuration.search.dataIds.length < 1) return; this.searchIndex = new JsSearch.Search("id"); this.searchIndex.indexStrategy = new JsSearch.ExactWordIndexStrategy(); this.configuration.search.dataIds.forEach((id) => this.searchIndex.addIndex(id)); this.searchIndex.addDocuments(this.configuration.data); console.log(this.searchIndex); } public search(query: string): void { if (this.configuration.search.dataIds.length < 1) return; let result = this.searchIndex.search(query); if (result.length === 0) { this.renderBody(this.configuration.data); } else { this.renderBody(result, true); } } public navigate(pageNumber): void { const maxPage = Math.ceil(this.configuration.data.length / this.configuration.pageSize - 1); if (this.configuration.pageSize <= 0 || pageNumber < 0 || pageNumber === this.currentPage || pageNumber > maxPage) return; this.log("Navigating to page: " + pageNumber); const skipCount = this.configuration.pageSize * pageNumber; const endIndex = this.configuration.data.length < skipCount + this.configuration.pageSize ? this.configuration.data.length : skipCount + this.configuration.pageSize; this.renderBody(this.configuration.data.slice(skipCount, endIndex)); this.currentPage = pageNumber; this.renderCurrentPageIndicator(); } public removeByID(id: string): void { const itemIndex = this.configuration.data.findIndex((c) => c[this.configuration.id] === id); if (itemIndex !== -1) { delete this.configuration.data[itemIndex]; this.domElement.querySelector(`tr[data-id="${id}"]`).remove(); } else { this.log("Grid does not contain id: " + id); } } public refreshData(data?: Array) { this.renderPaginator(); this.navigate(0); this.renderBody(data); } public render(el: Element): void { if (this.canRender) { this.log("Grid starting render"); this.domElement = el; this.renderSearch(); this.populateSearchIndex(); this.renderWrapper(); this.renderHead(); this.renderBody(); this.renderPaginator(); this.renderCurrentPageIndicator(); this.renderSearchResultWrapper(); this.log("Grid was rendered"); } else { throw new GridError("render is not allowed due to invalid props"); } } } class GridError extends Error { constructor(message: string) { super(message); this.name = "GridError"; } } function isArrayOfGridColumn(array: Array | Array): array is Array { try { // @ts-ignore return !(array.map((element) => "name" in element).indexOf(false) !== -1); } catch (e) { return false; } } function isGridColumn(val: GridColumn | string): val is GridColumn { try { // @ts-ignore return "columnName" in val && ("cellValue" in val || "dataId" in val); } catch (e) { return false; } }