diff --git a/.gitignore b/.gitignore index 0872c36c..34e0c259 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,5 @@ /node_modules app.js app.js.map +apiManager.js +apiManager.js.map diff --git a/.vscode/launch.json b/.vscode/launch.json new file mode 100644 index 00000000..4089e2fb --- /dev/null +++ b/.vscode/launch.json @@ -0,0 +1,15 @@ +{ + "version": "0.2.0", + "configurations": [ + { + "type": "chrome", + "request": "launch", + "name": "Debug in Chrome", + "url": "http://localhost:2050/", + "webRoot": "${workspaceFolder}", + "sourceMapPathOverrides": { + "webpack:///./*": "${webRoot}/*" + } + } + ] +} diff --git a/.vscode/tasks.json b/.vscode/tasks.json new file mode 100644 index 00000000..32a6e4c0 --- /dev/null +++ b/.vscode/tasks.json @@ -0,0 +1,15 @@ +{ + "version": "2.0.0", + "tasks": [ + { + "type": "typescript", + "tsconfig": "tsconfig.json", + "option": "watch", + "problemMatcher": [ + "$tsc-watch" + ], + "group": "build", + "label": "tsc: watch - tsconfig.json" + } + ] +} \ No newline at end of file diff --git a/apiManager.ts b/apiManager.ts new file mode 100644 index 00000000..aec4d817 --- /dev/null +++ b/apiManager.ts @@ -0,0 +1,50 @@ +class ApiManager { + + private mainUrl: string; + + constructor(mainUrl: string) { + this.mainUrl = mainUrl; + } + + private fetchJson(url: string): Promise { + return fetch(url) + .then(res => { + if (res.ok) { + return res.json(); + } else { + throw new Error(`HTTP error! Status: ${res.status}`); + } + }) + .catch(error => { + throw new Error(`Fetch failed: ${error}`); + }); + } + + /** Retrieves records from the api */ + getRecords(fromID: number, toID: number): Promise { + return this.fetchJson(`${this.mainUrl}/records?from=${fromID}&to=${toID}`); + } + + /** Retrieves columns from the api */ + getColumns(): Promise { + return this.fetchJson(`${this.mainUrl}/columns`); + } + + /** Retrieves the number of records there are */ + getRecordCount(): Promise { + return fetch(`${this.mainUrl}/recordCount`) + .then(res => { + if (res.ok) { + return res.text(); + } else { + throw new Error(`HTTP error? Status: ${res.status}`); + } + }) + .then(recordCount => { + return parseInt(recordCount); + }) + .catch(error => { + throw error; + }); + } +} diff --git a/app.ts b/app.ts new file mode 100644 index 00000000..be007857 --- /dev/null +++ b/app.ts @@ -0,0 +1,420 @@ +class DataHandler { + /** It tracks if the showRecords function is running. Is false if it's not. */ + private isFunctionRunning: boolean; + /** Provides the url to the server during initialization. */ + private apiManager: ApiManager; + /** The following track what page the user is on when navigating through the web app. */ + private currentPage: number; + /** This tracks the first page of the pagination that is being displayed. */ + private paginationStart: number; + /** This will track the last page that is being displayed. */ + private paginationEnd: number; + /** Tracks the current starting ID in the table. */ + private currentFromID: number; + /** Track the current last ID in the table. */ + private currentToID: number; + /** This is the calculation for the difference between the original ID based on the currentPage + * and the currentID which is based on the resizing of the table. */ + private difference: number; + /** A timer that handles window resizing events and store the return type of setTimeout. */ + private resizeTimer: ReturnType | null; + /** Will cache the record count and only make another api call if needed */ + private recordCount: number; + + constructor() { + this.isFunctionRunning = false; + this.apiManager = new ApiManager("http://localhost:2050"); + this.currentPage = 1; + this.paginationStart = 1; + this.paginationEnd = 10; + this.currentFromID = 0; + this.currentToID = 20; + this.difference = 0; + this.resizeTimer = null; + this.recordCount = 0; + } + + getRecordCount(): Promise { + if (this.recordCount !== 0) { + return Promise.resolve(this.recordCount); + } + return this.apiManager.getRecordCount() + .then(count => { + this.recordCount = count; + return this.recordCount; + }) + .catch(error => { + console.error("Failed getting the record count: ", error); + throw error; + }); + } + + /** Fetching the columns and rendering it on the table in the dom. */ + showColumns(): Promise { + $(".head-row").empty(); + return this.apiManager.getColumns() + .then(columns => { + for (const column of columns) { + $("#records-table-heading").append(`${column}`); + } + }) + .catch(error => { + console.error("Failed showing the columns: ", error); + throw error; + }); + } + + /** Fetching the records and rendering it on the DOM in the table*/ + showRecords(fromID: number, toID: number): Promise { + if (this.isFunctionRunning) { + return new Promise(() => { }); + } + this.isFunctionRunning = true; + let inputNumber: number; + let stringCount: string; + return this.getRecordCount() + .then(count => { + inputNumber = this.input(); + const maxRecords = this.recordsPerPage(); + $("#records-table-body").empty(); + this.loader(); + this.currentToID = toID; + this.currentFromID = fromID; + if (toID >= count) { + this.currentToID = (count - 1); + this.currentFromID = this.currentToID - (maxRecords - 1); + } else if (this.currentPage === 1) { + this.currentFromID = 0; + } + stringCount = count.toLocaleString().replace(/,/g, " "); + return this.apiManager.getRecords(this.currentFromID, this.currentToID); + }) + .then(records => { + $('.results').empty().append(`Displaying ID's ${this.currentFromID} - ${this.currentToID} out of ${stringCount}`); + let inputNumberString = inputNumber.toString(); + for (const record of records) { + $("#records-table-body").append(``); + for (const value of record) { + let isSearchValue = value === inputNumberString; + $(".body-row:last-child").append( + ` + ${value} + `); + } + $("#records-table-body").append(``); + } + this.isFunctionRunning = false; + }) + .catch(error => { + this.isFunctionRunning = false; + console.error("Failed showing the records: ", error); + throw error; + }); + } + + /** Handles pagination functionality and rendering it on the DOM.*/ + pageNumbers(start: number, end: number): Promise { + this.paginationStart = start; + this.paginationEnd = end; + return this.getRecordCount() + .then(count => { + $('.pagination').empty(); + let maxRecords = this.recordsPerPage(); + // This is the last page of all the records. It's calculated based on the amount of records showed + // on the table and the count of the records. + let lastPage = Math.ceil((count - 1) / maxRecords); + if (lastPage <= this.paginationEnd && lastPage >= this.paginationStart) { + this.paginationEnd = lastPage; + $(".next").css({ display: "none" }); + } else { + $(".next").css({ display: "block" }); + } + if (this.paginationStart <= 1) { + this.paginationStart = 1; + $(".prev").css({ display: "none" }); + } else { + $(".prev").css({ display: "block" }); + } + for (let i = this.paginationStart; i <= this.paginationEnd; i++) { + let isActive = i == this.currentPage; + $(".pagination").append( + `${i}` + ); + } + }) + .catch(error => { + console.error("Failed when showing the page numbers: ", error); + throw error; + }); + } + + /** Handles all the functionality related to pagination. */ + initializePagination(): void { + // Render the specific amount of records to the DOM based on the current page that it gets from the + // element's attribute value. + $(".pagination").on("click", ".pagination-item", (event) => { + $('.pagination-item').prop('disabled', true); + $('.search-input').val(''); + let returnedId = ($(event.target).attr("value")); + let maxRecords = this.recordsPerPage(); + this.currentPage = parseInt(returnedId); + let toID = this.currentPage * (maxRecords + 1) - 1; + if (this.difference > 0) { + toID = toID - this.difference; + } + let fromID = toID - maxRecords; + this.currentFromID = fromID; + $(".pagination-item").removeClass("active"); + $(event.target).addClass("active"); + $(".pagination-item").each(() => { + let elementID = ($(this).attr('value')); + let currentPageString = this.currentPage.toString(); + if (elementID == currentPageString) { + $(this).addClass('active'); + } + }); + this.showRecords(fromID, toID) + .then(() => { + $('.pagination-item').prop('disabled', false); + }) + .catch(error => { + console.error("Failed when clicking on the pagination: ", error); + alert("An error occurred while trying to load the page. Please try again."); + }); + }); + + // Gives the next set of page numbers based on the last page on the pagination. + $(".next").on("click", () => { + this.paginationStart = this.paginationEnd + 1; + this.paginationEnd = this.paginationStart + 9; + this.pageNumbers(this.paginationStart, this.paginationEnd) + .catch(error => { + console.error("Failed when clicking on the next button: ", error); + alert("An error occurred while trying to load the next set of pages. Please try again."); + }); + }); + + // Gives the previous set of pages numbers based on the last page in the pagination + $(".prev").on("click", () => { + this.paginationEnd = this.paginationStart - 1; + this.paginationStart = this.paginationEnd - 9; + this.pageNumbers(this.paginationStart, this.paginationEnd) + .catch(error => { + console.error("Failed when clicking on the previous button: ", error); + alert("An error occurred while trying to load the previous set of pages. Please try again."); + }); + }); + } + + /** Handles all the functionality related to the search. */ + initializeSearch(): void { + let regexPattern = /[0-9]/; + // Prevents certain characters to be entered in the input field. + $('.search-input').on('keydown', (e) => { + if (!regexPattern.test(e.key) && e.key.length === 1) { + e.preventDefault(); + } + if (e.key === 'Enter') { + $('.heading').trigger('click', '.results-box'); + } + }); + + // Takes the number entered in the search field and calculates a range and render that + // on to the DOM. + $(".search-input").on("input", (e: any) => { + e.preventDefault(); + this.getRecordCount() + .then(count => { + let inputNumber = this.input(); + if (!regexPattern.test(e.key)) { + let maxRecords = this.recordsPerPage(); + let pageNumber = Math.ceil(inputNumber / (maxRecords + 1)); + let end = pageNumber * (maxRecords + 1) - 1; + let start = end - maxRecords; + if (start < 0 || start < maxRecords) { + start = 0; + end = start + maxRecords; + } + if (end >= count) { + end = (count - 1); + this.currentToID = end; + } + this.currentPage = Math.floor((end + 1) / (maxRecords + 1)); + if (inputNumber < count && inputNumber > -1) { + $('.results-box').remove(); + $('.search-container').append( + `
+

${start} - ${end}

+
`); + } else { + $('.results-box').remove(); + $('.search-container').append( + `
+

Invalid Input!

+
`); + } + } else { + $('.results-box').remove(); + } + }) + .catch(error => { + console.error("Failed when searching: ", error); + alert("An error occurred while trying to search. Please try again."); + }); + }); + + // Will take the range on the DOM and return records based on that range. + $('.heading').on('click', '.results-select', (event: any) => { + $('.results-select').prop('disabled', true); + let startID: number; + let endID: number; + let pageEnd: number; + let pageStart: number; + this.getRecordCount() + .then(count => { + let idRange = $('.results-select').text(); + let rangeArray = null; + rangeArray = idRange.split('-'); + $('.results-box').remove(); + startID = parseInt(rangeArray[0]); + endID = parseInt(rangeArray[1]); + if (!isNaN(endID) && Number.isInteger(endID)) { + let maxRecords = this.recordsPerPage(); + this.currentPage = Math.floor((endID + 1) / (maxRecords + 1)); + pageEnd = Math.ceil(this.currentPage / 10) * 10; + pageStart = pageEnd - 9; + if (endID >= count) { + startID = ((this.currentPage - 1) * maxRecords) + 1; + endID = (count - 1); + this.paginationStart = pageStart; + this.paginationEnd = pageEnd; + } + } else { + throw new Error("Please provide a valid integer."); + } + }) + .then(() => { + this.pageNumbers(pageStart, pageEnd); + }) + .then(() => { + this.showRecords(startID, endID); + }) + .then(() => { + $('.results-select').prop('disabled', false); + }) + .catch(error => { + console.error("Failed when clicking on the results: ", error); + alert("An error occurred while trying to search. Please try again."); + }); + }); + } + + /** Will calculate the amount records to be shown according to the screen height. */ + adjustDisplayedRecords(): Promise { + let pageStart: number; + let pageEnd: number; + return this.getRecordCount() + .then(count => { + let maxRecords = this.recordsPerPage(); + let inputNumber = this.input(); + let newToID = this.currentToID === 0 ? this.currentToID + 1 : this.currentToID; + let newMaxRecords = maxRecords === 0 ? maxRecords + 1 : maxRecords; + if (inputNumber === -1) { + let newCurrentPage = Math.ceil(this.currentFromID / newMaxRecords); + if (newCurrentPage === 0) { + this.currentFromID = 0; + newCurrentPage = 1; + } + this.currentToID = this.currentFromID + maxRecords; + this.currentPage = newCurrentPage; + let originalID = (this.currentPage - 1) * (maxRecords + 1); + this.difference = originalID - this.currentFromID; + } else { + if (this.currentToID >= count) { + this.currentToID = (count - 1); + } + let newCurrentPage = Math.ceil(inputNumber / maxRecords); + this.currentToID = newCurrentPage * maxRecords; + this.currentPage = newCurrentPage; + this.currentFromID = (this.currentPage - 1) * maxRecords + 1; + } + pageEnd = Math.ceil(Math.floor(newToID / newMaxRecords) / 10) * 10; + pageEnd = pageEnd === 0 ? 10 : pageEnd; + pageStart = pageEnd - 9; + this.paginationStart = pageStart; + this.paginationEnd = pageEnd; + $("#records-table-body").empty(); + return this.showRecords(this.currentFromID, this.currentToID); + }) + .then(() => { + return this.pageNumbers(pageStart, pageEnd); + }) + .catch(error => { + console.error("Failed when adjusting the window size: ", error); + throw error; + }); + } + + /** When resizing the window. Timeout is put in place so that the function doesn't + * take in every value returned during resizing. */ + resize(): void { + if (this.resizeTimer !== null) { + clearTimeout(this.resizeTimer); + } + this.resizeTimer = setTimeout(() => { + this.adjustDisplayedRecords() + .catch(error => { + console.log("Failed when resizing the window: ", error); + alert("An error occurred while trying to resize the window. Please try again."); + }); + }, 250); + } + + /** Will display when the table is empty and it's busy fetching the records. */ + loader(): void { + let content = $('#records-table-body').text(); + if (content === '') { + $('.results').append('
'); + } else { + $('.loader').css({ 'display': 'none' }); + } + } + + /** Calculate how many records should be displayed according to the screen height. */ + recordsPerPage(): number { + const screenHeight = ($('#records-table-body').height()); + let maxRecords = Math.floor(screenHeight / 68); + return maxRecords; + } + + /** Retrieve the search value from the input even when it's empty. */ + input(): number { + let inputValue = ($('.search-input').val()); + let inputNumber = parseInt(inputValue); + if (isNaN(inputNumber) || inputNumber < 0) { + return -1; + } else { + return inputNumber; + } + } +} + +/** Runs when the web app is started. */ +window.onload = () => { + const dataHandler = new DataHandler(); + dataHandler.showColumns() + .then(() => { + dataHandler.adjustDisplayedRecords(); + }) + .then(() => { + dataHandler.initializePagination(); + dataHandler.initializeSearch(); + }) + .catch(error => { + console.error("Failed when loading the page: ", error); + alert("An error occurred while trying to load your page. Please try again."); + }); + $(window).on('resize', () => { + dataHandler.resize(); + }); +} diff --git a/index.html b/index.html index add5e736..637e903e 100644 --- a/index.html +++ b/index.html @@ -1,13 +1,37 @@ + JS Onboard Project + + + -

Hello

+
+

+
+ +
+
+
+ + + + + + + +
+
+
+ + + +
- diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 00000000..f1ae52be --- /dev/null +++ b/package-lock.json @@ -0,0 +1,28 @@ +{ + "name": "onboard-javascript", + "version": "1.0.0", + "lockfileVersion": 1, + "requires": true, + "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.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..b8b0f798 --- /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", + "dependencies": { + "typescript": "^3.8.3" + }, + "devDependencies": { + "@types/jquery": "^3.5.17" + }, + "scripts": { + "test": "echo \"Error: no test specified\" && exit 1", + "build": "tsc -p .", + "start": "npm run build -- -w" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/MaxTF141/onboard-javascript.git" + }, + "keywords": [], + "author": "", + "license": "ISC", + "bugs": { + "url": "https://github.com/MaxTF141/onboard-javascript/issues" + }, + "homepage": "https://github.com/MaxTF141/onboard-javascript#readme" +} diff --git a/style.css b/style.css new file mode 100644 index 00000000..71d35f72 --- /dev/null +++ b/style.css @@ -0,0 +1,261 @@ +*, +*::before, +*::after { + margin: 0; + padding: 0; + box-sizing: border-box; +} + +body { + background-color: #141A27; + background-attachment: fixed; + height: 100vh; + /* min-height: 68px !important; */ +} + +body::-webkit-scrollbar { + display: none; +} + +h1 { + text-align: center; + font-family: monospace; + font-size: 2.5rem; + color: #4DD2C6; +} + +.results { + font-family: monospace; + font-weight: 500; + font-size: 2rem; + color: #A1AFC2; +} + +.heading { + display: flex; + justify-content: space-between; + align-items: center; + padding: 1rem 2rem; +} + +.table-container { + display: flex; + flex-direction: column; + height: 80vh; + border: 1px solid #4DD2C6; + box-sizing: border-box; + padding: 20px; + overflow: hidden; +} + +.table-container::-webkit-scrollbar { + display: none; +} + +table { + flex-grow: 1; + width: 100%; + border-collapse: collapse; + table-layout: fixed; +} + +th { + font-family: monospace; + font-size: 1rem; + border: 0.5px solid #4DD2C6; + color: #4DD2C6; + text-overflow: ellipsis; + white-space: nowrap; + max-height: 10px; + padding: 2px; +} + +thead { + border: 1px solid #4DD2C6 !important; + background-color: #141A27; + border: 1px solid white !important; +} + +.body-row td { + text-align: center; + font-family: 'Atma', cursive; + color: #A1AFC2; + font-weight: 500; + /* text-overflow: ellipsis; + white-space: nowrap; */ + max-height: 10px; + padding: 2px; +} + +.search-input { + height: 30px; + width: 180px; + border: 0px; + background-color: #ffffff36; + color: white; + font-family: monospace; + letter-spacing: 2px; + padding-left: 10px; +} + +input:focus { + outline: 0px solid black; + outline-offset: 3px; +} + +.wrapper { + position: fixed; + bottom: 0; + left: 50%; + transform: translate(-50%, 0); +} + +.pagination { + display: flex; + justify-content: center; +} + +a { + text-decoration: none; + color: black; + padding: 9px; + border: 0px solid #3b3b3b; + margin: 3px; + background-color: #ffffff36; + box-shadow: inset 0px 0px 20px -10px rgba(255, 255, 255, 0.438); + +} + +.pagination a:hover { + color: white; + background-color: #4DD2C6; + border: 0px solid #4DD2C6; +} + +.active { + color: white !important; + border: 1px solid #4DD2C6 !important; +} + +.next, +.prev { + color: #4DD2C6 !important; +} + +.next:hover, +.prev:hover { + color: white !important; +} + +.header { + height: 5vh; +} + +.heading { + height: 5vh; +} + +.table-container { + height: 80vh; +} + +.wrapper { + height: 10vh; + display: flex; + align-items: center; +} + +.table-container { + display: flex; + flex-direction: column; + height: 80vh; + box-sizing: border-box; + padding: 20px; + overflow: hidden; +} + +table { + flex-grow: 2; + width: 100%; + border-collapse: collapse; + table-layout: fixed; +} + +#records-table-body { + min-height: 68px; +} + +th, +td { + padding: 0.2px; + text-align: center; + /* white-space: nowrap; + text-overflow: ellipsis; */ + max-height: fit-content; +} + +.search-container { + position: relative; +} + +.results-box { + width: 180px; + height: 50px; + background-color: white; + position: absolute; + top: 189; +} + +.results-box { + display: flex; + justify-content: center; + align-items: center; + cursor: pointer; +} + +.message { + text-align: center; +} + +a { + cursor: pointer; +} + +.loader { + border: 4px solid #4DD2C6; + width: 100px; + height: 100px; + border-radius: 50%; + border-right-color: transparent; + animation: rot 1s linear infinite; + box-shadow: 0px 0px 20px #4DD2C6 inset; + position: fixed; + margin: auto; + left: 0; + right: 0; + top: 0; + bottom: 0; +} + +.results-select { + font-family: 'Atma', cursive; + font-size: 1.4rem; +} + +.highlight { + background-color: #FFFF00 !important; + color: black !important; +} + +@keyframes rot { + 100% { + transform: rotate(180deg); + } +} + +@media screen and (max-width: 768px) { + td { + text-overflow: ellipsis !important; + white-space: nowrap !important; + } +}