From 55dc0a9754e6595b3d3530733beeddd0d3d52ec6 Mon Sep 17 00:00:00 2001 From: fcaps Date: Thu, 23 Nov 2023 07:24:00 +0100 Subject: [PATCH] reactivate clan pages --- Gruntfile.js | 3 +- fafApp.js | 5 +- grunt/concurrent.js | 2 +- grunt/copy.js | 24 +++ lib/clan/ClanRepository.js | 135 +++++++++++++ lib/clan/ClanService.js | 43 ++++ lib/clan/ClanServiceFactory.js | 14 ++ package.json | 2 + public/js/app/clan.js | 5 + public/js/app/clans.js | 175 ++++------------ public/js/app/getClans.js | 72 ------- public/styles/site/clans.sass | 231 +++------------------- routes/middleware.js | 7 +- routes/views/clanRouter.js | 16 +- routes/views/dataRouter.js | 18 ++ routes/views/defaultRouter.js | 4 + templates/views/clans.pug | 80 -------- templates/views/clans/clan.pug | 48 +++++ templates/views/clans/clans.pug | 9 + templates/views/clans/seeClan.pug | 27 --- tests/integration/clanRouter.test.js | 52 ++++- tests/integration/testData/clan-http.json | 1 + tests/integration/testData/clan.json | 1 + tests/setup.js | 5 + yarn.lock | 33 +++- 25 files changed, 478 insertions(+), 534 deletions(-) create mode 100644 grunt/copy.js create mode 100644 lib/clan/ClanRepository.js create mode 100644 lib/clan/ClanService.js create mode 100644 lib/clan/ClanServiceFactory.js create mode 100644 public/js/app/clan.js delete mode 100644 public/js/app/getClans.js delete mode 100644 templates/views/clans.pug create mode 100644 templates/views/clans/clan.pug create mode 100644 templates/views/clans/clans.pug delete mode 100644 templates/views/clans/seeClan.pug create mode 100644 tests/integration/testData/clan-http.json create mode 100644 tests/integration/testData/clan.json diff --git a/Gruntfile.js b/Gruntfile.js index 747fb360..ef46fabc 100644 --- a/Gruntfile.js +++ b/Gruntfile.js @@ -23,6 +23,7 @@ module.exports = function(grunt) { grunt.registerTask('prod', [ 'sass:dist', 'concat:js', - 'uglify:dist' + 'uglify:dist', + 'copy' ]); }; diff --git a/fafApp.js b/fafApp.js index c8be2e82..26e66d94 100644 --- a/fafApp.js +++ b/fafApp.js @@ -63,8 +63,7 @@ module.exports.setup = (app) => { app.set('views', 'templates/views') app.set('view engine', 'pug') app.set('port', appConfig.expressPort) - - app.use(middleware.injectServices) + app.use(middleware.initLocals) app.use(express.static('public', { @@ -88,6 +87,8 @@ module.exports.setup = (app) => { })) app.use(passport.initialize()) app.use(passport.session()) + app.use(middleware.injectServices) + app.use(flash()) app.use(middleware.username) app.use(copyFlashHandler) diff --git a/grunt/concurrent.js b/grunt/concurrent.js index 4f0b4a7d..c83f66da 100644 --- a/grunt/concurrent.js +++ b/grunt/concurrent.js @@ -1,6 +1,6 @@ module.exports = { dev: { - tasks: ['nodemon', 'concat', 'watch'], + tasks: ['nodemon', 'concat', 'watch', 'copy'], options: { logConcurrentOutput: true } diff --git a/grunt/copy.js b/grunt/copy.js new file mode 100644 index 00000000..c19015d4 --- /dev/null +++ b/grunt/copy.js @@ -0,0 +1,24 @@ +module.exports = { + js: { + files:[ + { + expand: true, + cwd: 'node_modules/simple-datatables/dist', + src: 'module.js', + dest: 'public/js/', + rename: function(dest, src) { + return dest + 'simple-datatables.js' + } + }, + { + expand: true, + cwd: 'node_modules/simple-datatables/dist', + src: 'style.css', + dest: 'public/styles/css/', + rename: function(dest, src) { + return dest + 'simple-datatables.css' + } + } + ] + } +} diff --git a/lib/clan/ClanRepository.js b/lib/clan/ClanRepository.js new file mode 100644 index 00000000..55fceb14 --- /dev/null +++ b/lib/clan/ClanRepository.js @@ -0,0 +1,135 @@ +class ClanRepository { + constructor(javaApiClient) { + this.javaApiClient = javaApiClient + } + + async fetchAll() { + const response = await this.javaApiClient.get('/data/clan?sort=createTime&include=leader&fields[clan]=name,tag,description,leader,memberships,createTime&fields[player]=login&page[number]=1&page[size]=3000') + + if (response.status !== 200) { + throw new Error('ClanRepository::fetchAll failed with response status "' + response.status + '"') + } + + const responseData = JSON.parse(response.data) + + if (typeof responseData !== 'object' || responseData === null) { + throw new Error('ClanRepository::fetchAll malformed response, not an object') + } + + if (!responseData.hasOwnProperty('data')) { + throw new Error('ClanRepository::fetchAll malformed response, expected "data"') + } + + if (responseData.data.length === 0) { + console.log('[info] clans empty') + + return [] + } + + if (!responseData.hasOwnProperty('included')) { + throw new Error('ClanRepository::fetchAll malformed response, expected "included"') + } + + const clans = responseData.data.map((item, index) => ({ + id: parseInt(item.id), + leaderName: responseData.included[index].attributes.login, + name: item.attributes.name, + tag: item.attributes.tag, + createTime: item.attributes.createTime, + description: item.attributes.description, + population: item.relationships.memberships.data.length + })) + + clans.sort((a, b) => { + if (a.population > b.population) { + return -1 + } + if (a.population < b.population) { + return 1 + } + + return 0 + }); + + return await clans + } + + async fetchClan(id) { + let response = await this.javaApiClient.get(`/data/clan/${id}?include=memberships.player`) + + if (response.status !== 200) { + throw new Error('ClanRepository::fetchClan failed with response status "' + response.status + '"') + } + + const data = JSON.parse(response.data) + + if (typeof data !== 'object' || data === null) { + throw new Error('ClanRepository::fetchClan malformed response, not an object') + } + + if (!data.hasOwnProperty('data')) { + throw new Error('ClanRepository::fetchClan malformed response, expected "data"') + } + + if (typeof data.data !== 'object' || data.data === null) { + return null + } + + if (typeof data.included !== 'object' || data.included === null) { + throw new Error('ClanRepository::fetchClan malformed response, expected "included"') + } + + const clanRaw = data.data.attributes + + const clan = { + id: data.data.id, + name: clanRaw.name, + tag: clanRaw.tag, + description: clanRaw.description, + createTime: clanRaw.createTime, + requiresInvitation: clanRaw.requiresInvitation, + tagColor: clanRaw.tagColor, + updateTime: clanRaw.updateTime, + founder: null, + leader: null, + memberships: {}, + } + + let members = {}; + + for (let k in data.included) { + switch (data.included[k].type) { + case "player": + const player = data.included[k]; + if (!members[player.id]) members[player.id] = {}; + members[player.id].id = player.id; + members[player.id].name = player.attributes.login; + + if (player.id === data.data.relationships.leader.data.id) { + clan.leader = members[player.id] + } + + if (player.id === data.data.relationships.founder.data.id) { + clan.founder = members[player.id] + } + + break; + + case "clanMembership": + const membership = data.included[k]; + const member = membership.relationships.player.data; + if (!members[member.id]) members[member.id] = {}; + members[member.id].id = member.id; + members[member.id].membershipId = membership.id; + members[member.id].joinedAt = membership.attributes.createTime; + break; + } + } + + clan.memberships = members + + return clan + } +} + +module.exports = ClanRepository diff --git a/lib/clan/ClanService.js b/lib/clan/ClanService.js new file mode 100644 index 00000000..d96ebcc3 --- /dev/null +++ b/lib/clan/ClanService.js @@ -0,0 +1,43 @@ +const {MutexService} = require("../MutexService"); +const clanTTL = 60 * 5 + +class ClanService { + constructor(cacheService, clanRepository, lockTimeout = 3000) { + this.lockTimeout = lockTimeout + this.cacheService = cacheService + this.mutexService = new MutexService() + this.clanRepository = clanRepository + } + + getCacheKey(name) { + return 'ClanService_' + name + } + + async getClan(id) { + return this.clanRepository.fetchClan(id) + } + + async getAll() { + + const cacheKey = this.getCacheKey('all') + + if (this.cacheService.has(cacheKey)) { + return this.cacheService.get(cacheKey) + } + + if (this.mutexService.locked) { + await this.mutexService.acquire(() => { + }, this.lockTimeout) + return this.getAll() + } + + await this.mutexService.acquire(async () => { + const result = await this.clanRepository.fetchAll() + this.cacheService.set(cacheKey, result, clanTTL); + }) + + return this.getAll() + } +} + +module.exports = ClanService diff --git a/lib/clan/ClanServiceFactory.js b/lib/clan/ClanServiceFactory.js new file mode 100644 index 00000000..c75f585d --- /dev/null +++ b/lib/clan/ClanServiceFactory.js @@ -0,0 +1,14 @@ +const ClanService = require("./ClanService") +const ClanRepository = require("./ClanRepository") +const {Axios} = require("axios") +const cacheService = require('../CacheService') + +module.exports = (javaApiBaseURL, token) => { + const config = { + baseURL: javaApiBaseURL, + headers: {Authorization: `Bearer ${token}`} + }; + const clanClient = new Axios(config) + + return new ClanService(cacheService, new ClanRepository(clanClient)) +} diff --git a/package.json b/package.json index 70591b1d..e2e611a1 100644 --- a/package.json +++ b/package.json @@ -11,6 +11,7 @@ "express": "^4.18.1", "express-session": "^1.17.3", "express-validator": "7.0.1", + "grunt-contrib-copy": "^1.0.0", "moment": "^2.29.4", "node-cache": "^5.1.2", "node-fetch": "^2.6.7", @@ -21,6 +22,7 @@ "request": "2.88.2", "session-file-store": "^1.5.0", "showdown": "^2.1.0", + "simple-datatables": "^8.0.1", "supertest-session": "^5.0.1", "url-slug": "^4.0.1" }, diff --git a/public/js/app/clan.js b/public/js/app/clan.js new file mode 100644 index 00000000..e6475474 --- /dev/null +++ b/public/js/app/clan.js @@ -0,0 +1,5 @@ +import('/js/simple-datatables.js').then(({DataTable}) => { + new DataTable("#clan-members", { + perPageSelect: null + }) +}) diff --git a/public/js/app/clans.js b/public/js/app/clans.js index 9ddd1faa..b26faa96 100644 --- a/public/js/app/clans.js +++ b/public/js/app/clans.js @@ -1,146 +1,37 @@ -//Variables used across the board -let pageNumber = 0; -let lastPage = 5; -let clanList = []; -let timedOutPlayers = []; -let currentClan = ''; +import('/js/simple-datatables.js').then(({DataTable}) => { + fetch('/data/clans.json') + .then(response => response.json()) + .then(data => { + if (!data || !data.length) { + return + } + + window.datatable = new DataTable("#clan-table", { + perPageSelect: null, + data: { + headings: [ + 'TAG', + 'NAME', + 'LEADER', + 'POPULATION', + ], + data: data.map(item => { + return [item.tag, item.name, item.leaderName, item.population] + }) + }}) + + window.datatable.on("datatable.selectrow", (rowIndex, event) => { + if (typeof rowIndex === "number") { + event.preventDefault() + window.location.href = '/clans/view/' + data[rowIndex].id + } + }) + }) + .catch((err) => { + console.log(err, 'loading clans failed') + }) +}) -let clanListDivided = clanList.length / 100; -// This decides the time filter -let d = new Date(); -let timeFilter = 6; // 6 Months is the default value -let minusTimeFilter = d.setMonth(d.getMonth() - timeFilter); -let currentDate = new Date(minusTimeFilter).toISOString(); - - -//Names of buttons -async function clanOneJSON() { - //Check which category is active - const response = await fetch(`js/app/members/getAllClans.json`); - - const data = await response.json(); - - clanList = data; - - return await data; -} - -clanOneJSON(); - - -//Updates the clan according to what is needed -function clanUpdate() { - // We convert clanList into a string to find out how many pages we have. We can find so by checking the first two digits. In other words, if we have 1349 players, then we have 13 pages. However, if we have 834, we have 8 pages (only take the first digit). - clanListDivided = clanList.length / 100; - lastPage = Math.floor(clanListDivided); - - //Deletes everything with the class clanDelete, we do it to delete all the previous clan and add the new one back in. - let clanDelete = document.querySelectorAll('.clanDelete'); - clanDelete.forEach(clanDelete => { - clanDelete.remove(); - }); - - //determines the current page, whether to add or substract the missing players in case we pressed next or previous then it will add or substract players - let clanIndex = (clanList.length - 100) - (pageNumber * 100); //- addNextPlayer; - let next100Players = clanList.length - (pageNumber * 100); - - for (clanIndex; clanIndex < next100Players; clanIndex++) { - if (clanIndex < 0) { - clanIndex = 0; - console.log('There are no more players left.'); - } - // Gets the player data and inserts it into the li element - - - document.getElementById('clanPlayer').insertAdjacentHTML('afterbegin', `
  • ${clanList[clanIndex][0].leaderName}
  • `); - document.getElementById('clanName').insertAdjacentHTML('afterbegin', `
  • ${clanList[clanIndex][1].name}
  • `); - document.getElementById('clanTAG').insertAdjacentHTML('afterbegin', `
  • ${clanList[clanIndex][1].tag}
  • `); - - document.getElementById('clanPopulation').insertAdjacentHTML('afterbegin', `
  • ${clanList[clanIndex][1].population}
  • `); - - } -} - -//This function triggers when the next, previous, first or last button are clicked -let pageButton = document.querySelectorAll('.pageButton'); - -function pageChange(newPageNumber) { - pageNumber = newPageNumber; - pageButton.forEach(element => element.classList.remove(`exhaustedButton`)); - if (pageNumber === 0) { - //You see 4-7 pageButton because there are a total of 8 buttons counting the ones at the bottom of the page - pageButton[0].classList.add('exhaustedButton'); - pageButton[2].classList.add('exhaustedButton'); - pageButton[4].classList.add('exhaustedButton'); - pageButton[6].classList.add('exhaustedButton'); - } - if (pageNumber === lastPage) { - pageButton[1].classList.add('exhaustedButton'); - pageButton[3].classList.add('exhaustedButton'); - pageButton[5].classList.add('exhaustedButton'); - pageButton[7].classList.add('exhaustedButton'); - } - clanUpdate(); -} - -// Don't know why but the code refuses to run correctly unless it is delayed by at least 1/10 of a second/100ms. Not really an issue but idk why its doing this -setTimeout(() => { - clanUpdate(); -}, 1000); - - -// SEARCH BAR - - -//Gets called from the HTML search input form -function pressEnter(event) { - let inputText = event.target.value; - // this regex grabs the current input and due to the ^, it selects whatever starts with the input, so if you type Te, Tex will show up. - if (inputText === '') { - document.querySelectorAll('.removeOldSearch').forEach(element => element.remove()); - } else { - let regex = `^${inputText.toLowerCase()}`; - let searchName = clanList.filter(element => element[1].tag.toLowerCase().match(regex)); - - document.querySelectorAll('.removeOldSearch').forEach(element => element.remove()); - for (let player of searchName.slice(0, 5)) { - document.querySelector('#placeMe').insertAdjacentHTML('afterend', `
  • ${player[1].tag}
  • `); - } - - if (event.key === 'Enter') { - - findPlayer(inputText); - } - } - document.querySelector('#errorLog').innerText = ''; - -} - -function findPlayer(userInput) { - clanOneJSON(currentClan) - .then(() => { - //input from the searchbar becomes clanName and then searchClan is their index number - let searchClan = clanList.findIndex(element => element[1].tag.toLowerCase() === userInput.toLowerCase()); - if (searchClan !== -1) { - window.location.href = `/clans/${userInput}`; - } else { - throw new Error('clan couldnt be found'); - } - - - document.querySelector('#errorLog').innerText = ``; - }).catch(() => { - document.querySelector('#errorLog').innerText = `Clan "${userInput}" couldn't be found`; - }); -} - -// SEACRH AND CLEAR BUTTONS -document.querySelector('#clearSearch').addEventListener('click', () => { - document.querySelector('#searchResults').classList.add('appearWhenSearching'); - document.querySelector('#clearSearch').classList.add('appearWhenSearching'); - let clanDelete = document.querySelectorAll('.clanDeleteSearch'); - clanDelete.forEach(element => element.remove()); -}); diff --git a/public/js/app/getClans.js b/public/js/app/getClans.js deleted file mode 100644 index f34fec5e..00000000 --- a/public/js/app/getClans.js +++ /dev/null @@ -1,72 +0,0 @@ -const clanName = document.getElementById('clanName'); -const clanTag = document.getElementById('clanTag'); -const clanDescription = document.getElementById('clanDescription'); -const clanCreation = document.getElementById('clanCreation'); -const clanLeader = document.getElementById('clanLeader'); -const clanMembers = document.getElementById('clanMembers'); - - -let leaderName = ''; - -async function getClan() { - - //So here we check the tag of the clan in the url - let url = window.location.href; - const sliceIndicator = url.indexOf('/clans'); -// The slice has + 7 because thats the amount of characters in "/clans/" yes with two /, not one! - let findClanTag = url.slice(sliceIndicator + 7, sliceIndicator + 10); - let clanTag = await findClanTag.replace(/\?m|\?/gm,''); - - // We compare the url TAG with the TAGS available in getAllClans and find the clan leader this way - - // TODO: Change this hardcoded url into something with env - const response = await fetch(`https://api.faforever.com/data/clan?include=memberships.player&filter=tag==${clanTag}`); - const fetchData = await response.json(); - - const leaderID = fetchData.data[0].relationships.leader.data.id; - - fetchData.included.forEach((element, index) => { - if (index % 2 !== 0) { - if (element.id === leaderID) { - leaderName = element.attributes.login; - } - } - }); - - - //verifies if user is a member, which allows them to leave the clan - const clanMember = document.getElementById('iAmMember'); - const isMember = url.indexOf('?member'); - let verifyMembership = url.slice(isMember + 8); - if (verifyMembership === 'true') { - clanMember.style.display = 'block'; - } - return fetchData; -} -setTimeout( ()=> { - getClan() - .then(fetchData => { - - const { attributes} = fetchData.data[0]; - clanName.insertAdjacentHTML('afterbegin', - `${attributes.name}`); - clanDescription.insertAdjacentHTML('afterbegin', - `${attributes.description}`); - clanTag.insertAdjacentHTML('afterbegin', - `Welcome to "${attributes.tag}"`); - //clanLeader.insertAdjacentHTML('afterbegin', - // `${fetchData.data[0].attributes.id}`); - clanCreation.insertAdjacentHTML('afterbegin', - `Created on ${attributes.createTime.slice(0, 10)}`); - clanLeader.insertAdjacentHTML('afterbegin', - `Led by ${leaderName}`); - - for (let i = 0; i < fetchData.included.length; i++) { - if (i % 2 !== 0) { - clanMembers.insertAdjacentHTML('afterbegin', - `
  • ${fetchData.included[i].attributes.login}
  • `); - } - } - }); - -},750); diff --git a/public/styles/site/clans.sass b/public/styles/site/clans.sass index d3f94d76..7cd0594f 100644 --- a/public/styles/site/clans.sass +++ b/public/styles/site/clans.sass @@ -1,204 +1,35 @@ @use '../abstracts/variables' @use '../abstracts/mixins' -// Background of the whole thing -.clanBackground - padding: 7.5vw 11vw - background-image: url("/../../images/fafbgpattern0.5.jpg") - ul - list-style: none - li - padding: 0.5em 0 - -// This is the searchbar input form -input - margin: 1em 1em 2px 1em - background-color: variables.$background-tertiary - color: variables.$color-active - font-size: var(--paragraph-font-size) - border: 0.5em solid variables.$background-secondary - border-radius: 10px - z-index: 2 - position: relative - - - -// The main box with all the names, tags, ranks, etc. -.mainClanContainer - @include mixins.gridMainContainer - padding: 0 - p - padding: 0.2em - -.clanContainer - font-family: variables.$family-paragraph - padding: 0em - .clanItem - - ul - font-size: var(--paragraph-font-size) - padding: 1em 0 - font-weight: 700 - - // ACTUAL LISTS - // There is a double li element here because the js creates a li parent element and then puts another li child element (which the child is all the names you see like ANZ and so on) - li - list-style: none - font-weight: normal - padding: 0em 0 - li - font-size: var(--paragraph-font-size) - padding: 1em 0 - -.clanBorder - border: 1px solid variables.$color-active - padding: 0px - background-color: variables.$background-primary - -// Change background colors on items in the leaderboard -.clanItem - li:nth-child(odd) - background-color: mix(variables.$background-tertiary, variables.$background-primary, 25%) - li:nth-child(even) - background-color: variables.$background-secondary - -// First page last page, etc -.clanButton - padding: 1em 0.25em - display: inline-block - place-items: start - font-family: variables.$family-paragraph - font-size: var(--paragraph-font-size) - letter-spacing: 0em - ul - padding: 0.5em - font-weight: 900 - li - - display: inline-block - padding: 0.6em 1em - border: 0.05em solid variables.$color-inactive - background-color: variables.$background-tertiary - cursor: pointer - transition: 0.4s - &:hover - border: 0.05em solid variables.$color-active - filter: brightness(2) - .exhaustedButton - border: 0.05em solid variables.$color-inactive - background-color: variables.$background-secondary - color: variables.$color-inactive-plus - pointer-events: none - cursor: default - -#searchResults - margin: 1em 0 0 0 -.appearWhenSearching - display: none -.centerYourself - display: grid - justify-items: center - position: relative - -.searchBar - ul - background-color: variables.$background-secondary - color: variables.$color-inactive - font-size: calc(var(--paragraph-font-size) * 0.9) - border-radius: 0 0 10px 10px +#clan-view text-align: left - li - padding: 0.5em 1em - -.clearButton - li - cursor: pointer - margin: 1em 1em 0 1em - border-radius: 10px - background-color: variables.$background-tertiary - color: variables.$color-inactive - transition: 0.4s - &:hover - background-color: variables.$color-inactive-plus - color: variables.$color-active - .fa-trash-alt - padding: 0.5em - -@media (max-width: 800px) - .clanFilter - font-size: 10px - .clanContainer - ul - font-size: 10px - padding: 1em 0 0 0 - - li - text-overflow: ellipsis - font-size: 10px - padding: 1em 0 - - .clearButton - left: 90% - top: 68% - .clanBackground - padding: 1vw - .clanButton - padding: 1vw - font-size: var(--paragraph-font-size) - ul - li - padding: 0.65em - .clanDelete - font-size: 10px - - -@media (max-width: 1600px) - .clanContainer - ul - font-size: 14px - li - font-size: 14px - .clanFilter - font-size: 14px - - - -// Below are classes that depend -.clanManagement - @include mixins.gridMainContainer - padding: 0vw 15vw - margin: 1em 0 - .clanManagementItem - padding: 1.5em 0 - display: inline-block - justify-self: center - align-self: center - - //clan Description - textarea - width: 40vw - - .clanManagementDanger - background-color: variables.$Cybran-dark - border-radius: 20px - ul - text-align: center - li - display: inline-block - list-style: none - text-align: left - .clanManagementTable - padding: 1.5em - display: inline-block - justify-self: center - align-self: center - th - padding: 4em - -@media (max-width: 800px) - .clanManagement - padding: 5px -.bigButton - font-size: 1.25em - - + margin: 20px 60px +#clan-info + text-align: left + td + padding: 10px +#clan-table + button + margin: 0 + padding: 0 + .datatable-sorter::before + border-top: 4px solid white + .datatable-sorter::after + border-bottom: 4px solid white + tbody + tr:hover + background-color: #1f2349 + cursor: pointer + td + text-align: left +#clan-members + button + margin: 0 + padding: 0 + .datatable-sorter::before + border-top: 4px solid white + .datatable-sorter::after + border-bottom: 4px solid white + tbody + td + text-align: left diff --git a/routes/middleware.js b/routes/middleware.js index 79391dbf..b98eb057 100755 --- a/routes/middleware.js +++ b/routes/middleware.js @@ -1,4 +1,5 @@ -const WordpressServiceFactory = require("../lib/WordpressServiceFactory"); +const WordpressServiceFactory = require("../lib/WordpressServiceFactory") +const ClanServiceFactory = require("../lib/clan/ClanServiceFactory") const appConfig = require("../config/app"); const wordpressService = WordpressServiceFactory(appConfig.wordpressUrl) @@ -43,6 +44,10 @@ exports.injectServices = function(req, res, next) { req.services = { wordpressService: wordpressService } + + if (req.isAuthenticated()) { + req.services.clanService = ClanServiceFactory(appConfig.apiUrl, req.user.data.attributes.token) + } next() } diff --git a/routes/views/clanRouter.js b/routes/views/clanRouter.js index 5000277f..7a742602 100644 --- a/routes/views/clanRouter.js +++ b/routes/views/clanRouter.js @@ -1,7 +1,17 @@ -const express = require('express'); -const router = express.Router(); +const express = require('express') +const router = express.Router() +const middlewares = require('../middleware') -// This will be replaced soon, therefor I did not spend time on it +router.get('/', middlewares.isAuthenticated(), (req, res) => res.render('clans/clans')) +router.get('/view/:id', middlewares.isAuthenticated(), async (req, res) => { + const clanId = parseInt(req.params.id || null) + + if (!clanId) { + return res.redirect('/clans') + } + + return res.render('clans/clan', {clan: await req.services.clanService.getClan(clanId)}) +}) router.get('*', (req, res) => res.status(503).render('errors/503-known-issue')); module.exports = router diff --git a/routes/views/dataRouter.js b/routes/views/dataRouter.js index 8b0faeec..9b1735b5 100644 --- a/routes/views/dataRouter.js +++ b/routes/views/dataRouter.js @@ -33,6 +33,24 @@ router.get('/content-creators.json', async (req, res) => { getData(req, res, 'content-creators', await req.services.wordpressService.getContentCreators()) }) +router.get('/clans.json', async (req, res) => { + try { + return res.json(await req.services.clanService.getAll()) + } catch (e) { + if (e instanceof AcquireTimeoutError) { + return res.status(503).json({error: 'timeout reached'}) + } + + console.error('[error] dataRouter::get:clans.json failed with "' + e.toString() + '"') + + if (!res.headersSent) { + return res.status(500).json({error: 'unexpected error'}) + } + + throw e + } +}) + module.exports = router diff --git a/routes/views/defaultRouter.js b/routes/views/defaultRouter.js index 2ec30e28..b96bc455 100644 --- a/routes/views/defaultRouter.js +++ b/routes/views/defaultRouter.js @@ -13,6 +13,10 @@ router.get('/contribution', (reqd, res) => res.render('contribution')) router.get('/content-creators', (reqd, res) => res.render('content-creators')) router.get('/play', (reqd, res) => res.render('play')) +// redirect for the game-client https://github.com/FAForever/website/issues/459 +router.get('/clan/:id', (req, res) => { + res.redirect('/clans/view/' + req.params.id) +}) // https://github.com/search?q=org%3AFAForever+account_activated&type=code router.get('/account_activated', (req, res) => res.redirect('/account/register')) diff --git a/templates/views/clans.pug b/templates/views/clans.pug deleted file mode 100644 index 8e40ff6b..00000000 --- a/templates/views/clans.pug +++ /dev/null @@ -1,80 +0,0 @@ -extends ../layouts/default -include ../mixins/flash-messages -block bannerMixin - -block content - - .clanBackground - .mainClanContainer - .clanContainer.column12.centerYourself - +flash-messages(flash) - p Search FAF Clans - #errorLog - input#input(onkeyup=`pressEnter(event)` type='text' placeholder='Clan TAG') - #searchbar - .searchBar - ul - #placeMe - ul#clearSearch.clearButton.appearWhenSearching - li.fas.fa-trash-alt - .mainClanContainer.clanBorder#searchResults.appearWhenSearching - - .clanContainer.column6 - .clanItem - ul Clan Name - li#clanNameSearch - .clanContainer.column1 - .clanItem - ul TAG - li#clanTAGSearch - .clanContainer.column3 - .clanItem - ul Clan Leader - li#clanPlayerSearch - .clanContainer.column2 - .clanItem - ul Population - li#clanPopulationSearch - .mainClanContainer - - //DO NOT Change the order of these without changing the js. For them to work, they need to be in this specific order - .clanContainer.clanButton.column12 - ul - li(onclick= `pageChange(0)`).pageButton.exhaustedButton First - li(onclick= `pageChange(lastPage)`).pageButton Last - ul - li(onclick= `pageChange(pageNumber - 1)`).pageButton.exhaustedButton Previous - li(onclick= `pageChange(pageNumber + 1)`).pageButton Next - - .mainClanContainer.clanBorder - - - .clanContainer.column6 - .clanItem - ul Clan Name - li#clanName - .clanContainer.column1 - .clanItem - ul TAG - li#clanTAG - .clanContainer.column3 - .clanItem - ul#spawnPlayer Clan Leader - li#clanPlayer - .clanContainer.column2 - .clanItem - ul Population - li#clanPopulation - .mainClanContainer - .clanContainer.clanButton.column12 - ul - li(onclick= `pageChange(0)`).pageButton.exhaustedButton First - li(onclick= `pageChange(lastPage)`).pageButton Last - ul - li(onclick= `pageChange(pageNumber - 1)`).pageButton.exhaustedButton Previous - li(onclick= `pageChange(pageNumber + 1)`).pageButton Next - -block js - - script( src="../../js/app/clans.js") - diff --git a/templates/views/clans/clan.pug b/templates/views/clans/clan.pug new file mode 100644 index 00000000..e20c94b2 --- /dev/null +++ b/templates/views/clans/clan.pug @@ -0,0 +1,48 @@ +extends ../../layouts/default +block head + link(rel='stylesheet' href='/styles/css/simple-datatables.css') +block content + #clan-view + a(href="/clans") <- OVERVIEW +
    +
    + .gridMainContainer + .column4 + table#clan-info + tr + td NAME + td #{clan.name} + tr + td TAG + td #{clan.tag} + tr + td LEADER + td #{clan.leader.name} 👑 + tr + td FOUNDER + if clan.founder + td #{clan.founder.name} + else + td GONE + tr + td JOIN + if clan.requiresInvitation + td Invitation Only + else + td Free For All + .column8 + h2 Description + div #{clan.description} + .column12 + table#clan-members + thead + tr + th NAME + th JOINED AT + tbody + each member in clan.memberships + tr + td #{member.name} + td #{member.joinedAt} +block js + script( src="../../js/app/clan.js" type="module") diff --git a/templates/views/clans/clans.pug b/templates/views/clans/clans.pug new file mode 100644 index 00000000..dd5c6783 --- /dev/null +++ b/templates/views/clans/clans.pug @@ -0,0 +1,9 @@ +extends ../../layouts/default + +block head + link(rel='stylesheet' href='/styles/css/simple-datatables.css') +block content + h1 Clans + table#clan-table +block js + script( src="../../js/app/clans.js" type="module") diff --git a/templates/views/clans/seeClan.pug b/templates/views/clans/seeClan.pug deleted file mode 100644 index cd8c3d08..00000000 --- a/templates/views/clans/seeClan.pug +++ /dev/null @@ -1,27 +0,0 @@ -extends ../../layouts/default -block bannerMixin - -block content - // Most of this page is generated through its js file, this pug file is just used to put the ids in place - .renderClan - .renderClanContainer.column12 - h2#clanTag - h1#clanName - p#clanDescription - p#clanCreation - h1#clanLeader - - - #iAmMember - form(method='post', action="/clans/leave", onsubmit="return confirm('You will not be able to return in that clan unless invited again. Press OK to confim.');") - input(type='hidden', name='clan_id', value=clan_id) - input(type='hidden', name='membership_id', value=my_membership) - h2.row.centered-flex - button(type='submit').danger.btn.btn-lg Leave my clan - p Clan Members - - ul.renderClanSubGrid#clanMembers - - -block js - script( src="../../js/app/getClans.js") diff --git a/tests/integration/clanRouter.test.js b/tests/integration/clanRouter.test.js index 90ff00af..5079a7f4 100644 --- a/tests/integration/clanRouter.test.js +++ b/tests/integration/clanRouter.test.js @@ -1,21 +1,65 @@ const express = require('express') const supertestSession = require("supertest-session"); const fafApp = require('../../fafApp') +const passportMock = require("../helpers/PassportMock"); let testSession = null beforeEach(async () => { const app = new express() fafApp.setup(app) + passportMock(app, {passAuthentication: true}) fafApp.loadRouters(app) testSession = supertestSession(app) }) describe('Clan Routes', function () { - const arr = ['/clans', '/clans/everything'] - test.each(arr)("responds with 503 to %p", (async (route) => { - const res = await testSession.get(route) + test('clan list is protected', async () => { + let response = await testSession.get('/clans') + expect(response.statusCode).toBe(302) + expect(response.headers.location).toBe('/login') + + await testSession.get('/mock-login') + + response = await testSession.get('/clans') + expect(response.statusCode).toBe(200) + expect(response.text).toContain('Clans'); + }) + + test('clan list can be accessed with user', async () => { + const response = await testSession.get('/clans') + expect(response.statusCode).toBe(302) + expect(response.headers.location).toBe('/login') + }) + + test('clan view is protected', async () => { + let response = await testSession.get('/clans/view/1') + expect(response.statusCode).toBe(302) + expect(response.headers.location).toBe('/login') + + await testSession.get('/mock-login') + + response = await testSession.get('/clans/view/148') + expect(response.statusCode).toBe(200) + expect(response.text).toContain('STS-Clan'); + }) + + test('clan view redirects on shitty id', async () => { + await testSession.get('/mock-login') + let response = await testSession.get('/clans/view/not-a-number') + expect(response.statusCode).toBe(302) + expect(response.headers.location).toBe('/clans') + }) + + test('game-client clan view link is redirected', async () => { + const response = await testSession.get('/clan/1') + expect(response.statusCode).toBe(302) + expect(response.headers.location).toBe('/clans/view/1') + }) + + test("responds with 503 to other clan routes", async () => { + const res = await testSession.get('/clans/everything-else') expect(res.statusCode).toBe(503) expect(res.text).toContain('Sorry commanders, we failed to build enough pgens and are now in a tech upgrade'); - })) + }) }) diff --git a/tests/integration/testData/clan-http.json b/tests/integration/testData/clan-http.json new file mode 100644 index 00000000..35d18287 --- /dev/null +++ b/tests/integration/testData/clan-http.json @@ -0,0 +1 @@ +{"data":{"type":"clan","id":"148","attributes":{"createTime":"2014-04-22T14:52:03Z","description":"This is the old GPG-Net STS-Clan. all german people can join :) \n\n","name":"STS-Clan","requiresInvitation":true,"tag":"STS","tagColor":null,"updateTime":"2014-04-22T14:52:03Z","websiteUrl":"http://localhost:8096/clan/148"},"relationships":{"founder":{"data":{"type":"player","id":"33328"}},"leader":{"data":{"type":"player","id":"33328"}},"memberships":{"data":[{"type":"clanMembership","id":"6782"},{"type":"clanMembership","id":"6783"},{"type":"clanMembership","id":"6784"},{"type":"clanMembership","id":"6785"},{"type":"clanMembership","id":"6786"},{"type":"clanMembership","id":"6787"},{"type":"clanMembership","id":"6788"},{"type":"clanMembership","id":"6789"}]}}},"included":[{"type":"clanMembership","id":"6789","attributes":{"createTime":"2014-05-27T20:23:33Z","updateTime":"2017-05-15T17:48:35Z"},"relationships":{"clan":{"data":{"type":"clan","id":"148"}},"player":{"data":{"type":"player","id":"102475"}}}},{"type":"player","id":"102475","attributes":{"createTime":"2014-05-27T20:56:05Z","login":"DragoonTT","updateTime":"2023-10-21T21:18:37Z","userAgent":null},"relationships":{"avatarAssignments":{"data":[]},"bans":{"data":[]},"clanMembership":{"data":{"type":"clanMembership","id":"6789"}},"names":{"data":[]},"userNotes":{"data":[]}}},{"type":"clanMembership","id":"6782","attributes":{"createTime":"2014-04-22T14:52:03Z","updateTime":"2017-05-15T17:48:35Z"},"relationships":{"clan":{"data":{"type":"clan","id":"148"}},"player":{"data":{"type":"player","id":"33328"}}}},{"type":"player","id":"33328","attributes":{"createTime":"2012-10-06T18:03:04Z","login":"MVN050","updateTime":"2023-10-21T21:18:37Z","userAgent":"faf-client"},"relationships":{"avatarAssignments":{"data":[]},"bans":{"data":[]},"clanMembership":{"data":{"type":"clanMembership","id":"6782"}},"names":{"data":[]},"userNotes":{"data":[]}}},{"type":"clanMembership","id":"6783","attributes":{"createTime":"2014-04-23T20:25:28Z","updateTime":"2017-05-15T17:48:35Z"},"relationships":{"clan":{"data":{"type":"clan","id":"148"}},"player":{"data":{"type":"player","id":"2719"}}}},{"type":"player","id":"2719","attributes":{"createTime":"2012-07-05T12:22:02Z","login":"Bubble678","updateTime":"2023-10-21T21:18:37Z","userAgent":"downlords-faf-client"},"relationships":{"avatarAssignments":{"data":[]},"bans":{"data":[]},"clanMembership":{"data":{"type":"clanMembership","id":"6783"}},"names":{"data":[{"type":"nameRecord","id":"19886"}]},"userNotes":{"data":[]}}},{"type":"clanMembership","id":"6784","attributes":{"createTime":"2014-04-23T20:28:35Z","updateTime":"2017-05-15T17:48:35Z"},"relationships":{"clan":{"data":{"type":"clan","id":"148"}},"player":{"data":{"type":"player","id":"2722"}}}},{"type":"player","id":"2722","attributes":{"createTime":"2012-07-05T12:13:24Z","login":"GeneralMajor","updateTime":"2023-10-21T21:18:37Z","userAgent":null},"relationships":{"avatarAssignments":{"data":[]},"bans":{"data":[]},"clanMembership":{"data":{"type":"clanMembership","id":"6784"}},"names":{"data":[{"type":"nameRecord","id":"4341"},{"type":"nameRecord","id":"4467"}]},"userNotes":{"data":[]}}},{"type":"clanMembership","id":"6785","attributes":{"createTime":"2014-04-27T14:02:01Z","updateTime":"2017-05-15T17:48:35Z"},"relationships":{"clan":{"data":{"type":"clan","id":"148"}},"player":{"data":{"type":"player","id":"79436"}}}},{"type":"player","id":"79436","attributes":{"createTime":"2013-11-15T18:39:17Z","login":"PWD_Lux","updateTime":"2023-10-21T21:18:37Z","userAgent":null},"relationships":{"avatarAssignments":{"data":[]},"bans":{"data":[]},"clanMembership":{"data":{"type":"clanMembership","id":"6785"}},"names":{"data":[]},"userNotes":{"data":[]}}},{"type":"clanMembership","id":"6786","attributes":{"createTime":"2014-04-28T19:57:43Z","updateTime":"2017-05-15T17:48:35Z"},"relationships":{"clan":{"data":{"type":"clan","id":"148"}},"player":{"data":{"type":"player","id":"33228"}}}},{"type":"player","id":"33228","attributes":{"createTime":"2012-10-06T18:02:58Z","login":"Totalschaden","updateTime":"2023-10-21T21:18:37Z","userAgent":"downlords-faf-client"},"relationships":{"avatarAssignments":{"data":[]},"bans":{"data":[]},"clanMembership":{"data":{"type":"clanMembership","id":"6786"}},"names":{"data":[{"type":"nameRecord","id":"1248"},{"type":"nameRecord","id":"1530"},{"type":"nameRecord","id":"1818"}]},"userNotes":{"data":[]}}},{"type":"clanMembership","id":"6787","attributes":{"createTime":"2014-05-06T17:04:19Z","updateTime":"2017-05-15T17:48:35Z"},"relationships":{"clan":{"data":{"type":"clan","id":"148"}},"player":{"data":{"type":"player","id":"98228"}}}},{"type":"player","id":"98228","attributes":{"createTime":"2014-04-19T19:45:30Z","login":"Cmd_Matrix","updateTime":"2023-10-21T21:18:37Z","userAgent":"downlords-faf-client"},"relationships":{"avatarAssignments":{"data":[]},"bans":{"data":[]},"clanMembership":{"data":{"type":"clanMembership","id":"6787"}},"names":{"data":[]},"userNotes":{"data":[]}}},{"type":"clanMembership","id":"6788","attributes":{"createTime":"2014-05-11T12:58:54Z","updateTime":"2017-05-15T17:48:35Z"},"relationships":{"clan":{"data":{"type":"clan","id":"148"}},"player":{"data":{"type":"player","id":"56082"}}}},{"type":"player","id":"56082","attributes":{"createTime":"2014-05-05T19:20:03Z","login":"Fleischie","updateTime":"2023-10-21T21:18:37Z","userAgent":null},"relationships":{"avatarAssignments":{"data":[]},"bans":{"data":[]},"clanMembership":{"data":{"type":"clanMembership","id":"6788"}},"names":{"data":[]},"userNotes":{"data":[]}}}]} \ No newline at end of file diff --git a/tests/integration/testData/clan.json b/tests/integration/testData/clan.json new file mode 100644 index 00000000..9bac2a91 --- /dev/null +++ b/tests/integration/testData/clan.json @@ -0,0 +1 @@ +{"id":"148","name":"STS-Clan","tag":"STS","description":"This is the old GPG-Net STS-Clan. all german people can join :) \n\n","createTime":"2014-04-22T14:52:03Z","requiresInvitation":true,"tagColor":null,"updateTime":"2014-04-22T14:52:03Z","founder":{"id":"33328","membershipId":"6782","joinedAt":"2014-04-22T14:52:03Z","name":"MVN050"},"leader":{"id":"33328","membershipId":"6782","joinedAt":"2014-04-22T14:52:03Z","name":"MVN050"},"memberships":{"2719":{"id":"2719","membershipId":"6783","joinedAt":"2014-04-23T20:25:28Z","name":"Bubble678"},"2722":{"id":"2722","membershipId":"6784","joinedAt":"2014-04-23T20:28:35Z","name":"GeneralMajor"},"33228":{"id":"33228","membershipId":"6786","joinedAt":"2014-04-28T19:57:43Z","name":"Totalschaden"},"33328":{"id":"33328","membershipId":"6782","joinedAt":"2014-04-22T14:52:03Z","name":"MVN050"},"56082":{"id":"56082","membershipId":"6788","joinedAt":"2014-05-11T12:58:54Z","name":"Fleischie"},"79436":{"id":"79436","membershipId":"6785","joinedAt":"2014-04-27T14:02:01Z","name":"PWD_Lux"},"98228":{"id":"98228","membershipId":"6787","joinedAt":"2014-05-06T17:04:19Z","name":"Cmd_Matrix"},"102475":{"id":"102475","membershipId":"6789","joinedAt":"2014-05-27T20:23:33Z","name":"DragoonTT"}}} \ No newline at end of file diff --git a/tests/setup.js b/tests/setup.js index 3790bde5..3f649710 100644 --- a/tests/setup.js +++ b/tests/setup.js @@ -1,6 +1,7 @@ const fs = require("fs"); const wordpressService = require("../lib/WordpressService"); const leaderboardService = require("../lib/LeaderboardService"); +const clanService = require("../lib/clan/ClanService"); beforeEach(() => { const newsFile = JSON.parse(fs.readFileSync('tests/integration/testData/news.json',{encoding:'utf8', flag:'r'})) jest.spyOn(wordpressService.prototype, 'getNews').mockResolvedValue(newsFile); @@ -31,6 +32,10 @@ beforeEach(() => { throw new Error('do we need to change the mock?') }) + + jest.spyOn(clanService.prototype, 'getClan').mockImplementation((id) => { + return JSON.parse(fs.readFileSync('tests/integration/testData/clan.json',{encoding:'utf8', flag:'r'})) + }) }) afterEach(() => { diff --git a/yarn.lock b/yarn.lock index b8fea215..b44ae9f7 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1449,7 +1449,7 @@ caseless@~0.12.0: resolved "https://registry.yarnpkg.com/caseless/-/caseless-0.12.0.tgz#1b681c21ff84033c826543090689420d187151dc" integrity sha512-4tYFyifaFfGacoiObjJegolkwSU4xQNGbVgUiNYVUxbQ2x2lUsFvY4hVgVzGiIe6WLOPqycWXA40l+PWsxthUw== -chalk@^1.0.0: +chalk@^1.0.0, chalk@^1.1.1: version "1.1.3" resolved "https://registry.yarnpkg.com/chalk/-/chalk-1.1.3.tgz#a8115c55e4a702fe4d150abd3872822a7e09fc98" integrity sha512-U3lRVLMSlsCfjqYPbLyVv11M9CPW4I728d6TCKMAOJueEeB9/8o+eSsMnxPJD+Q+K909sdESg7C+tIkoH6on1A== @@ -1938,6 +1938,11 @@ dateformat@~4.6.2: resolved "https://registry.yarnpkg.com/dateformat/-/dateformat-4.6.3.tgz#556fa6497e5217fedb78821424f8a1c22fa3f4b5" integrity sha512-2P0p0pFGzHS5EMnhdxQi7aJN+iMheud0UhG4dlE1DLAlvL8JHjJJTX/CSm4JXwV0Ka5nGk3zC5mcb5bUQUxxMA== +dayjs@^1.11.10: + version "1.11.10" + resolved "https://registry.yarnpkg.com/dayjs/-/dayjs-1.11.10.tgz#68acea85317a6e164457d6d6947564029a6a16a0" + integrity sha512-vjAczensTgRcqDERK0SR2XMwsF/tSvnvlv6VcF2GIhg6Sx4yOIt/irsr1RDJsKiIyBzJDpCoXiWWq28MqH2cnQ== + debug@2.6.9, debug@^2.2.0, debug@^2.3.3: version "2.6.9" resolved "https://registry.yarnpkg.com/debug/-/debug-2.6.9.tgz#5d128515df134ff327e90a4c93f4e077a536341f" @@ -2109,6 +2114,11 @@ dezalgo@^1.0.4: asap "^2.0.0" wrappy "1" +diff-dom@^5.1.2: + version "5.1.2" + resolved "https://registry.yarnpkg.com/diff-dom/-/diff-dom-5.1.2.tgz#951627784bc45e32999f0c97cd42e4cf8c35791f" + integrity sha512-ayOX+pBYzyLdt7iXFd+8jvWzhrcWk+9gQqYk7Zz8/0hpIsqSbtk6MNbtds+Ox6B8ONsdtIcfPmk3NXPdgb3+xQ== + diff-sequences@^29.6.3: version "29.6.3" resolved "https://registry.yarnpkg.com/diff-sequences/-/diff-sequences-29.6.3.tgz#4deaf894d11407c51efc8418012f9e70b84ea921" @@ -2560,6 +2570,11 @@ figures@^3.0.0: dependencies: escape-string-regexp "^1.0.5" +file-sync-cmp@^0.1.0: + version "0.1.1" + resolved "https://registry.yarnpkg.com/file-sync-cmp/-/file-sync-cmp-0.1.1.tgz#a5e7a8ffbfa493b43b923bbd4ca89a53b63b612b" + integrity sha512-0k45oWBokCqh2MOexeYKpyqmGKG+8mQ2Wd8iawx+uWd/weWJQAZ6SoPybagdCI4xFisag8iAR77WPm4h3pTfxA== + file-uri-to-path@1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz#553a7b8446ff6f684359c445f1e37a05dacc33dd" @@ -3042,6 +3057,14 @@ grunt-contrib-concat@^2.1.0: chalk "^4.1.2" source-map "^0.5.3" +grunt-contrib-copy@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/grunt-contrib-copy/-/grunt-contrib-copy-1.0.0.tgz#7060c6581e904b8ab0d00f076e0a8f6e3e7c3573" + integrity sha512-gFRFUB0ZbLcjKb67Magz1yOHGBkyU6uL29hiEW1tdQ9gQt72NuMKIy/kS6dsCbV0cZ0maNCb0s6y+uT1FKU7jA== + dependencies: + chalk "^1.1.1" + file-sync-cmp "^0.1.0" + grunt-contrib-jshint@^3.2.0: version "3.2.0" resolved "https://registry.yarnpkg.com/grunt-contrib-jshint/-/grunt-contrib-jshint-3.2.0.tgz#d97c125ce6dafef1b0cc766cd87201ae0fb7b408" @@ -6382,6 +6405,14 @@ signal-exit@^3.0.0, signal-exit@^3.0.2, signal-exit@^3.0.3, signal-exit@^3.0.7: resolved "https://registry.yarnpkg.com/signal-exit/-/signal-exit-3.0.7.tgz#a9a1767f8af84155114eaabd73f99273c8f59ad9" integrity sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ== +simple-datatables@^8.0.1: + version "8.0.1" + resolved "https://registry.yarnpkg.com/simple-datatables/-/simple-datatables-8.0.1.tgz#9e1dd25bcdf30782aee14f6f138c8e3f97bbbc2f" + integrity sha512-1l9N7yacy4pECoOw/khm/n7XtEcpgoh5znJ1diqern1fwRqgOCgdI7keW9LCeV5es2M4K+db3qWjaWHbcFUh0Q== + dependencies: + dayjs "^1.11.10" + diff-dom "^5.1.2" + sisteransi@^1.0.5: version "1.0.5" resolved "https://registry.yarnpkg.com/sisteransi/-/sisteransi-1.0.5.tgz#134d681297756437cc05ca01370d3a7a571075ed"