Skip to content

Commit

Permalink
feat: add pagination to apisearcher
Browse files Browse the repository at this point in the history
the code is a mess and should be refactored at some point
  • Loading branch information
FreekBes committed Dec 18, 2024
1 parent d9c409a commit cc5cc44
Show file tree
Hide file tree
Showing 13 changed files with 574 additions and 121 deletions.
470 changes: 389 additions & 81 deletions src/routes/admin/apisearcher.ts

Large diffs are not rendered by default.

12 changes: 5 additions & 7 deletions src/routes/admin/points.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { Express } from 'express';
import { CodamCoalitionScore, PrismaClient } from '@prisma/client';
import fs from 'fs';
import { fetchSingleApiPage, getAPIClient, getPageNav } from '../../utils';
import { fetchSingleApiPage, getAPIClient, getOffset, getPageNav, getPageNumber } from '../../utils';
import { ExpressIntraUser } from '../../sync/oauth';
import { createScore, handleFixedPointScore, shiftScore } from '../../handlers/points';

Expand All @@ -12,11 +12,8 @@ export const setupAdminPointsRoutes = function(app: Express, prisma: PrismaClien
// Calculate the total amount of pages
const totalScores = await prisma.codamCoalitionScore.count();
const totalPages = Math.ceil(totalScores / SCORES_PER_PAGE);
const pageNum = (req.query.page ? parseInt(req.query.page as string) : 1);
if (isNaN(pageNum) || pageNum < 1 || pageNum > totalPages) {
return res.status(404).send('Page not found');
}
const offset = (pageNum - 1) * SCORES_PER_PAGE;
const pageNum = getPageNumber(req, totalPages);
const offset = getOffset(pageNum, SCORES_PER_PAGE);

// Retrieve the scores to be displayed on the page
const scores = await prisma.codamCoalitionScore.findMany({
Expand Down Expand Up @@ -389,7 +386,8 @@ export const setupAdminPointsRoutes = function(app: Express, prisma: PrismaClien

// Verify the event exists on Intra
const api = await getAPIClient();
const intraEvent = await fetchSingleApiPage(api, `/events/${eventId}`);
const apires = await fetchSingleApiPage(api, `/events/${eventId}`);
const intraEvent = apires.data;
if (!intraEvent) {
return res.status(404).send('Intra event not found');
}
Expand Down
24 changes: 11 additions & 13 deletions src/routes/hooks/triggers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,8 @@ export const setupWebhookTriggerRoutes = function(app: Express, prisma: PrismaCl
// ID belongs to a location ID in the intra system
const api = await getAPIClient();
try {
const location: Location = await fetchSingleApiPage(api, `/locations/${req.params.id}`, {}) as Location;
const apires = await fetchSingleApiPage(api, `/locations/${req.params.id}`, {});
const location: Location = apires.data as Location;
if (location === null) {
console.error(`Failed to find location ${req.params.id}, cannot trigger location close webhook`);
return res.status(404).json({ error: 'Location not found' });
Expand All @@ -34,7 +35,8 @@ export const setupWebhookTriggerRoutes = function(app: Express, prisma: PrismaCl
// ID belongs to a team ID in the intra system
const api = await getAPIClient();
try {
const team = await fetchSingleApiPage(api, `/teams/${req.params.id}`, {});
const apires = await fetchSingleApiPage(api, `/teams/${req.params.id}`, {});
const team = apires.data;
if (team === null) {
console.error(`Failed to find team ${req.params.id}, cannot trigger projectsUser update webhook for team users`);
return res.status(404).json({ error: 'Team not found' });
Expand All @@ -46,7 +48,8 @@ export const setupWebhookTriggerRoutes = function(app: Express, prisma: PrismaCl
console.warn(`User ${user.id} in team ${team.id} has no projects_user ID, skipping projectsUser update webhook...`);
}
try {
const projectUser: ProjectUser = await fetchSingleApiPage(api, `/projects_users/${user.projects_user_id}`, {}) as ProjectUser;
const apires2 = await fetchSingleApiPage(api, `/projects_users/${user.projects_user_id}`, {});
const projectUser: ProjectUser = apires2.data as ProjectUser;
await handleProjectsUserUpdateWebhook(prisma, projectUser);
}
catch (err) {
Expand All @@ -65,11 +68,8 @@ export const setupWebhookTriggerRoutes = function(app: Express, prisma: PrismaCl
// ID belongs to a projects_user ID in the intra system
const api = await getAPIClient();
try {
const projectUser: ProjectUser = await fetchSingleApiPage(api, `/projects_users/${req.params.id}`, {}) as ProjectUser;
if (projectUser === null) {
console.error(`Failed to find projects_user ${req.params.id}, cannot trigger projectsUser update webhook`);
return res.status(404).json({ error: 'Project user not found' });
}
const apires = await fetchSingleApiPage(api, `/projects_users/${req.params.id}`, {});
const projectUser: ProjectUser = apires.data as ProjectUser;
await handleProjectsUserUpdateWebhook(prisma, projectUser);
return res.status(200).json({ status: 'ok' });
}
Expand Down Expand Up @@ -98,11 +98,9 @@ export const setupWebhookTriggerRoutes = function(app: Express, prisma: PrismaCl
// ID belongs to a scale_team ID in the intra system
const api = await getAPIClient();
try {
const scaleTeam: ScaleTeam = await fetchSingleApiPage(api, `/scale_teams/${req.params.id}`, {}) as ScaleTeam;
if (scaleTeam === null) {
console.error(`Failed to find scale_team ${req.params.id}, cannot trigger scale_team update webhook`);
return res.status(404).json({ error: 'Scale team not found' });
}
const apires = await fetchSingleApiPage(api, `/scale_teams/${req.params.id}`, {});
const scaleTeam: ScaleTeam = apires.data as ScaleTeam;

await handleScaleTeamUpdateWebhook(prisma, scaleTeam);
return res.status(200).json({ status: 'ok' });
}
Expand Down
40 changes: 30 additions & 10 deletions src/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import Fast42 from "@codam/fast42";
import { api } from "./main";
import { CURSUS_ID } from "./env";
import NodeCache from "node-cache";
import { Request } from "express";

export const getAPIClient = async function(): Promise<Fast42> {
if (!api) {
Expand All @@ -12,19 +13,38 @@ export const getAPIClient = async function(): Promise<Fast42> {
return api;
};

export const fetchSingleApiPage = async function(api: Fast42, endpoint: string, params: Record<string, string> = {}): Promise<any> {
const job = await api.getPage(endpoint, "1", params);
try {
if (job.status !== 200) {
console.error(`Failed to fetch page ${endpoint} with status ${job.status}`);
return null;
export const fetchSingleApiPage = function(api: Fast42, endpoint: string, params: Record<string, string> = {}, pageNum: number = 1): Promise<{headers: any, data: any}> {
return new Promise(async (resolve, reject) => {
try {
const job = await api.getPage(endpoint, pageNum.toString(), params);
if (job.status !== 200) {
console.error(`Failed to fetch page ${endpoint} with status ${job.status}`);
reject(`Failed to fetch page ${endpoint} with status ${job.status}`);
}
const headers = job.headers.raw();
const data = await job.json();
resolve({ headers, data });
}
catch (err) {
console.error(`Failed to fetch page ${endpoint}`, err);
reject(err);
}
return await job.json();
});
};

export const getPageNumber = function(req: Request, totalPages: number | null): number {
const pageNum = (req.query.page ? parseInt(req.query.page as string) : 1);
if (pageNum < 1 || isNaN(pageNum)) {
return 1;
}
catch (err) {
console.error(`Failed to fetch page ${endpoint}`, err);
return null;
if (totalPages && !isNaN(totalPages) && pageNum > totalPages) {
return totalPages;
}
return pageNum;
};

export const getOffset = function(pageNum: number, itemsPerPage: number): number {
return (pageNum - 1) * itemsPerPage;
};

export const isStudentOrStaff = async function(prisma: PrismaClient, intraUser: ExpressIntraUser | IntraUser): Promise<boolean> {
Expand Down
4 changes: 2 additions & 2 deletions static/js/apisearcher-inputlist.js
Original file line number Diff line number Diff line change
Expand Up @@ -68,12 +68,12 @@ const ApiSearcherInputList = function(options) {
}
};

this.clearAndLoadResults = (data) => {
this.clearAndLoadResults = (results) => {
this.clear();

// Load results into datalist
const options = [];
for (const row of data) {
for (const row of results.data) {
const option = document.createElement('option');
const optionValue = [];
for (const dataKey of this.dataKeys) {
Expand Down
125 changes: 119 additions & 6 deletions static/js/apisearcher-table.js
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,14 @@ const ApiSearcherTable = function(options) {
this.filterForm = document.querySelector(this.options['filterForm']);
this.filterForm.addEventListener('submit', this.search);

// Page navigation setup
if (this.options['pageNav']) {
this.pageNav = document.querySelector(this.options['pageNav']);
}
else {
this.pageNav = null;
}

// Points calculator setup
if (this.options['noPoints'] !== true) {
if (!this.options['pointsCalculator']) {
Expand Down Expand Up @@ -120,12 +128,94 @@ const ApiSearcherTable = function(options) {
}
};

this.setupPagination = (results) => {
const pageNav = [];
const currentPageNum = results.meta.pagination.page;
const totalPages = results.meta.pagination.pages;
const maxPages = 5;
const halfMaxPages = Math.floor(maxPages / 2);
let startPage = Math.max(1, currentPageNum - halfMaxPages);
let endPage = Math.min(totalPages, startPage + maxPages - 1);

if (endPage - startPage < maxPages - 1) {
startPage = Math.max(1, endPage - maxPages + 1);
}
if (endPage - startPage < maxPages - 1) {
endPage = Math.min(totalPages, startPage + maxPages - 1);
}
if (endPage - startPage < maxPages - 1) {
startPage = Math.max(1, endPage - maxPages + 1);
}
if (startPage > 1) {
pageNav.push({
num: 1,
active: false,
text: 'First',
});
pageNav.push({
num: currentPageNum - 1,
active: false,
text: '<',
});
}
for (let i = startPage; i <= endPage; i++) {
pageNav.push({
num: i,
active: i === currentPageNum,
text: i.toString(),
});
}
if (endPage < totalPages) {
pageNav.push({
num: currentPageNum + 1,
active: false,
text: '>',
});
pageNav.push({
num: totalPages,
active: false,
text: 'Last',
});
}

const ul = document.createElement('ul');
ul.classList.add('pagination');
for (const page of pageNav) {
const li = document.createElement('li');
li.classList.add('page-item');
if (page.active) {
li.classList.add('active');
li.setAttribute('aria-current', 'page');
}
const a = document.createElement('a');
a.classList.add('page-link');
a.innerText = page.text;
a.href = '?page=' + page.num;
a.addEventListener('click', (e) => {
e.preventDefault();
this.clearAndShowLoading();
this.search(null, e.target.href.split('=')[1]);
// TODO: update page URL
});
li.appendChild(a);
ul.appendChild(li);
}
this.pageNav.appendChild(ul);
};

this.clear = () => {
// Delete all tbody children
const tbody = this.results.querySelector('tbody');
while (tbody.firstChild) {
tbody.removeChild(tbody.firstChild);
}

// Clear pagination
if (this.pageNav) {
while (this.pageNav.firstChild) {
this.pageNav.removeChild(this.pageNav.firstChild);
}
}
};

this.clearAndShowLoading = () => {
Expand All @@ -149,13 +239,18 @@ const ApiSearcherTable = function(options) {
}
};

this.clearAndLoadResults = (data) => {
this.clearAndLoadResults = (results) => {
this.clear();
const tbody = this.results.querySelector('tbody');

// Set up pagination
if (this.pageNav) {
this.setupPagination(results);
}

// Load results into table
const trs = [];
for (const row of data) {
for (const row of results.data) {
const tr = document.createElement('tr');
for (const header of this.headers) {
const td = document.createElement('td');
Expand Down Expand Up @@ -289,19 +384,37 @@ const ApiSearcherTable = function(options) {
tbody.append(...trs);
};

this.search = async (e) => {
this.abortController = null;
this.search = async (e, pageNum = 1) => {
if (e) {
e.preventDefault();
}
// Abort any ongoing fetch requests
if (this.abortController) {
this.abortController.abort();
this.abortController = null;
}
// Check if a filter should be applied
let filter = '';
if (this.filterSelector.value != 'none' && this.filterValue.value != '') {
filter = this.filterSelector.value + '/' + this.filterValue.value;
// TODO: update page URL with filter
}
this.clearAndShowLoading();
const req = await fetch(this.concatUrlPaths(this.url, filter));
const results = await req.json();
this.clearAndLoadResults(results);
try {
this.abortController = new AbortController();
fetch(this.concatUrlPaths(this.url, filter) + '?page=' + pageNum, { signal: this.abortController.signal })
.then(req => req.json())
.then(results => this.clearAndLoadResults(results))
}
catch (err) {
if (err.name === 'AbortError') {
console.log('Search aborted');
}
else {
console.error('An error occurred while fetching data', err);
}
}
};

this.init();
Expand Down
3 changes: 3 additions & 0 deletions templates/admin/hooks/history.njk
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,8 @@
</tbody>
</table>

<nav aria-label="Page navigation" class="d-flex justify-content-center" id="pagenav"></nav>

<script src="/js/reactive-button.js"></script>
<script src="/js/apisearcher-table.js"></script>
<script>
Expand All @@ -55,6 +57,7 @@ const apiSearcher = new ApiSearcherTable({
filterSelector: '#searchfilter',
filterValue: '#searchfilterval',
filterForm: '#searchfilterform',
pageNav: '#pagenav',
noPoints: true,
actions: [
// TODO: add option to retrigger the webhook
Expand Down
2 changes: 0 additions & 2 deletions templates/admin/points/history.njk
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,6 @@
</ol>
</nav>

{{ pagination(pageNav) }}

<table class="table coalition-colored">
<thead>
<th scope="col">Date</th>
Expand Down
3 changes: 3 additions & 0 deletions templates/admin/points/manual/evaluation.njk
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,8 @@
</tbody>
</table>

<nav aria-label="Page navigation" class="d-flex justify-content-center" id="pagenav"></nav>

<script src="/js/reactive-button.js"></script>
<script src="/js/apisearcher-table.js"></script>
<script>
Expand All @@ -54,6 +56,7 @@ const apiSearcher = new ApiSearcherTable({
filterSelector: '#searchfilter',
filterValue: '#searchfilterval',
filterForm: '#searchfilterform',
pageNav: '#pagenav',
pointsCalculator: function(data) {
const EVALUATION_DEFAULT_DURATION = 900; // 15 minutes
const basePoints = {{ fixedPointType.point_amount }} * (data.scale.duration / EVALUATION_DEFAULT_DURATION);
Expand Down
3 changes: 3 additions & 0 deletions templates/admin/points/manual/exam.njk
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,8 @@
</tbody>
</table>

<nav aria-label="Page navigation" class="d-flex justify-content-center" id="pagenav"></nav>

<script src="/js/reactive-button.js"></script>
<script src="/js/apisearcher-table.js"></script>
<script>
Expand All @@ -52,6 +54,7 @@ const apiSearcher = new ApiSearcherTable({
filterSelector: '#searchfilter',
filterValue: '#searchfilterval',
filterForm: '#searchfilterform',
pageNav: '#pagenav',
pointsCalculator: function(data) {
const basePoints = {{ fixedPointType.point_amount }};
return basePoints;
Expand Down
Loading

0 comments on commit cc5cc44

Please sign in to comment.