- 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
This commit is contained in:
Sören Oesterwind 2023-07-08 00:09:54 +02:00
parent c026b5f1a8
commit 0233453084
15 changed files with 196 additions and 96 deletions

View File

@ -10,7 +10,9 @@
"/@popperjs/core/dist/umd/popper.min.js.map", "/@popperjs/core/dist/umd/popper.min.js.map",
"/bootstrap/dist/js/bootstrap.bundle.min.js.map", "/bootstrap/dist/js/bootstrap.bundle.min.js.map",
"/bootstrap-icons/font/fonts/bootstrap-icons.woff", "/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 "debugMode": false
} }

9
package-lock.json generated
View File

@ -16,6 +16,7 @@
"body-parser": "^1.20.2", "body-parser": "^1.20.2",
"bootstrap": "^5.3.0-alpha3", "bootstrap": "^5.3.0-alpha3",
"bootstrap-icons": "^1.10.5", "bootstrap-icons": "^1.10.5",
"bootstrap-table": "^1.22.1",
"csv": "^6.2.11", "csv": "^6.2.11",
"eta": "^2.0.1", "eta": "^2.0.1",
"express": "^4.18.2", "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": { "node_modules/brace-expansion": {
"version": "1.1.11", "version": "1.1.11",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz",

View File

@ -24,6 +24,7 @@
"body-parser": "^1.20.2", "body-parser": "^1.20.2",
"bootstrap": "^5.3.0-alpha3", "bootstrap": "^5.3.0-alpha3",
"bootstrap-icons": "^1.10.5", "bootstrap-icons": "^1.10.5",
"bootstrap-table": "^1.22.1",
"csv": "^6.2.11", "csv": "^6.2.11",
"eta": "^2.0.1", "eta": "^2.0.1",
"express": "^4.18.2", "express": "^4.18.2",

View File

@ -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); 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}" }`);
}

View File

@ -93,13 +93,13 @@
<a href="/settings/category/new" class="btn btn-primary" data-bs-toggle="modal" data-bs-target="#itemModifyModal" onclick="primeCreateNew()">Create new item</a> <a href="/settings/category/new" class="btn btn-primary" data-bs-toggle="modal" data-bs-target="#itemModifyModal" onclick="primeCreateNew()">Create new item</a>
</div> </div>
</div> </div>
<table class="table align-middle"> <table class="table align-middle" id="itemList" data-sortable="true" data-search-highlight="true" data-pagination="true" data-page-size="25" data-remember-order="true">
<thead> <thead>
<tr> <tr>
<th scope="col">SKU</th> <th scope="col" data-field="SKU" class="sku" data-sortable="true">SKU</th>
<th scope="col">Name</th> <th scope="col" data-field="name" data-sortable="true">Name</th>
<th scope="col">Status</th> <th scope="col" data-field="status" data-sortable="true">Status</th>
<th scope="col">Actions</th> <th scope="col" data-field="actions" data-searchable="false">Actions</th>
</tr> </tr>
</thead> </thead>
<% if(it.items.length == 0) { %> <% if(it.items.length == 0) { %>
@ -109,49 +109,8 @@
</tr> </tr>
</tbody> </tbody>
<% } %> <% } %>
<tbody>
<% it.items.forEach(function(user){ %>
<tr>
<td scope="row" data-bs-toggle="tooltip" data-bs-placement="left" data-bs-title="ID: <%= user.id %>">
<% if (user.SKU == null) { %>
<i>No SKU assigned</i>
<% } else { %> <%= user.SKU %> <% } %></td>
<td><%= user.name %></td>
<% if(user.status == "normal") { %>
<td><span class="badge text-bg-success"><%= user.status %></span></td>
<% } else if(user.status == "stolen") { %>
<td><span class="badge text-bg-danger"><%= user.status %></span></td>
<% } else if(user.status == "lost") { %>
<td><span class="badge text-bg-warning"><%= user.status %></span></td>
<% } else if(user.status == "borrowed") { %>
<td><span class="badge text-bg-info"><%= user.status %></span></td>
<% } %>
<td>
<button class="btn btn-primary" data-bs-toggle="modal" data-bs-target="#itemModifyModal" onclick="primeEdit(); getDataForEdit('<%= user.id %>')">
<i class="bi bi-pencil"></i>
</button>
<button class="btn btn-danger" onclick="preFillDeleteModalNxt('<%= user.id %>','items','Item')" data-bs-toggle="modal" data-bs-target="#staticBackdrop">
<i class="bi bi-trash"></i>
</button>
</td>
</tr>
<% }) %>
</tbody>
</table> </table>
<br />
<% if(it.maxPages > 1) { %>
<nav aria-label="Page selector">
<ul class="pagination justify-content-center">
<li class="page-item <%= it.currentPage-1 < 1 ? 'disabled' : ''%>"><a class="page-link" href="?page=<%= it.currentPage - 1 %>">Previous</a></li>
<% for (var i = 1; i <= it.maxPages; i++) { %>
<li class="page-item <%= it.currentPage == i ? 'active' : ''%>"><a class="page-link" href="?page=<%= i %>"><%= i %></a></li>
<% } %>
<li class="page-item <%= it.currentPage+1 > it.maxPages ? 'disabled' : ''%>"><a class="page-link" href="?page=<%= it.currentPage + 1 %>">Next</a></li>
</ul>
</nav>
<% } %>
</div> </div>
<script src="/js/editItems.js"></script> <script src="/js/editItems.js"></script>
<script src="/js/itemPageHandler.js"></script>
<%~ E.includeFile("partials/controlsFoot.eta.html") %> <%~ E.includeFile("partials/foot.eta.html") %> <%~ E.includeFile("partials/controlsFoot.eta.html") %> <%~ E.includeFile("partials/foot.eta.html") %>

