diff --git a/.gitignore b/.gitignore index 0872c36c..39ddd198 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,12 @@ /node_modules app.js app.js.map +controllers.js +controllers.js.map +data.js +data.js.map +model.js +model.js.map +views.js +views.js.map +.vscode/settings.json diff --git a/app.ts b/app.ts new file mode 100644 index 00000000..7c168f79 --- /dev/null +++ b/app.ts @@ -0,0 +1,48 @@ +/** + * Project Overview - MVC Architecture: + * + * This project adheres to the Data-> Model-View-Controller (MVC) design pattern, ensuring a clear separation of concerns: + * + 1. **Data Source**: + * - The foundational source from which the application retrieves its raw data. This is + * API endpoints. + * + * 2. **Model** (`StateManager`): + * - Manages the state and data of the application. + * - Interfaces with the data source to handle data retrieval, filtering, pagination, and other data-related operations. + * + * 3. **View** (`TableRenderer`): + * - Responsible for rendering and updating the user interface. + * - Renders column headers, records, and manages the visual representation. + * + * 4. **Controllers**: + * - **WindowResizeHandler**: Detects window resize events and updates the table view accordingly. + * - **PaginationManager**: Manages page navigation, searching functionalities, and live input validation. + * + */ + +/*** Main Script ***/ + +window.onload = async () => { + // Initialize data.ts + const apiManager = new ApiManager(); + + // Initialize model.ts + const stateManager = new StateManager(apiManager); + await stateManager.initializeState(); + + // Initialize views.ts + const tableRenderer = new TableRenderer(stateManager); + await tableRenderer.initialRender(); + + // Initialize controllers.ts + const paginationManager = new PaginationManager( + tableRenderer, + stateManager + ); + const windowResizeHandler = new WindowResizeHandler( + tableRenderer, + stateManager, + paginationManager + ); +}; diff --git a/controllers.ts b/controllers.ts new file mode 100644 index 00000000..1300963e --- /dev/null +++ b/controllers.ts @@ -0,0 +1,325 @@ +/*** Controllers ***/ + +/** Handles window resize events to update the view of the application. */ +class WindowResizeHandler { + private debouncedUpdate: Function; + private paginationManager: PaginationManager; + private tableRenderer: TableRenderer; + private stateManager: StateManager; + + /** + * @param {TableRenderer} tableRenderer - Used for re-rendering table data. + * @param {StateManager} stateManager - State control for retrieving/updating application data. + */ + + constructor ( + tableRenderer: TableRenderer, + stateManager: StateManager, + paginationManager: PaginationManager + ) { + this.debouncedUpdate = this.debounce( + this.updateAfterResize.bind(this), + 250 + ); + this.paginationManager = paginationManager; + this.tableRenderer = tableRenderer; + this.stateManager = stateManager; + + // Attach event listener for window resize. + this.setupEventListenersResize(); + } + + private setupEventListenersResize(): void { + window.addEventListener("resize", () => this.handleResize()); + } + + handleResize(): void { + try { + this.debouncedUpdate(); + } catch (error) { + console.error( + `Error in handleResize: ${ + error instanceof Error ? error.message : error + }` + ); + alert("An error occurred while resizing. Please try again."); + } + } + + // Debounce function to reduce the number of function calls while user is dragging the browser window. + debounce(func: Function, delay: number): Function { + let timeout: ReturnType | null = null; + return (...args: any[]) => { + const later = () => { + timeout = null; + func(...args); + }; + if (timeout !== null) { + clearTimeout(timeout); + } + timeout = setTimeout(later, delay); + }; + } + + async updateAfterResize(): Promise { + this.stateManager.adjustWindowSize(); + await this.stateManager.retrieveRecords().catch((error) => { + console.error( + `Error retrieving records: ${ + error instanceof Error ? error.message : error + }` + ); + alert( + "An error occurred while retrieving records. Please try again later." + ); + return; + }); + + const records = this.stateManager.getRecords(); + + if (records !== null) { + this.tableRenderer.renderRecords(records); + } + this.paginationManager.updateButtonStates(); + } +} + +/** Handles pagination and search functionalities for the application's table view. */ +class PaginationManager { + // DOM elements required for pagination and search. + private prevButton: HTMLButtonElement | null = null; + private nextButton: HTMLButtonElement | null = null; + private searchButton: HTMLButtonElement | null = null; + private mainHeading: HTMLElement | null = null; + private filterInput: HTMLInputElement | null = null; + private errorMessage: HTMLElement | null = null; + + /** + * @param {TableRenderer} tableRenderer - Used for re-rendering table data. + * @param {StateManager} stateManager - State control for retrieving/updating application data. + */ + constructor ( + private tableRenderer: TableRenderer, + private stateManager: StateManager + ) { + this.initializeDOMElements(); + // Attach event listeners for buttons and other UI elements. + this.setupEventListeners(); + } + + private initializeDOMElements(): void { + this.prevButton = this.retrieveElement( + "prevPage", + "button" + ) as HTMLButtonElement; + this.nextButton = this.retrieveElement( + "nextPage", + "button" + ) as HTMLButtonElement; + this.searchButton = this.retrieveElement( + "searchButton", + "button" + ) as HTMLButtonElement; + this.mainHeading = this.retrieveElement( + "main-heading", + "heading" + ) as HTMLElement; + this.filterInput = this.retrieveElement( + "filterInput", + "input box" + ) as HTMLInputElement; + this.errorMessage = this.retrieveElement( + "errorMessage", + "error message" + ) as HTMLElement; + } + + private retrieveElement( + id: string, + description?: string + ): HTMLElement | null { + const element = document.getElementById(id); + if (!element) { + console.error(`Element with ID '${id}' not found`); + if (description) { + alert( + `A critical ${description} is missing on the page. Some functionalities might not work as expected.` + ); + } + } + return element; + } + + /** Attaches event listeners to the relevant DOM elements to handle user interactions. */ + private setupEventListeners(): void { + if (this.prevButton) { + this.prevButton.addEventListener("click", () => + this.decrementPage() + ); + } + + if (this.nextButton) { + this.nextButton.addEventListener("click", () => + this.incrementPage() + ); + } + + if (this.searchButton) { + this.searchButton.addEventListener("click", () => + this.searchById() + ); + } + + if (this.filterInput) { + this.filterInput.addEventListener("keyup", (event) => { + if (event.key === "Enter") { + this.searchById(); + } + }); + } + + if (this.mainHeading) { + this.mainHeading.addEventListener("click", () => + this.navigateToHome() + ); + } + + if (this.filterInput && this.errorMessage) { + this.setupLiveValidation(); + } + } + + /** Navigates to the home page by reloading the window.*/ + navigateToHome(): void { + try { + window.location.reload(); + } catch (error) { + console.error( + `Error while navigating to home: ${ + error instanceof Error ? error.message : error + }` + ); + alert("Failed to reload the page. Please try again."); + } + } + + /** Fetches the next set of records and updates the view. */ + async incrementPage(): Promise { + this.stateManager.goToNextPage(); + + await this.stateManager.retrieveRecords().catch((error) => { + console.error( + `Error in retrieveRecords while incrementing page: ${ + error instanceof Error ? error.message : error + }` + ); + alert("Failed to increment the page. Please contact support."); + }); + + const records = this.stateManager.getRecords(); + + if (records !== null) { + this.tableRenderer.renderRecords(records); + } + this.updateButtonStates(); + } + + /** Fetches the previous set of records and updates the view. */ + async decrementPage(): Promise { + this.stateManager.goToPreviousPage(); + + await this.stateManager.retrieveRecords().catch((error) => { + console.error( + `Error in retrieveRecords while decrementing page: ${ + error instanceof Error ? error.message : error + }` + ); + alert("Failed to decrement the page. Please contact support."); + }); + + const records = this.stateManager.getRecords(); + + if (records !== null) { + this.tableRenderer.renderRecords(records); + } + + this.updateButtonStates(); + } + + /** Searches for a record by its ID and updates the view. */ + async searchById(): Promise { + if (!this.filterInput) { + alert("Filter input element is missing"); + return; + } + + const searchValue = parseInt(this.filterInput.value, 10); + if (isNaN(searchValue)) { + alert("Invalid search value or none"); + return; + } + + this.stateManager.setHighlightedId(searchValue); + + await this.stateManager + .searchByIdStateChange(searchValue) + .catch((error) => { + console.error( + `Error in searchByIdStateChange: ${ + error instanceof Error ? error.message : error + }` + ); + alert("A serious error occurred, please try again later"); + return; + }); + + const records = this.stateManager.getRecords(); + + if (records !== null) { + this.tableRenderer.renderRecords(records, searchValue); + } + + this.updateButtonStates(); + } + + /** Validates input for the search bar in real-time. */ + setupLiveValidation(): void { + if (!this.filterInput || !this.errorMessage) { + console.error( + "Live validation setup failed: Required elements not found." + ); + return; + } + + this.filterInput.addEventListener("input", () => { + const inputValue = this.filterInput!.value; // The "!" here asserts non-null, because I already checked for null above. + const maxValue = this.stateManager.getTotalRecordCount() - 1; + if (inputValue.length === 0) { + this.errorMessage!.textContent = ""; + } else if ( + inputValue.length < 1 || + inputValue.length > 6 || + !/^\d+$/.test(inputValue) + ) { + this.errorMessage!.textContent = `Invalid input. Please enter a number between 0 and ${maxValue}.`; + } else { + this.errorMessage!.textContent = ""; + } + }); + } + + /** Updates the state of the pagination buttons based on the current view. */ + public updateButtonStates(): void { + if (!this.prevButton || !this.nextButton) { + alert("Button elements are missing"); + return; + } + + const from = this.stateManager.getFrom(); + const to = this.stateManager.getTo(); + const totalRecordCount = this.stateManager.getTotalRecordCount(); + + this.prevButton.disabled = from === 0; + this.nextButton.disabled = to === totalRecordCount - 1; + } +} diff --git a/data.ts b/data.ts new file mode 100644 index 00000000..524067a8 --- /dev/null +++ b/data.ts @@ -0,0 +1,74 @@ +//*** Data ***/ + +type CityData = (number | string)[]; + +/** Manages API requests for fetching record count, column names, and data records.*/ +class ApiManager { + totalRecordCount: number; + columnNames: string[] | null; + + constructor () { + this.totalRecordCount = 0; + this.columnNames = null; + } + + async fetchTotalRecordCount(): Promise { + const response = await fetch("http://localhost:2050/recordCount").catch( + (error) => { + console.error(`Network error fetching record count: ${error}`); + throw error; + } + ); + + if (!response.ok) { + const errorMessage = `Failed to fetch total record count: ${response.statusText}`; + console.error(errorMessage); + throw new Error(errorMessage); + } + + const data: number = await response.json().catch((error) => { + console.error(`Error parsing JSON for record count: ${error}`); + throw error; + }); + + this.totalRecordCount = data; + } + + async fetchColumnNames(): Promise { + const response = await fetch("http://localhost:2050/columns").catch( + (error) => { + console.error(`Network error fetching column names: ${error}`); + throw error; + } + ); + + if (!response.ok) { + const errorMessage = `Failed to fetch column names: ${response.statusText}`; + console.error(errorMessage); + throw new Error(errorMessage); + } + + const data: string[] = await response.json().catch((error) => { + console.error(`Error parsing JSON for column names: ${error}`); + throw error; + }); + + this.columnNames = data; + } + + async fetchRecords(from: number, to: number): Promise { + const response = await fetch( + `http://localhost:2050/records?from=${from}&to=${to}` + ).catch((error) => { + console.error(`Network error fetching records: ${error}`); + throw error; + }); + + if (!response.ok) { + throw new Error(`Failed to fetch records: ${response.statusText}`); + } + + const data: CityData[] = await response.json(); + return data; + } +} diff --git a/index.html b/index.html index add5e736..ed2fa3c9 100644 --- a/index.html +++ b/index.html @@ -1,13 +1,41 @@ - - JS Onboard Project - - - - -

Hello

- - + + Riaan JS Onboard Project + + + + + + + + + +
+ +

Area 51's Grocery List

+ + + + + + +
+ +
+ - diff --git a/model.ts b/model.ts new file mode 100644 index 00000000..8c203daa --- /dev/null +++ b/model.ts @@ -0,0 +1,246 @@ +//*** Model ***/ + +/** Manages the application's state for data display, navigation, and search functionalities. */ +class StateManager { + private highlightedId: number | null = null; + private rowHeight: number; + private headerHeight: number; + private availableHeight: number; + private numRows: number; + private from: number; + private to: number; + private apiManager: ApiManager; + private records: CityData[] | null = null; + private columnNames: string[] | null; + private totalRecordCount = 0; + + constructor (apiManager: ApiManager) { + this.rowHeight = 20; + this.headerHeight = 180; + this.availableHeight = 0; + this.numRows = 0; + this.apiManager = apiManager; + this.from = 0; + this.to = 0; + this.columnNames = null; + this.totalRecordCount = 0; + } + + public getHighlightedId(): number | null { + return this.highlightedId; + } + + public setHighlightedId(value: number | null): void { + this.highlightedId = value; + } + + /** Sets up initial state, fetches record count and column names, and adjusts the display window size. */ + async initializeState(): Promise { + await this.fetchAndStoreTotalRecordCount().catch((error) => { + console.error("Error in fetchAndStoreTotalRecordCount:", error); + return; + }); + + await this.retrieveColumnNames().catch((error) => { + console.error("Error in retrieveColumnNames:", error); + return; + }); + + try { + this.adjustWindowSize(); + } catch (error) { + console.error( + `Error in adjustWindowSize: ${ + error instanceof Error ? error.message : error + }` + ); + alert("An error occurred while adjusting the window size. Please try again."); + } + } + + async retrieveColumnNames(): Promise { + await this.apiManager.fetchColumnNames().catch((error) => { + console.error( + "Error fetching column names from apiManager:",error); + throw error; + }); + + if (this.apiManager.columnNames !== null) { + this.columnNames = this.apiManager.columnNames; + } + } + + async fetchAndStoreTotalRecordCount(): Promise { + await this.apiManager.fetchTotalRecordCount().catch(error => { + console.error("Error fetching total record count from apiManager:", error); + throw error; + }); + + this.totalRecordCount = this.apiManager.totalRecordCount; + } + + getTotalRecordCount(): number { + return this.totalRecordCount; + } + + getColumnNames(): string[] | null { + return this.columnNames; + } + + getRecords(): CityData[] | null { + return this.records; + } + + getFrom(): number { + return this.from; + } + + setFrom(value: number): void { + this.from = value; + } + + getTo(): number { + return this.to; + } + + setTo(value: number): void { + this.to = value; + } + + goToNextPage(): void { + const from = this.getFrom(); + const to = this.getTo(); + const recordsPerPage = this.numRows; + + const newFrom = from + recordsPerPage; + const newTo = newFrom + recordsPerPage - 1; + + // Check that 'to' does not exceed totalRecordCount. + if (newTo >= this.totalRecordCount) { + this.setTo(this.totalRecordCount - 1); + this.setFrom(this.totalRecordCount - recordsPerPage); + } else { + this.setFrom(newFrom); + this.setTo(newTo); + } + } + + goToPreviousPage(): void { + const from = this.getFrom(); + const to = this.getTo(); + const recordsPerPage = this.numRows; + + const newFrom = from - recordsPerPage; + const newTo = newFrom + recordsPerPage - 1; + + // Check that 'from' does not exceed 0. + if (newFrom < 0) { + this.setFrom(0); + this.setTo(recordsPerPage - 1); + } else { + this.setFrom(newFrom); + this.setTo(newTo); + } + } + + async searchByIdStateChange(id: number): Promise { + const newFrom = id; + const newTo = id + this.numRows - 1; + const recordsPerPage = this.numRows; + + // Checking that 'to' does not exceed totalRecordCount. + if (newTo >= this.totalRecordCount) { + this.setTo(this.totalRecordCount - 1); + this.setFrom(this.totalRecordCount - recordsPerPage); + } else { + this.setTo(newTo); + this.setFrom(newFrom); + } + + await this.retrieveRecords().catch(error => { + console.error("Error retrieving records in searchByIdStateChange:", error); + throw error; + }); + } + + /** Adjusts the available height based on window size and recalculates the number of rows. */ + adjustWindowSize(): void { + if (typeof window === "undefined" || !window.innerHeight) { + throw new Error("Unable to access window dimensions"); + } + + // Determine the dynamic height of the header and pagination. + const mainHeadingElement = document.getElementById("main-heading"); + const paginationElement = document.getElementById("pagination"); + + if (mainHeadingElement && paginationElement) { + this.headerHeight = + mainHeadingElement.clientHeight + + paginationElement.clientHeight; + } else { + throw new Error("Could not find main-heading and/or pagination elements"); + } + + if (!this.rowHeight) { + throw new Error("Row height is not properly configured"); + } + + this.availableHeight = + window.innerHeight - this.headerHeight - this.rowHeight * 2; + this.numRows = Math.floor(this.availableHeight / this.rowHeight); + + if (this.numRows <= 0) { + console.log("Window size too small, setting minimum number of rows to 1"); + this.numRows = 1; + } + + // Calculating new values without modifying the state immediately. + let newFrom = this.from; + let newTo = this.from + this.numRows - 1; + + // If it's the first set of records ("first page"), start from 0 and populate the whole window size. + if (this.from === 0) { + newFrom = 0; + newTo = this.numRows - 1; + } + + // Ensure `newTo` doesn't exceed totalRecordCount and adjust `newFrom` accordingly, + // meaning populate the whole window size. + if (newTo >= this.totalRecordCount) { + newTo = this.totalRecordCount - 1; + newFrom = newTo - this.numRows + 1; + } + + // Check if the highlighted ID is currently between from and to, + // to enable priority functionality (always visible in the window). + const highlightedId = this.getHighlightedId(); + if ( + highlightedId !== null && + highlightedId >= this.from && + highlightedId <= this.to + ) { + // If newTo would be smaller than highlightedId, adjust to keep highlightedId in view. + if (newTo < highlightedId) { + newTo = highlightedId; + newFrom = newTo - this.numRows + 1; + } + } + + // Now, after all conditions have been checked, set the state. + this.setFrom(newFrom); + this.setTo(newTo); + } + + async retrieveRecords(): Promise { + this.records = await this.apiManager + .fetchRecords(this.from, this.to) + .catch((error) => { + console.error( + `Error fetching records: ${ + error instanceof Error ? error.message : error + }` + ); + throw error; + }); + } +} diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 00000000..9ee14bc8 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,68 @@ +{ + "name": "onboard-javascript", + "version": "1.0.0", + "lockfileVersion": 2, + "requires": true, + "packages": { + "": { + "name": "onboard-javascript", + "version": "1.0.0", + "license": "ISC", + "dependencies": { + "typescript": "^3.9.2" + }, + "devDependencies": { + "@types/jquery": "^3.5.17" + } + }, + "node_modules/@types/jquery": { + "version": "3.5.17", + "resolved": "https://registry.npmjs.org/@types/jquery/-/jquery-3.5.17.tgz", + "integrity": "sha512-U40tNEAGSTZ7R1OC6kGkD7f4TKW5DoVx6jd9kTB9mo5truFMi1m9Yohnw9kl1WpTPvDdj7zAw38LfCHSqnk5kA==", + "dev": true, + "dependencies": { + "@types/sizzle": "*" + } + }, + "node_modules/@types/sizzle": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/@types/sizzle/-/sizzle-2.3.3.tgz", + "integrity": "sha512-JYM8x9EGF163bEyhdJBpR2QX1R5naCJHC8ucJylJ3w9/CVBaskdQ8WqBf8MmQrd1kRvp/a4TS8HJ+bxzR7ZJYQ==", + "dev": true + }, + "node_modules/typescript": { + "version": "3.9.2", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-3.9.2.tgz", + "integrity": "sha512-q2ktq4n/uLuNNShyayit+DTobV2ApPEo/6so68JaD5ojvc/6GClBipedB9zNWYxRSAlZXAe405Rlijzl6qDiSw==", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=4.2.0" + } + } + }, + "dependencies": { + "@types/jquery": { + "version": "3.5.17", + "resolved": "https://registry.npmjs.org/@types/jquery/-/jquery-3.5.17.tgz", + "integrity": "sha512-U40tNEAGSTZ7R1OC6kGkD7f4TKW5DoVx6jd9kTB9mo5truFMi1m9Yohnw9kl1WpTPvDdj7zAw38LfCHSqnk5kA==", + "dev": true, + "requires": { + "@types/sizzle": "*" + } + }, + "@types/sizzle": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/@types/sizzle/-/sizzle-2.3.3.tgz", + "integrity": "sha512-JYM8x9EGF163bEyhdJBpR2QX1R5naCJHC8ucJylJ3w9/CVBaskdQ8WqBf8MmQrd1kRvp/a4TS8HJ+bxzR7ZJYQ==", + "dev": true + }, + "typescript": { + "version": "3.9.2", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-3.9.2.tgz", + "integrity": "sha512-q2ktq4n/uLuNNShyayit+DTobV2ApPEo/6so68JaD5ojvc/6GClBipedB9zNWYxRSAlZXAe405Rlijzl6qDiSw==" + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 00000000..9d7593ae --- /dev/null +++ b/package.json @@ -0,0 +1,28 @@ +{ + "name": "onboard-javascript", + "version": "1.0.0", + "description": "This is a JavaScript project for all new developers to complete before venturing into our web frontend codebase.", + "main": "index.js", + "scripts": { + "test": "echo \"Error: no test specified\" && exit 1", + "build": "tsc", + "start" : "npm run build", + "watch": "tsc --watch" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/riaanwastaken/onboard-javascript.git" + }, + "author": "", + "license": "ISC", + "bugs": { + "url": "https://github.com/riaanwastaken/onboard-javascript/issues" + }, + "homepage": "https://github.com/riaanwastaken/onboard-javascript#readme", + "dependencies": { + "typescript": "^3.9.2" + }, + "devDependencies": { + "@types/jquery": "^3.5.17" + } +} diff --git a/stylesheet.css b/stylesheet.css new file mode 100644 index 00000000..90760bd5 --- /dev/null +++ b/stylesheet.css @@ -0,0 +1,98 @@ +#main-heading { + font-family: "Agency FB", sans-serif; + font-size: 2.5em; + font-weight: bold; + text-align: center; + margin: 10px 0; + color: #333; + text-shadow: #00ff00; + cursor: pointer; +} + +#main-container { + margin: 0 auto; + background-color: #fff; +} + +html, +body { + font-family: "SF Pro Text", "Helvetica Neue", Helvetica, Arial, sans-serif; + color: #333; + margin-bottom: 0; + overflow: hidden; +} + +th { + font-family: "Agency FB", sans-serif; + text-align: center; + font-size: 16px; + border: 1px solid #a1e3a1; + background-color: #333333; + color: #4caf50; +} + +td { + font-family: "Agency FB", sans-serif; + font-size: 14px; + border: 1px solid #a1e3a1; + text-align: center; + color: #333333; + text-overflow: ellipsis; + overflow: hidden; + white-space: nowrap; +} + +table { + border-collapse: collapse; +} + +#myTable { + table-layout: fixed; + + width: 100%; + height: 100%; + border: 1px solid #a1e3a1; + border-radius: 12px; + box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1); +} + +.highlight { + background-color: #00ff00; + color: #4caf50; +} + +.pagination-1 { + text-align: center; + padding-top: 25px; + padding-bottom: 25px; + font-family: "Agency FB", sans-serif; +} + +button { + padding: 10px 20px; + border: 1px solid #00ff00; + background-color: #1e1e1e; + color: #00ff00; + border-radius: 5px; + font-family: "Agency FB", sans-serif; + cursor: pointer; + transition: background-color 0.3s; +} + +button:disabled { + background-color: #555; + color: #ccc; +} + +button:hover:not(:disabled) { + background-color: #333; +} + +#filterInput { + padding: 10px; + border: 1px solid #00ff00; + border-radius: 5px; + font-family: "Agency FB", sans-serif; + color: #00ff00; + background-color: #1e1e1e; +} diff --git a/views.ts b/views.ts new file mode 100644 index 00000000..6b8dd3ac --- /dev/null +++ b/views.ts @@ -0,0 +1,116 @@ +//*** Views ***/ + +/** + * TableRenderer is responsible for rendering data into an HTML table. + * It fetches data from the StateManager and populates the table accordingly. + */ +class TableRenderer { + private stateManager: StateManager; + + constructor (stateManager: StateManager) { + this.stateManager = stateManager; + } + + /** + * Renders the initial table layout including column names and initial data set. + * @param {StateManager} stateManager - The manager to fetch state from. + */ + async initialRender(): Promise { + const columnNames = this.stateManager.getColumnNames(); + if (columnNames !== null) { + this.renderColumnNames(columnNames); + } + + await this.stateManager.retrieveRecords().catch((error) => { + console.error("Error retrieving records in initialRender:", error); + throw error; + }); + + const records = this.stateManager.getRecords(); + + if (records !== null) { + this.renderRecords(records); + } + } + + renderColumnNames(columnNames: string[]): void { + const thead = document.querySelector("thead"); + if (thead === null) { + throw new Error("Table header not found."); + } + + const row = document.createElement("tr"); + for (const columnName of columnNames) { + const cell = document.createElement("th"); + cell.textContent = columnName; + row.appendChild(cell); + } + thead.appendChild(row); + + try { + this.setColumnWidths(); + } catch (error) { + if (error instanceof Error) { + console.error( + `An error occurred in setColumnWidths: ${error.message}` + ); + } else { + console.error( + `An unknown error occurred in setColumnWidths: ${error}` + ); + } + throw error; + } + } + + /** Sets the widths of table columns evenly. */ + setColumnWidths(): void { + try { + const table = document.getElementById("myTable"); + + if (!table) { + throw new Error('Table with id "myTable" not found.'); + } + + const headerCells = table.querySelectorAll("th"); + const numCols = headerCells.length; + const colWidth = 100 / numCols; + + headerCells.forEach((headerCell: Element) => { + (headerCell as HTMLElement).style.width = `${colWidth}%`; + }); + } catch (error) { + console.error(`Error setting column widths: ${error}`); + } + } + + /** Populates the table body with records. Optionally highlights a specified row if searched. */ + renderRecords(records: CityData[], highlightId: number | null = null) { + // Use the state's highlightedId if no highlightId is provided. + highlightId = highlightId ?? this.stateManager.getHighlightedId(); + + const tbody = document.querySelector("tbody"); + if (tbody === null) { + throw new Error("Table body not found."); + } + + tbody.innerHTML = ""; + + for (const record of records) { + const row = document.createElement("tr"); + if ( + highlightId !== null && + record.length > 0 && + parseInt(record[0].toString(), 10) === highlightId + ) { + row.classList.add("highlight"); + } + for (const cell of record) { + const td = document.createElement("td"); + td.textContent = cell.toString(); + row.appendChild(td); + } + tbody.appendChild(row); + } + } +}