diff --git a/.gitignore b/.gitignore index 0872c36c..e94d4e01 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,9 @@ /node_modules app.js app.js.map +ApiData.js +ApiData.js.map +GridTemplate.js +GridTemplate.js.map +Interfaces.js +Interfaces.js.map diff --git a/ApiData.ts b/ApiData.ts new file mode 100644 index 00000000..09e4356a --- /dev/null +++ b/ApiData.ts @@ -0,0 +1,249 @@ +/** Debounce utility function to limit function execution frequency */ +function debounce any>(func: F, waitFor: number) { + let timeout: number; + + return (...args: Parameters): Promise> => { + clearTimeout(timeout); + + return new Promise((resolve) => { + timeout = setTimeout(() => { + resolve(func(...args)); + }, waitFor); + }); + }; +} + +/** Constants for grid calculation +* GRID_RATIO represents the ratio of the grid's height to the window's height. +*/ +const GRID_RATIO = 9 / 20; +const ROW_HEIGHT = 16; + +/** manage data and settings on the grid */ +class ApiData { + + pageSize: number; + currentPage: number = 1; + data: GridData[] = []; + totalItems: number = 0; + columnNames: ColumnName[] = []; + maxGridHeight: number = 0; + firstVal: number = 0; + lastVal: number = -1; + maxRange: number; + + constructor(pageSize: number) { + this.pageSize = pageSize; + this.maxRange = 0; + } + + /** Initialize method to set up the grid */ + initialize(): Promise { + this.adjustGridHeight(); + return this.recordCount() + .then(() => this.fetchColumns()) + .then(() => this.fetchAndDisplayRecords()) + .then(() => this.setupControls()); + } + + /** Fetch total record count from the server,fetches data from an API and populates class properties */ + recordCount(): Promise { + return this.fetchNumData('http://localhost:2050/recordCount') + .then((response: number) => { + this.totalItems = response; + this.maxRange = this.totalItems - 1; + }) + .catch(error => { + console.error('Failed to fetch record count:', error); + throw error; + }); + } + + /** Use the fetchData() func to make an HTTP request to the API endpoint and process the data */ + fetchColumns(): Promise { + return this.fetchStrData('http://localhost:2050/columns') + .then((response: string) => { + const res = JSON.parse(response); + this.columnNames = res.map((columnName: string) => ({ name: columnName })); + // Initialize the 'data' property as an empty array of GridData objects + this.data = new Array(this.columnNames.length); + }) + .catch(error => { + console.error('Failed to fetch columns:' + error); + throw ('Failed to fetch columns:' + error); + }); + } + + /** Get records from API for fetch and search functionality */ + fetchAndProcessRecords(from: number, to: number): Promise { + $('#spinner').show(); + $('#grid').hide(); + + return this.fetchStrData(`http://localhost:2050/records?from=${from}&to=${to}`) + .then((response: string) => { + const res = JSON.parse(response); + const processedData = res.map((record: string[]) => { + const obj: GridData = {}; + for (let j = 0; j < this.columnNames.length && j < record.length; j++) { + obj[this.columnNames[j].name] = record[j]; + } + return obj; + }); + $('#spinner').hide(); + $('#grid').show(); + return processedData; + }) + .catch(error => { + console.error('Failed to fetch records: ', error); + throw new Error('Failed to fetch records: ' + error); + }); + } + + /** Fetches records using fetchAndProcessRecords(), processes them, displays them, and updates page information. */ + fetchAndDisplayRecords(): Promise { + let from = this.firstVal; + let to = Math.min(from + this.pageSize - 1, this.maxRange); + + if (to >= this.maxRange) { + const lastPage = Math.ceil(this.firstVal / this.pageSize) + 1; + this.currentPage = lastPage; + from = this.maxRange - this.pageSize + 1; + to = this.maxRange; + } + + return this.fetchAndProcessRecords(from, to) + .then(processedData => { + this.data = processedData; + this.displayRecords(); + }) + .catch(error => { + console.error('Failed to fetch records:', error); + alert('Error occured while fetching records!'); + }); + } + + /** search through records using fromID */ + searchRecords(searchValue: number): Promise { + if (searchValue >= 0 && searchValue <= this.maxRange) { + this.firstVal = searchValue; + if (searchValue + this.pageSize > this.maxRange) { + this.firstVal = Math.max(0, this.maxRange - this.pageSize + 1); + } + this.currentPage = Math.ceil(this.firstVal / this.pageSize) + 1; + // empty search input after searching + $('#fromInput').val(''); + return this.fetchAndDisplayRecords(); + } else { + alert(`Error while searching, please enter values in the range (0-${this.maxRange})`); + return Promise.resolve(); + } + } + + /** use Ajax for data fetching */ + private async fetchStrData(url: string): Promise { + $('#overlay').show(); + const response = await $.ajax({ + url, + method: 'GET', + }); + $('#overlay').hide(); + return response; + } + + private async fetchNumData(url: string): Promise { + const response = await $.ajax({ + url, + method: 'GET', + }); + return response; + } + + /** Change grid height according to screen size */ + private adjustGridHeight(): void { + const gridElement = document.getElementById('grid'); + const pageCntrl = $('.grid-controls').innerHeight(); + const screenHeight = $(window).innerHeight(); + if (gridElement && pageCntrl !== undefined && screenHeight !== undefined) { + this.maxGridHeight = screenHeight - pageCntrl; + gridElement.style.height = `${this.maxGridHeight}px`; + } + } + + /** Update the page information and records display based on the current state of the grid. */ + private updatePageInfo(): void { + const totalPages = Math.ceil(this.totalItems / this.pageSize); + const pageInfo = `Page ${this.currentPage} of ${totalPages}`; + const from = this.firstVal; + let to = Math.min(from + this.pageSize - 1, this.maxRange); + $('#pageInfo').text(`${pageInfo}`); + $('.records').text(`Showing records ${from} to ${to}`); + } + + private setupControls(): void { + $('#prevBtn').on('click', () => this.handlePageChange(-1)); + $('#nextBtn').on('click', () => this.handlePageChange(1)); + $(window).on('resize', debounce(() => { this.handleResize(); }, 100)); + } + + /** Handles page navigation by updating the firstVal, lastVal, current page, and enabling/disabling previous and next buttons as needed. */ + private handlePageChange(delta: number): void { + let prevBtn = $('#prevBtn'); + let nextBtn = $('#nextBtn'); + + // Check if delta(change in page number) is positive and disable the next page if firstval + pageSize exceeds the MaxRange. + if (delta > 0 && this.firstVal + delta * this.pageSize > this.maxRange) { + this.firstVal = this.maxRange - this.pageSize + 1; + prevBtn.attr("disabled", null); + nextBtn.attr("disabled", "disabled"); + } else if (delta < 0 && this.firstVal + delta * this.pageSize < 0) { + // If delta is negative then reset firstVal to 0 and disable prev button + this.firstVal = 0; + prevBtn.attr("disabled", "disabled"); + nextBtn.attr("disabled", null); + } else { + this.firstVal = Math.max(0, Math.min(this.firstVal + delta * this.pageSize, this.maxRange)); + prevBtn.attr("disabled", null); + nextBtn.attr("disabled", null); + } + + this.currentPage = Math.ceil(this.firstVal / this.pageSize) + 1; + + this.fetchAndDisplayRecords() + .catch(error => { + console.error("Error fetching records while changing page :", error); + alert('Error occured while changing page!'); + }); + } + + private handleResize(): void { + const newGridSize = Math.ceil((Math.ceil(($(window).innerHeight())) * GRID_RATIO) / ROW_HEIGHT) - 1; + + // Check if the new grid size is non-negative + if (newGridSize >= 0) { + // Adjust firstVal for the last page + if (this.firstVal + newGridSize > this.maxRange) { + this.firstVal = this.maxRange - newGridSize + 1; + } + + this.pageSize = newGridSize; + this.lastVal = this.firstVal + newGridSize - 1; + + this.adjustGridHeight(); + + this.fetchAndDisplayRecords() + .then(() => { + this.updatePageInfo(); + }) + .catch(error => { + console.error("Error fetching records while resizing:", error); + alert('Error occured while resizing!'); + }); + } + } + + private displayRecords(): void { + const gridTemplate = new GridTemplate(this.columnNames, this.data); + gridTemplate.displayRecords(); + this.updatePageInfo(); + } +} diff --git a/GridTemplate.ts b/GridTemplate.ts new file mode 100644 index 00000000..bd8799ce --- /dev/null +++ b/GridTemplate.ts @@ -0,0 +1,44 @@ +/** manage the grid template and display records */ +class GridTemplate { + private columnNames: ColumnName[]; + private dataRecords: GridData[]; + + /** Initializes the column names and data records that will be used to display records in the grid. */ + constructor(columnNames: ColumnName[], dataRecords: GridData[]) { + this.columnNames = columnNames; + this.dataRecords = dataRecords; + } + + /** Display records in a grid in table format */ + displayRecords(): void { + + const gridElement = document.getElementById('grid'); + if (gridElement) { + gridElement.innerHTML = ''; + const table = document.createElement('table'); + const thead = document.createElement('thead'); + const headerRow = document.createElement('tr'); + for (const column of this.columnNames) { + const th = document.createElement('th'); + th.textContent = column.name; + headerRow.appendChild(th); + } + thead.appendChild(headerRow); + table.appendChild(thead); + // Create table body + const tbody = document.createElement('tbody'); + for (const row of this.dataRecords) { + const tr = document.createElement('tr'); + for (const column of this.columnNames) { + const td = document.createElement('td'); + td.textContent = row[column.name]; + tr.appendChild(td); + } + tbody.appendChild(tr); + } + table.appendChild(tbody); + // Append the table to the grid element + gridElement.appendChild(table); + } + } +} diff --git a/Interfaces.ts b/Interfaces.ts new file mode 100644 index 00000000..6c600670 --- /dev/null +++ b/Interfaces.ts @@ -0,0 +1,9 @@ +/** Interface to define column names */ +interface ColumnName { + name: string; +} + +/** Interface to define the structure of grid data */ +interface GridData { + [key: string]: any; +} diff --git a/Styles.css b/Styles.css new file mode 100644 index 00000000..d4ee8bb1 --- /dev/null +++ b/Styles.css @@ -0,0 +1,107 @@ +html, +body, +#grid { + margin: 0; + padding: 0; + height: 100%; + overflow: hidden; + font-family: system-ui; +} + +#overlay { + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + background-color: rgba(231, 238, 238, 0.815); + z-index: 900; + display: none; +} + +.spinner-container { + text-align: center; +} + +#grid { + border: 1px solid #333333; + text-align: center; + background-color: #f2f6f7; + display: grid; + grid-template-rows: 1fr; +} + +/* table */ +table { + width: 100%; + height: 100%; + border-collapse: collapse; + table-layout: fixed; +} + +tr { + border: 1px solid #333333; +} + +th { + background-color: #333333; + font-weight: 450; + color: white; +} + +td { + border: 1px solid #333333; + align-content: center; + width: 1%; +} + +.grid-controls { + display: flex; + align-items: center; + justify-content: center; + padding: 10px; + color: white; + background-color: #333333; + position: sticky; + bottom: 0; +} + +#pageInfo, +.records { + font-weight: 450; + background-color: #333333; +} + +#fromInput { + padding: 5px 10px; + margin: 0 5px; + background-color: #232425; + color: white; +} + +/* button */ +.btn { + cursor: pointer; + padding: 5px 10px; + margin: 0 5px; + color: white; + background-color: #5a5f61; +} + +@media screen and (max-width: 1300px) { + td { + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } + + td:hover { + overflow: visible; + white-space: normal; + text-overflow: unset; + } + + #pageInfo { + display: none; + } +} diff --git a/app.ts b/app.ts new file mode 100644 index 00000000..42edcb00 --- /dev/null +++ b/app.ts @@ -0,0 +1,23 @@ +/** Wait for the document to be ready */ +$(document).ready(() => { + // Initialization and setup code + const initialGridSize = Math.ceil(Math.ceil((($(window).innerHeight()))* GRID_RATIO)/ ROW_HEIGHT) - 1; + const apidata = new ApiData(initialGridSize); + + // Set up search button click handler + $('#searchBtn').on('click', () => { + let from = parseInt(($('#fromInput').val())); + apidata.searchRecords(from); + }); + + // Initialize the grid + apidata.initialize() + .catch((error) => { + console.error('Error during initialization:', error); + alert(error); + }); + + // overlay when the page is still getting ready + const overlay = $('
'); + $('body').append(overlay); +}); diff --git a/index.html b/index.html index add5e736..f2d4e134 100644 --- a/index.html +++ b/index.html @@ -1,13 +1,34 @@ + JS Onboard Project + + + + + -

Hello

+ +
+ +
+
+
+
+ + + +
+ + +
+
- diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 00000000..2d1ab408 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,26 @@ +{ + "name": "onboard-javascript", + "version": "1.0.0", + "lockfileVersion": 1, + "requires": true, + "dependencies": { + "@types/jquery": { + "version": "3.5.16", + "resolved": "https://registry.npmjs.org/@types/jquery/-/jquery-3.5.16.tgz", + "integrity": "sha512-bsI7y4ZgeMkmpG9OM710RRzDFp+w4P1RGiIt30C1mSBT+ExCleeh4HObwgArnDFELmRrOpXgSYN9VF1hj+f1lw==", + "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==" + }, + "typescript": { + "version": "3.8.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-3.8.3.tgz", + "integrity": "sha512-MYlEfn5VrLNsgudQTVJeNaQFUAI7DkhnOjdpAp4T+ku1TfQClewlbSuTVHiA+8skNBgaf02TL/kLOvig4y3G8w==" + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 00000000..a4e866f9 --- /dev/null +++ b/package.json @@ -0,0 +1,25 @@ +{ + "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 -- -w" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/Ntobe99/onboard-javascript.git" + }, + "author": "", + "license": "ISC", + "bugs": { + "url": "https://github.com/Ntobe99/onboard-javascript/issues" + }, + "homepage": "https://github.com/Ntobe99/onboard-javascript#readme", + "dependencies": { + "@types/jquery": "^3.5.16", + "typescript": "^3.8.3" + } +} diff --git a/spinner.gif b/spinner.gif new file mode 100644 index 00000000..aaac0761 Binary files /dev/null and b/spinner.gif differ