View File

@ -6,6 +6,17 @@
<script src="/js/handleSidebarTriangles.js"></script> <script src="/js/handleSidebarTriangles.js"></script>
<script src="/js/formHandler.js"></script> <script src="/js/formHandler.js"></script>
<script> <script>
const popoverTriggerList = document.querySelectorAll('[data-bs-toggle="popover"]') function activateTooltips(){
const popoverList = [...popoverTriggerList].map(popoverTriggerEl => new bootstrap.Popover(popoverTriggerEl)) // 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();
</script> </script>

View File

@ -1,8 +1,2 @@
<script>
// 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))
</script>
</body> </body>
</html> </html>

View File

@ -8,7 +8,6 @@
<title>AssetFlow - <%= it.title %></title> <title>AssetFlow - <%= it.title %></title>
<meta name="author" content="[Project-Name-Here]" /> <meta name="author" content="[Project-Name-Here]" />
<!--<link rel="icon" href="/favicon.ico" />-->
<link rel="icon" href="/logo/Design_icon.svg" type="image/svg+xml" /> <link rel="icon" href="/logo/Design_icon.svg" type="image/svg+xml" />
<script src="/js/handleColorMode.js"></script> <script src="/js/handleColorMode.js"></script>
@ -21,7 +20,8 @@
<script src="/static/bootstrap/dist/js/bootstrap.bundle.min.js"></script> <script src="/static/bootstrap/dist/js/bootstrap.bundle.min.js"></script>
<script src="/static/@popperjs/core/dist/umd/popper.min.js"></script> <script src="/static/@popperjs/core/dist/umd/popper.min.js"></script>
<script src="/static/tsparticles-confetti/tsparticles.confetti.bundle.min.js"></script> <script src="/static/tsparticles-confetti/tsparticles.confetti.bundle.min.js"></script>
<link rel="stylesheet" href="/static/bootstrap-table/dist/bootstrap-table.min.css">
<script src="/static/bootstrap-table/dist/bootstrap-table.min.js"></script>
</head> </head>
<body> <body>
<!-- The body and html tag need to be left open! --> <!-- The body and html tag need to be left open! -->

View File

@ -6,6 +6,7 @@ import itemRoute from './items.js';
import categoryRoute from './categories.js'; import categoryRoute from './categories.js';
import storageUnitRoute from './storageUnits.js'; import storageUnitRoute from './storageUnits.js';
import storageLocationRoute from './storageLocations.js'; import storageLocationRoute from './storageLocations.js';
import versionRoute from './version.js'
import search_routes from './search/index.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. // TODO: Migrate routes to lowercase.
Router.route('/storageUnits').get(storageUnitRoute.get).post(storageUnitRoute.post).patch(storageUnitRoute.patch).delete(storageUnitRoute.del); 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('/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.use('/search', search_routes);
Router.route('/test').get(testRoute.get); Router.route('/test').get(testRoute.get);

View File

@ -1,16 +1,18 @@
import { Request, Response } from 'express'; import { Request, Response } from 'express';
import { prisma, __path, log } from '../../../index.js'; import { prisma, __path, log } from '../../../index.js';
import { itemStatus } from '@prisma/client'; import { itemStatus } from '@prisma/client';
import { parseIntRelation, parseIntOrUndefined } from '../../../assets/helper.js'; import { parseIntRelation, parseIntOrUndefined, parseDynamicSortBy } from '../../../assets/helper.js';
// Get item. // Get item.
function get(req: Request, res: Response) { async function get(req: Request, res: Response) {
if (req.query.getAll === undefined) { // Set sane defaults if undefined.
// Check if required fields are present if (req.query.sort === undefined) {
if (!req.query.id) { req.query.sort = 'id';
res.status(400).json({ errorcode: 'VALIDATION_ERROR', error: 'One or more required fields are missing' }); }
return; if (req.query.order === undefined) {
req.query.order = 'asc';
} }
if (req.query.id) {
// Check if number is a valid integer // Check if number is a valid integer
if (!Number.isInteger(parseInt(req.query.id.toString()))) { if (!Number.isInteger(parseInt(req.query.id.toString()))) {
res.status(400).json({ errorcode: 'VALIDATION_ERROR', error: 'The id field must be an integer' }); 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 }); res.status(500).json({ errorcode: 'DB_ERROR', error: err });
}); });
} else { } 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 prisma.item
.findMany({ .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<contactInfo> ) from relations. // Get contactInfo, category, storageLocation( storageUnit<contactInfo> ) from relations.
include: { include: {
contactInfo: true, contactInfo: true,
@ -67,7 +114,7 @@ function get(req: Request, res: Response) {
}) })
.then((items) => { .then((items) => {
if (items) { if (items) {
res.status(200).json(JSON.stringify(items)); res.status(200).json(JSON.stringify({ total: itemCountFiltered, totalNotFiltered: itemCountNotFiltered, items: items }));
} else { } else {
res.status(410).json({ errorcode: 'NOT_EXISTING', error: 'Item does not exist' }); res.status(410).json({ errorcode: 'NOT_EXISTING', error: 'Item does not exist' });
} }
@ -97,7 +144,7 @@ function post(req: Request, res: Response) {
.create({ .create({
data: { data: {
SKU: req.body.sku, 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, name: req.body.name,
comment: req.body.comment, comment: req.body.comment,
status: req.body.status, // Only enum(itemStatus) values are valid status: req.body.status, // Only enum(itemStatus) values are valid
@ -167,7 +214,7 @@ async function patch(req: Request, res: Response) {
}, },
data: { data: {
SKU: req.body.sku, 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, name: req.body.name,
comment: req.body.comment, comment: req.body.comment,
status: req.body.status, // Only enum(itemStatus) values are valid status: req.body.status, // Only enum(itemStatus) values are valid

View File

@ -2,33 +2,13 @@ import { Request, Response } from 'express';
import { prisma, __path, log } from '../../index.js'; import { prisma, __path, log } from '../../index.js';
async function get(req: Request, res: Response) { 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 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) => { .then((items) => {
prisma.storageLocation.findMany({}).then((locations) => { prisma.storageLocation.findMany({}).then((locations) => {
prisma.itemCategory.findMany({}).then((categories) => { prisma.itemCategory.findMany({}).then((categories) => {
prisma.contactInfo.findMany({}).then((contactInfo) => { 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 });
}) })
}); });
}); });

View File

@ -1,4 +1,13 @@
var amountOfForms = $('.frontendForm').length; var amountOfForms = $('.frontendForm').length;
function isNewDataLoaderAvailable() {
try {
return FLAG_supports_new_data_loader;
} catch (error) {
return false;
}
}
$('.frontendForm').each(function () { $('.frontendForm').each(function () {
// TODO Handle empty strings as null or undefined, not as '' // TODO Handle empty strings as null or undefined, not as ''
$(this).on('submit', function (e) { $(this).on('submit', function (e) {
@ -26,7 +35,12 @@ $('.frontendForm').each(function () {
// Clear all fields // Clear all fields
form.find('input, textarea').val(''); form.find('input, textarea').val('');
// Create toast // Create toast
if(isNewDataLoaderAvailable()) {
createNewToast('<i class="bi bi-check2"></i> Changes saved successfully.', "text-bg-success", undefined, false)
} else {
createNewToast('<i class="bi bi-check2"></i> Changes saved successfully.', "text-bg-success") createNewToast('<i class="bi bi-check2"></i> Changes saved successfully.', "text-bg-success")
}
}, },
error: function (data) { error: function (data) {
console.log('error'); console.log('error');

View File

@ -10,7 +10,6 @@ trinagles.each(function () {
$(this).addClass('rotate'); $(this).addClass('rotate');
} }
console.log('target', target);
target.on('show.bs.collapse', function () { target.on('show.bs.collapse', function () {
$(triTar).addClass('rotate'); $(triTar).addClass('rotate');
$(triTar).removeClass('derotate'); $(triTar).removeClass('derotate');

View File

@ -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 = '<i>No SKU assigned</i>';
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 = `<span class="badge text-bg-${colorStatus}">${item.status}</span>`;
item.actions = `
<button class="btn btn-primary" data-bs-toggle="modal" data-bs-target="#itemModifyModal" onclick="primeEdit(); getDataForEdit('${item.id}')">
<i class="bi bi-pencil"></i>
</button>
<button class="btn btn-danger" onclick="preFillDeleteModalNxt('${item.id}','items','Item')" data-bs-toggle="modal" data-bs-target="#staticBackdrop">
<i class="bi bi-trash"></i>
</button>`
item.SKU = `<p data-bs-toggle="tooltip" data-bs-placement="left" data-bs-title="ID: ${item.id}">${item.SKU}</p>`
});
///// --------------------------------- /////
return {"rows": json, total: total, totalNotFiltered: totalNotFiltered, totalRows: total};
}
loadPageData()

View File

@ -26,6 +26,11 @@ function createNewToast(message, colorSelector, autoHideTime = 1500, autoReload
targetContainer.appendChild(newToast); targetContainer.appendChild(newToast);
currentToasts.push(newToast); currentToasts.push(newToast);
$(newToast).toast('show'); $(newToast).toast('show');
try {
loadPageData();
} catch (error) {
console.debug("Page does not support new data loading.")
}
setTimeout(() => { setTimeout(() => {
destroyToast(newToast.id); destroyToast(newToast.id);
if (autoReload && !forceSkipReload) { if (autoReload && !forceSkipReload) {