From 0233453084f461860727ab727684c816a4ab6218 Mon Sep 17 00:00:00 2001 From: grey Date: Sat, 8 Jul 2023 00:09:54 +0200 Subject: [PATCH] - introduced table view with client side loading - improved /items/ with support for pagination - improved helper functions for tooltips and popovers - removed console log residue from handleSidebarTriangles - introduction of version route --- allowedStaticPaths.json | 4 +- package-lock.json | 9 +++ package.json | 1 + src/assets/helper.ts | 22 ++++++- src/frontend/items.eta.html | 53 ++-------------- src/frontend/partials/controlsFoot.eta.html | 17 ++++- src/frontend/partials/foot.eta.html | 6 -- src/frontend/partials/head.eta.html | 4 +- src/routes/api/v1/index.ts | 3 +- src/routes/api/v1/items.ts | 69 +++++++++++++++++---- src/routes/frontend/items.ts | 24 +------ static/js/formHandler.js | 16 ++++- static/js/handleSidebarTriangles.js | 1 - static/js/itemPageHandler.js | 58 +++++++++++++++++ static/js/toastHandler.js | 5 ++ 15 files changed, 196 insertions(+), 96 deletions(-) create mode 100644 static/js/itemPageHandler.js diff --git a/allowedStaticPaths.json b/allowedStaticPaths.json index c299d03..257b0b1 100644 --- a/allowedStaticPaths.json +++ b/allowedStaticPaths.json @@ -10,7 +10,9 @@ "/@popperjs/core/dist/umd/popper.min.js.map", "/bootstrap/dist/js/bootstrap.bundle.min.js.map", "/bootstrap-icons/font/fonts/bootstrap-icons.woff", - "/tsparticles-confetti/tsparticles.confetti.bundle.min.js" + "/tsparticles-confetti/tsparticles.confetti.bundle.min.js", + "/bootstrap-table/dist/bootstrap-table.min.js", + "/bootstrap-table/dist/bootstrap-table.min.css" ], "debugMode": false } diff --git a/package-lock.json b/package-lock.json index cf0034f..2bd8769 100644 --- a/package-lock.json +++ b/package-lock.json @@ -16,6 +16,7 @@ "body-parser": "^1.20.2", "bootstrap": "^5.3.0-alpha3", "bootstrap-icons": "^1.10.5", + "bootstrap-table": "^1.22.1", "csv": "^6.2.11", "eta": "^2.0.1", "express": "^4.18.2", @@ -1299,6 +1300,14 @@ } ] }, + "node_modules/bootstrap-table": { + "version": "1.22.1", + "resolved": "https://registry.npmjs.org/bootstrap-table/-/bootstrap-table-1.22.1.tgz", + "integrity": "sha512-Nw8p+BmaiMDSfoer/p49YeI3vJQAWhudxhyKMuqnJBb3NRvCRewMk7JDgiN9SQO3YeSejOirKtcdWpM0dtddWg==", + "peerDependencies": { + "jquery": "3" + } + }, "node_modules/brace-expansion": { "version": "1.1.11", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", diff --git a/package.json b/package.json index 2c4d53b..67990e5 100644 --- a/package.json +++ b/package.json @@ -24,6 +24,7 @@ "body-parser": "^1.20.2", "bootstrap": "^5.3.0-alpha3", "bootstrap-icons": "^1.10.5", + "bootstrap-table": "^1.22.1", "csv": "^6.2.11", "eta": "^2.0.1", "express": "^4.18.2", diff --git a/src/assets/helper.ts b/src/assets/helper.ts index a7311ad..1f4a535 100644 --- a/src/assets/helper.ts +++ b/src/assets/helper.ts @@ -88,6 +88,26 @@ export function parseIntRelation(data: string, relation_name: string = 'id', doN }`); } -export function parseIntOrUndefined(data: string) { + +/** + * Function to parse a string into a number or return undefined if it is not a number + * + * @export + * @param {string || any} data + * @returns {object} + */ +export function parseIntOrUndefined(data: any) { return isNaN(parseInt(data)) ? undefined : parseInt(data); } + +/** + * A function to create a sortBy compatible object from a string + * + * @export + * @param {string} SortField + * @param {string} Order + * @returns {object} + */ +export function parseDynamicSortBy(SortField: string, Order: string){ + return JSON.parse(`{ "${SortField}": "${Order}" }`); +} diff --git a/src/frontend/items.eta.html b/src/frontend/items.eta.html index 674e5f6..84da31c 100644 --- a/src/frontend/items.eta.html +++ b/src/frontend/items.eta.html @@ -93,13 +93,13 @@ Create new item - +
- - - - + + + + <% if(it.items.length == 0) { %> @@ -109,49 +109,8 @@ <% } %> - - <% it.items.forEach(function(user){ %> - - - - <% if(user.status == "normal") { %> - - - <% } else if(user.status == "stolen") { %> - - <% } else if(user.status == "lost") { %> - - <% } else if(user.status == "borrowed") { %> - - <% } %> - - - <% }) %> -
SKUNameStatusActionsSKUNameStatusActions
- <% if (user.SKU == null) { %> - No SKU assigned - <% } else { %> <%= user.SKU %> <% } %><%= user.name %><%= user.status %><%= user.status %><%= user.status %><%= user.status %> - - -
-
- <% if(it.maxPages > 1) { %> - - <% } %> + <%~ E.includeFile("partials/controlsFoot.eta.html") %> <%~ E.includeFile("partials/foot.eta.html") %> diff --git a/src/frontend/partials/controlsFoot.eta.html b/src/frontend/partials/controlsFoot.eta.html index 1268640..8d33f0b 100644 --- a/src/frontend/partials/controlsFoot.eta.html +++ b/src/frontend/partials/controlsFoot.eta.html @@ -6,6 +6,17 @@ \ No newline at end of file + function activateTooltips(){ + // Enable all bootstrap tooltips. + // https://getbootstrap.com/docs/5.3/components/tooltips/#enable-tooltips + const tooltipTriggerList = document.querySelectorAll('[data-bs-toggle="tooltip"]') + const tooltipList = [...tooltipTriggerList].map(tooltipTriggerEl => new bootstrap.Tooltip(tooltipTriggerEl)) + } + function activatePopovers(){ + const popoverTriggerList = document.querySelectorAll('[data-bs-toggle="popover"]') + const popoverList = [...popoverTriggerList].map(popoverTriggerEl => new bootstrap.Popover(popoverTriggerEl)) + } + + activatePopovers(); + activateTooltips(); + diff --git a/src/frontend/partials/foot.eta.html b/src/frontend/partials/foot.eta.html index 8ba1720..308b1d0 100644 --- a/src/frontend/partials/foot.eta.html +++ b/src/frontend/partials/foot.eta.html @@ -1,8 +1,2 @@ - diff --git a/src/frontend/partials/head.eta.html b/src/frontend/partials/head.eta.html index 93f0fe7..a56e7c8 100644 --- a/src/frontend/partials/head.eta.html +++ b/src/frontend/partials/head.eta.html @@ -8,7 +8,6 @@ AssetFlow - <%= it.title %> - @@ -21,7 +20,8 @@ - + + diff --git a/src/routes/api/v1/index.ts b/src/routes/api/v1/index.ts index 7ed4b04..302fb4e 100644 --- a/src/routes/api/v1/index.ts +++ b/src/routes/api/v1/index.ts @@ -6,6 +6,7 @@ import itemRoute from './items.js'; import categoryRoute from './categories.js'; import storageUnitRoute from './storageUnits.js'; import storageLocationRoute from './storageLocations.js'; +import versionRoute from './version.js' import search_routes from './search/index.js'; @@ -27,7 +28,7 @@ Router.route('/categories').get(categoryRoute.get).post(categoryRoute.post).patc // TODO: Migrate routes to lowercase. Router.route('/storageUnits').get(storageUnitRoute.get).post(storageUnitRoute.post).patch(storageUnitRoute.patch).delete(storageUnitRoute.del); Router.route('/storageLocations').get(storageLocationRoute.get).post(storageLocationRoute.post).patch(storageLocationRoute.patch).delete(storageLocationRoute.del); - +Router.route('/version').get(versionRoute.get); Router.use('/search', search_routes); Router.route('/test').get(testRoute.get); diff --git a/src/routes/api/v1/items.ts b/src/routes/api/v1/items.ts index 2d42f1d..37a8465 100644 --- a/src/routes/api/v1/items.ts +++ b/src/routes/api/v1/items.ts @@ -1,16 +1,18 @@ import { Request, Response } from 'express'; import { prisma, __path, log } from '../../../index.js'; import { itemStatus } from '@prisma/client'; -import { parseIntRelation, parseIntOrUndefined } from '../../../assets/helper.js'; +import { parseIntRelation, parseIntOrUndefined, parseDynamicSortBy } from '../../../assets/helper.js'; // Get item. -function get(req: Request, res: Response) { - if (req.query.getAll === undefined) { - // Check if required fields are present - if (!req.query.id) { - res.status(400).json({ errorcode: 'VALIDATION_ERROR', error: 'One or more required fields are missing' }); - return; - } +async function get(req: Request, res: Response) { + // Set sane defaults if undefined. + if (req.query.sort === undefined) { + req.query.sort = 'id'; + } + if (req.query.order === undefined) { + req.query.order = 'asc'; + } + if (req.query.id) { // Check if number is a valid integer if (!Number.isInteger(parseInt(req.query.id.toString()))) { res.status(400).json({ errorcode: 'VALIDATION_ERROR', error: 'The id field must be an integer' }); @@ -48,8 +50,53 @@ function get(req: Request, res: Response) { res.status(500).json({ errorcode: 'DB_ERROR', error: err }); }); } else { + // Get all items + const itemCountNotFiltered = await prisma.item.count({}); + + // Get all items (filtered) + const itemCountFiltered = await prisma.item.count({ + where: { + OR: [ + { + SKU: { + // Probably use prisma's Full-text search if it's out of beta + // @ts-ignore + contains: req.query.search.length > 0 ? req.query.search : '' + } + }, + { + name: { + // @ts-ignore + contains: req.query.search.length > 0 ? req.query.search : '' + } + } + ] + }, + orderBy: parseDynamicSortBy(req.query.sort.toString(), req.query.order.toString()) + }); + // log.core.debug('Dynamic relation:', parseDynamicSortBy(req.query.sort.toString(), req.query.order.toString())); + prisma.item .findMany({ + take: parseIntOrUndefined(req.query.limit), + skip: parseIntOrUndefined(req.query.offset), + where: { + OR: [ + { + SKU: { + // @ts-ignore + contains: req.query.search.length > 0 ? req.query.search : '' + } + }, + { + name: { + // @ts-ignore + contains: req.query.search.length > 0 ? req.query.search : '' + } + } + ] + }, + orderBy: parseDynamicSortBy(req.query.sort.toString(), req.query.order.toString()), // Get contactInfo, category, storageLocation( storageUnit ) from relations. include: { contactInfo: true, @@ -67,7 +114,7 @@ function get(req: Request, res: Response) { }) .then((items) => { if (items) { - res.status(200).json(JSON.stringify(items)); + res.status(200).json(JSON.stringify({ total: itemCountFiltered, totalNotFiltered: itemCountNotFiltered, items: items })); } else { res.status(410).json({ errorcode: 'NOT_EXISTING', error: 'Item does not exist' }); } @@ -97,7 +144,7 @@ function post(req: Request, res: Response) { .create({ data: { SKU: req.body.sku, - amount: parseIntOrUndefined(req.body.ammount), // FIXME: This is silently failing if NaN.. + amount: parseIntOrUndefined(req.body.amount), // FIXME: This is silently failing if NaN.. name: req.body.name, comment: req.body.comment, status: req.body.status, // Only enum(itemStatus) values are valid @@ -167,7 +214,7 @@ async function patch(req: Request, res: Response) { }, data: { SKU: req.body.sku, - amount: parseIntOrUndefined(req.body.ammount), // FIXME: This is silently failing if NaN.. + amount: parseIntOrUndefined(req.body.amount), // FIXME: This is silently failing if NaN.. name: req.body.name, comment: req.body.comment, status: req.body.status, // Only enum(itemStatus) values are valid diff --git a/src/routes/frontend/items.ts b/src/routes/frontend/items.ts index b996adf..728eafc 100644 --- a/src/routes/frontend/items.ts +++ b/src/routes/frontend/items.ts @@ -2,33 +2,13 @@ import { Request, Response } from 'express'; import { prisma, __path, log } from '../../index.js'; async function get(req: Request, res: Response) { - // If no page is provided redirect to first - if (req.query.page === undefined) { - res.redirect('?page=1'); - return; - } - - let page = parseInt(req.query.page.toString()); - const itemCount = await prisma.item.count({}); // Count all items in the DB - - const takeSize = 25; // Amount of times per page - const pageSize = Math.ceil(itemCount / takeSize); // Amount of pages, always round up - - // If page is less then 1 or more then the max page size redirect to first or last page. If itemCount is 0 do not redirect. - if (page < 1) { - res.redirect('?page=1'); - return; - } else if (page > pageSize && itemCount !== 0) { - res.redirect('?page=' + pageSize); - return; - } prisma.item - .findMany({ skip: (page - 1) * takeSize, take: takeSize, orderBy: { SKU: "asc" } }) // Skip the amount of items per page times the page number minus 1; skip has to be (page-1)*takeSize because skip is 0 indexed + .findMany({}) // Skip the amount of items per page times the page number minus 1; skip has to be (page-1)*takeSize because skip is 0 indexed .then((items) => { prisma.storageLocation.findMany({}).then((locations) => { prisma.itemCategory.findMany({}).then((categories) => { prisma.contactInfo.findMany({}).then((contactInfo) => { - res.render(__path + '/src/frontend/items.eta.html', { items: items, currentPage: page, maxPages: pageSize, storeLocs: locations, categories: categories, contactInfo: contactInfo }); + res.render(__path + '/src/frontend/items.eta.html', { items: items, storeLocs: locations, categories: categories, contactInfo: contactInfo }); }) }); }); diff --git a/static/js/formHandler.js b/static/js/formHandler.js index 6b616dc..900a84a 100644 --- a/static/js/formHandler.js +++ b/static/js/formHandler.js @@ -1,4 +1,13 @@ var amountOfForms = $('.frontendForm').length; + +function isNewDataLoaderAvailable() { + try { + return FLAG_supports_new_data_loader; + } catch (error) { + return false; + } +} + $('.frontendForm').each(function () { // TODO Handle empty strings as null or undefined, not as '' $(this).on('submit', function (e) { @@ -26,7 +35,12 @@ $('.frontendForm').each(function () { // Clear all fields form.find('input, textarea').val(''); // Create toast - createNewToast(' Changes saved successfully.', "text-bg-success") + if(isNewDataLoaderAvailable()) { + createNewToast(' Changes saved successfully.', "text-bg-success", undefined, false) + } else { + createNewToast(' Changes saved successfully.', "text-bg-success") + } + }, error: function (data) { console.log('error'); diff --git a/static/js/handleSidebarTriangles.js b/static/js/handleSidebarTriangles.js index f330b34..82ccb20 100644 --- a/static/js/handleSidebarTriangles.js +++ b/static/js/handleSidebarTriangles.js @@ -10,7 +10,6 @@ trinagles.each(function () { $(this).addClass('rotate'); } - console.log('target', target); target.on('show.bs.collapse', function () { $(triTar).addClass('rotate'); $(triTar).removeClass('derotate'); diff --git a/static/js/itemPageHandler.js b/static/js/itemPageHandler.js new file mode 100644 index 0000000..6313227 --- /dev/null +++ b/static/js/itemPageHandler.js @@ -0,0 +1,58 @@ +const FLAG_supports_new_data_loader = true; + +/** + * Should we ever implement items in items, have a look at this: + * https://examples.bootstrap-table.com/index.html?extensions/treegrid.html#extensions/treegrid.html + */ + +function loadPageData() { + const itemList = $('#itemList'); + // itemList.empty(); + itemList.bootstrapTable('destroy') + itemList.bootstrapTable({url: "/api/v1/items", search: true, showRefresh: true, responseHandler: dataResponseHandler, sidePagination: 'server', serverSort: true}) + setTimeout(() => { + activateTooltips(); + }, 1000); +} + +function dataResponseHandler(res) { + json = JSON.parse(res); + // console.log(json) + totalNotFiltered = json.totalNotFiltered; + total = json.total; + json = json.items; + json.forEach((item) => { + colorStatus = ''; + if(item.SKU == null) item.SKU = 'No SKU assigned'; + switch (item.status) { + case 'normal': + colorStatus = 'success'; + break; + case 'stolen': + colorStatus = 'danger'; + break; + case 'lost': + colorStatus = 'warning'; + break; + case 'borrowed': + colorStatus = 'info'; + break; + default: + colorStatus = 'secondary'; + } + item.status = `${item.status}`; + item.actions = ` + + ` + item.SKU = `

${item.SKU}

` + }); + ///// --------------------------------- ///// + + return {"rows": json, total: total, totalNotFiltered: totalNotFiltered, totalRows: total}; +} + +loadPageData() diff --git a/static/js/toastHandler.js b/static/js/toastHandler.js index 851356f..2cf62be 100644 --- a/static/js/toastHandler.js +++ b/static/js/toastHandler.js @@ -26,6 +26,11 @@ function createNewToast(message, colorSelector, autoHideTime = 1500, autoReload targetContainer.appendChild(newToast); currentToasts.push(newToast); $(newToast).toast('show'); + try { + loadPageData(); + } catch (error) { + console.debug("Page does not support new data loading.") + } setTimeout(() => { destroyToast(newToast.id); if (autoReload && !forceSkipReload) {