/** * @template TRow A type that the shape of a row should be. E.g. * ``` * type User = { * id: number; * name: string; * } * ``` * Then `User` would be passed as the generic. */ export abstract class Table { table: HTMLTableElement; tableBody: HTMLTableSectionElement; headers: Header[]; messageEl: HTMLElement | null; trueSymbol: string; falseSymbol: string; /** * Constructs a table based on the parameters given. * @param exampleObject An instance of the type to be displaying. Can be an object with default/zero values for that type * such as `0`, `""`, `false` or `true`. E.g. for a user ```{ id: 0; name: ""; }``` would be passed. * @param displayNamesAndOrder A object with the same properties, but all string type for the header cell values. * Used by passing an object with the keys of {@link TRow} and the name preferred. E.g. for a user: * ```{ id: "ID", name: "Name" }```, this makes the columns appear in the order of ID then Name. * @param messageElement Used to display messages related to data output. Optional. * Omit (undefined) to use the class provided HTMLParagraphElement. * Pass an HTMLElement to use a custom element. * Pass null to disable automatic message output. * @param trueSymbol The symbol used for boolean cells that are true. Optional, default "✔". * @param falseSymbol The symbol used for boolean cells that are false. Optional, default "✖". */ constructor(exampleObject: TRow, displayNamesAndOrder?: Record, messageElement?: HTMLElement | undefined | null, trueSymbol: string = "✔", falseSymbol: string = "✖") { this.table = document.createElement("table") this.tableBody = document.createElement("tbody"); this.headers = this.createHeadersFromType(exampleObject, displayNamesAndOrder); this.setHeaderRow(); this.table.appendChild(this.tableBody); // If null it means disabled this.messageEl = messageElement === undefined ? document.createElement("p") : messageElement; this.trueSymbol = trueSymbol; this.falseSymbol = falseSymbol; } /** * Creates a list of {@link Header} objects to be used in a table for the header cell values, column types, and header/column order (if provided, otherwise inferred). * @param exampleObject An example object of TRow, e.g. User = { id: 0, name: "Name"}. Used to store the types for each column. * @param displayNamesAndOrder The order and names of the columns on the table. * e.g. User = { fullname: "Full Name", id: "User ID" }, the headers would be "Full Name" and "User ID" in column order and headers, * @return An array of {@link Header} type shape that holds the header/column info. */ createHeadersFromType( exampleObject: TRow, // Needed for runtime, since types don't exist in JS. displayNamesAndOrder?: Record ): Header[] { // Checks if display names and order was passed. If not, derives the keys to use for the header from the example object. const keys = displayNamesAndOrder ? (Object.keys(displayNamesAndOrder) as (keyof TRow)[]) : (Object.keys(exampleObject) as (keyof TRow)[]); return keys.map(key => ({ key, // Raw property name from TRow value: displayNamesAndOrder?.[key] ?? String(key), // The value of the th html element, with a nullish coalescing operator for if its null type: typeof exampleObject[key], // The type of the column })); } /** * Sets the header values on the header row from the {@link headers} object on this object. */ setHeaderRow() { const thead = document.createElement("thead"); const headerRow = document.createElement("tr"); for (const header of this.headers) { const th = document.createElement("th") th.textContent = header.value; headerRow.appendChild(th); } thead.appendChild(headerRow); this.table.appendChild(thead); } /** * Returns the table element of this instance for putting onto a page. */ getTable(): HTMLTableElement { return this.table; } /** * Appends one more row to the current rows. * @param data Data to append as a row. */ appendRow(data: TRow): void { const row = this.createPopulatedRow(data) this.tableBody.appendChild(row) } /** * Appends multiple rows to the table, at the end off the table. * @param data The rows to append to the table, in shape of {@link TRow}. */ appendRows(data: TRow[]): void { if (data.length === 0) { this.setMessage("No data to append. Empty list provided.") return; } for (const element of data) { this.appendRow(element) } } /** * Creates a row, populated with data, for displaying in the table. * @param data The data in the shape of `R`, with values populated. * @return A HTMLTableRowElement that can then be set on a table. */ createPopulatedRow(data: TRow): HTMLTableRowElement { const row = document.createElement("tr"); for (const header of this.headers) { const cell: HTMLTableCellElement = document.createElement("td"); const value = data[header.key] let cellValue = ""; switch (header.type) { case "boolean": cell.classList.add("table-cell-boolean"); cellValue = value ? this.trueSymbol : this.falseSymbol; cell.classList.add(value ? "table-cell-boolean-true" : "table-cell-boolean-false"); break; case "number": cellValue = String(value); cell.classList.add("table-cell-number"); break; default: cellValue = value != null ? String(value) : ""; } cell.textContent = cellValue; row.append(cell); } return row; } /** * Deletes all current rows. */ clearRows(): void { while (this.tableBody.firstChild) { this.tableBody.removeChild(this.tableBody.firstChild) } } /** * Deletes all current rows and sets a single row to the data in data param. * @param data The data to set the table to. Should be of shape {@link TRow} */ setSingleRow(data: TRow): void { this.clearRows() this.appendRow(data) } /** * Deletes all current rows and sets the rows to the data in data param. * @param data The data to set the table to. Should be of shape {@link TRow} */ setRows(data: TRow[]): void { this.clearRows() if (data.length === 0) { this.setMessage("No data to show. Empty list provided.") } this.appendRows(data) } /** * Get the message element for the table. * @return The HTMLElement thats used for showing messaged. Or null if its not set. */ getMessageElement(): HTMLElement | null { return this.messageEl; } /** * Clears the {@link messageEl}.textContent value to "". If the message element is not set it does nothing. */ clearMessage() { if (!this.messageEl) return; this.messageEl.textContent = "" } /** * Appends a message to the alread existing table message, if the message element is set. Works even if current message is empty (""). * @param message The message to append to the current message. */ appendMessage(message: string) { if (!this.messageEl) return; this.messageEl.textContent = `${this.messageEl.textContent} ${message}` } /** * Sets the message, overwriting what it was before. Id the message element is not set, it does nothing. * @param message The message to set the message element to. */ setMessage(message: string) { if (!this.messageEl) return; this.clearMessage(); this.appendMessage(message); } /** * Change the symbols for boolean cells on the table. * @param trueSymbol What the true symbol should be. * @param falseSymbol What the false symbol should be. * @param updateExisting Whether to update the existing cells. Default to true. */ changeBooleanSymbols(trueSymbol: string, falseSymbol: string, updateExisting: boolean = true) { this.trueSymbol = trueSymbol; this.falseSymbol = falseSymbol; // Change the symbol on already rendered boolean cells. if (updateExisting) { this.tableBody.querySelectorAll(".table-cell-boolean-true") .forEach(c => c.textContent = trueSymbol); this.tableBody.querySelectorAll(".table-cell-boolean-false") .forEach(c => c.textContent = falseSymbol); } } } type Header = { key: keyof TRow; // The name of the field. value: string; // The name of the header cell type: string; // The type of the field, can be string since typesof are returned as strings. }