pagination + sql expressions + frontend improvements

This commit is contained in:
Roman 2023-01-19 18:12:16 +01:00
parent 878cd62bbe
commit 92c78356ed
16 changed files with 216 additions and 71 deletions

@ -18,7 +18,7 @@ trait Pagination {
return [ return [
'page' => new Parameter('page', Parameter::TYPE_INT, true, 1), 'page' => new Parameter('page', Parameter::TYPE_INT, true, 1),
'count' => new Parameter('count', Parameter::TYPE_INT, true, 25), 'count' => new Parameter('count', Parameter::TYPE_INT, true, 25),
'orderBy' => new StringType('orderBy', -1, true, $defaultOrderBy, $orderColumns), 'orderBy' => new StringType('orderBy', -1, true, $defaultOrderBy, array_values($orderColumns)),
'sortOrder' => new StringType('sortOrder', -1, true, $defaultSortOrder, ['asc', 'desc']), 'sortOrder' => new StringType('sortOrder', -1, true, $defaultSortOrder, ['asc', 'desc']),
]; ];
} }
@ -36,7 +36,7 @@ trait Pagination {
} }
$pageCount = intval(ceil($this->entityCount / $this->pageSize)); $pageCount = intval(ceil($this->entityCount / $this->pageSize));
$this->page = min($this->page, $pageCount); // number of pages changed due to pageSize / filter $this->page = max(1, min($this->page, $pageCount)); // number of pages changed due to pageSize / filter
$this->result["pagination"] = [ $this->result["pagination"] = [
"current" => $this->page, "current" => $this->page,
@ -91,18 +91,22 @@ trait Pagination {
} }
if ($orderBy) { if ($orderBy) {
$handler = $baseQuery->getHandler(); $sortColumn = array_search($orderBy, $this->paginationOrderColumns);
$baseTable = $handler->getTableName(); if (is_string($sortColumn)) {
$sortColumn = DatabaseEntityHandler::buildColumnName($orderBy);
$fullyQualifiedColumn = "$baseTable.$sortColumn";
$selectedColumns = $baseQuery->getSelectValues();
if (in_array($sortColumn, $selectedColumns)) {
$entityQuery->orderBy($sortColumn); $entityQuery->orderBy($sortColumn);
} else if (in_array($fullyQualifiedColumn, $selectedColumns)) {
$entityQuery->orderBy($fullyQualifiedColumn);
} else { } else {
$entityQuery->orderBy($orderBy); $handler = $baseQuery->getHandler();
$baseTable = $handler->getTableName();
$sortColumn = DatabaseEntityHandler::buildColumnName($orderBy);
$fullyQualifiedColumn = "$baseTable.$sortColumn";
$selectedColumns = $baseQuery->getSelectValues();
if (in_array($sortColumn, $selectedColumns)) {
$entityQuery->orderBy($sortColumn);
} else if (in_array($fullyQualifiedColumn, $selectedColumns)) {
$entityQuery->orderBy($fullyQualifiedColumn);
} else {
$entityQuery->orderBy($orderBy);
}
} }
} }

@ -0,0 +1,28 @@
<?php
namespace Core\Driver\SQL\Expression;
use Core\Driver\SQL\SQL;
abstract class AbstractFunction extends Expression {
private string $functionName;
private mixed $value;
public function __construct(string $functionName, mixed $value) {
$this->functionName = $functionName;
$this->value = $value;
}
public function getExpression(SQL $sql, array &$params): string {
return $this->functionName . "(" . $sql->addValue($this->getValue(), $params) . ")";
}
public function getFunctionName(): string {
return $this->functionName;
}
public function getValue(): mixed {
return $this->value;
}
}

@ -2,21 +2,10 @@
namespace Core\Driver\SQL\Expression; namespace Core\Driver\SQL\Expression;
use Core\Driver\SQL\SQL; class Distinct extends AbstractFunction {
class Distinct extends Expression {
private mixed $value;
public function __construct(mixed $value) { public function __construct(mixed $value) {
$this->value = $value; parent::__construct("DISTINCT", $value);
} }
public function getValue(): mixed {
return $this->value;
}
function getExpression(SQL $sql, array &$params): string {
return "DISTINCT(" . $sql->addValue($this->getValue(), $params) . ")";
}
} }

@ -0,0 +1,11 @@
<?php
namespace Core\Driver\SQL\Expression;
class Lower extends AbstractFunction {
public function __construct(mixed $value) {
parent::__construct("LOWER", $value);
}
}

@ -2,15 +2,10 @@
namespace Core\Driver\SQL\Expression; namespace Core\Driver\SQL\Expression;
use Core\Driver\SQL\SQL; class Sum extends AbstractFunction {
class Sum extends Alias { public function __construct(mixed $value) {
parent::__construct("SUM", $value);
public function __construct(mixed $value, string $alias) {
parent::__construct($value, $alias);
} }
protected function addValue(SQL $sql, array &$params): string {
return "SUM(" . $sql->addValue($this->getValue(), $params) . ")";
}
} }

@ -0,0 +1,11 @@
<?php
namespace Core\Driver\SQL\Expression;
class Upper extends AbstractFunction {
public function __construct(mixed $value) {
parent::__construct("UPPER", $value);
}
}

@ -35,6 +35,7 @@ return [
"rename" => "Umbenennen", "rename" => "Umbenennen",
"remove" => "Entfernen", "remove" => "Entfernen",
"change" => "Bearbeiten", "change" => "Bearbeiten",
"close" => "Schließen",
"reset" => "Zurücksetzen", "reset" => "Zurücksetzen",
"move" => "Verschieben", "move" => "Verschieben",
"delete" => "Löschen", "delete" => "Löschen",

@ -27,6 +27,7 @@ return [
"request" => "Request", "request" => "Request",
"cancel" => "Cancel", "cancel" => "Cancel",
"confirm" => "Confirm", "confirm" => "Confirm",
"close" => "Close",
"ok" => "OK", "ok" => "OK",
"remove" => "Remove", "remove" => "Remove",
"change" => "Change", "change" => "Change",

@ -146,12 +146,12 @@ class User extends DatabaseEntity {
return !empty($this->fullName) ? $this->fullName : $this->name; return !empty($this->fullName) ? $this->fullName : $this->name;
} }
public static function buildSQLDisplayName(SQL $sql, string $joinColumn): Alias { public static function buildSQLDisplayName(SQL $sql, string $joinColumn, string $alias = "user"): Alias {
return new Alias( return new Alias(
$sql->select(new Coalesce( $sql->select(new Coalesce(
new NullIf(new Column("User.full_name"), ""), new NullIf(new Column("User.full_name"), ""),
new NullIf(new Column("User.name"), "")) new NullIf(new Column("User.name"), ""))
)->from("User")->whereEq("User.id", new Column($joinColumn)), )->from("User")->whereEq("User.id", new Column($joinColumn)),
"user"); $alias);
} }
} }

@ -1,5 +1,9 @@
import {USER_GROUP_ADMIN} from "./constants"; import {USER_GROUP_ADMIN} from "./constants";
import {isInt} from "./util"; import {createDownload, isInt} from "./util";
Date.prototype.toJSON = function() {
return Math.round(this.getTime() / 1000);
};
export default class API { export default class API {
constructor() { constructor() {
@ -14,7 +18,7 @@ export default class API {
return this.loggedIn ? this.session.csrfToken : null; return this.loggedIn ? this.session.csrfToken : null;
} }
async apiCall(method, params) { async apiCall(method, params, expectBinary=false) {
params = params || { }; params = params || { };
const csrfToken = this.csrfToken(); const csrfToken = this.csrfToken();
const config = {method: 'post'}; const config = {method: 'post'};
@ -32,6 +36,16 @@ export default class API {
} }
let response = await fetch("/api/" + method, config); let response = await fetch("/api/" + method, config);
if (response.headers.has("content-disposition")) {
let contentDisposition = response.headers.get("content-disposition");
if (contentDisposition.toLowerCase().startsWith("attachment;")) {
let fileName = /filename="?([^"]*)"?/;
let blob = await response.blob();
createDownload(fileName.exec(contentDisposition)[1], blob);
return { success: true, msg: "" };
}
}
let res = await response.json(); let res = await response.json();
if (!res.success && res.msg === "You are not logged in.") { if (!res.success && res.msg === "You are not logged in.") {
this.loggedIn = false; this.loggedIn = false;
@ -56,7 +70,6 @@ export default class API {
return false; return false;
} }
hasGroup(groupIdOrName) { hasGroup(groupIdOrName) {
if (this.loggedIn && this.user?.groups) { if (this.loggedIn && this.user?.groups) {
if (isInt(groupIdOrName)) { if (isInt(groupIdOrName)) {

@ -25,3 +25,12 @@
.data-table-clickable { .data-table-clickable {
cursor: pointer; cursor: pointer;
} }
.pagination-controls {
margin-top: 6px;
}
.pagination-page-size > div {
padding-top: 5px;
padding-bottom: 5px;
}

@ -85,8 +85,10 @@ export function DataTable(props) {
} }
const numColumns = columns.length; const numColumns = columns.length;
let numRows = 0;
let rows = []; let rows = [];
if (data && data?.length) { if (data && data?.length) {
numRows = data.length;
for (const [rowIndex, entry] of data.entries()) { for (const [rowIndex, entry] of data.entries()) {
let row = []; let row = [];
for (const [index, column] of columns.entries()) { for (const [index, column] of columns.entries()) {
@ -96,14 +98,14 @@ export function DataTable(props) {
} }
rows.push(<TableRow className={clsx({["data-table-clickable"]: typeof onClick === 'function'})} rows.push(<TableRow className={clsx({["data-table-clickable"]: typeof onClick === 'function'})}
onClick={() => onRowClick(rowIndex, entry)} onClick={(e) => ["tr","td"].includes(e.target.tagName.toLowerCase()) && onRowClick(rowIndex, entry)}
key={"row-" + rowIndex}> key={"row-" + rowIndex}>
{ row } { row }
</TableRow>); </TableRow>);
} }
} else if (placeholder) { } else if (placeholder) {
rows.push(<TableRow key={"row-placeholder"}> rows.push(<TableRow key={"row-placeholder"}>
<TableCell colSpan={numColumns}> <TableCell colSpan={numColumns} align={"center"}>
{ placeholder } { placeholder }
</TableCell> </TableCell>
</TableRow>); </TableRow>);
@ -126,7 +128,7 @@ export function DataTable(props) {
{ rows } { rows }
</TableBody> </TableBody>
</Table> </Table>
{pagination.renderPagination(L, rows.length)} {pagination.renderPagination(L, numRows)}
</Box> </Box>
} }
@ -140,7 +142,7 @@ export class DataColumn {
} }
renderData(L, entry, index) { renderData(L, entry, index) {
return entry[this.field] return typeof this.field === 'function' ? this.field(entry) : entry[this.field];
} }
renderHead() { renderHead() {
@ -228,14 +230,33 @@ export class ControlsColumn extends DataColumn {
renderData(L, entry, index) { renderData(L, entry, index) {
let buttonElements = []; let buttonElements = [];
for (const [index, button] of this.buttons.entries()) { for (const [index, button] of this.buttons.entries()) {
let element = button.element; let element = typeof button.element === 'function'
let props = { ? button.element(entry, index)
key: "button-" + index, : button.element;
onClick: (() => button.onClick(entry)),
className: "data-table-clickable" let buttonProps = {};
if (typeof button.props === 'function') {
buttonProps = button.props(entry, index);
} else {
buttonProps = button.props;
} }
if (typeof button.showIf !== 'function' || button.showIf(entry)) { let props = {
...buttonProps,
key: "button-" + index,
onClick: (e) => { e.stopPropagation(); button.onClick(entry, index); },
className: "data-table-clickable",
}
if (button.hasOwnProperty("disabled")) {
props.disabled = typeof button.disabled === 'function'
? button.disabled(entry, index)
: button.disabled;
}
if ((!button.hasOwnProperty("hidden")) ||
(typeof button.hidden === 'function' && !button.hidden(entry, index)) ||
(!button.hidden)) {
buttonElements.push(React.createElement(element, props)) buttonElements.push(React.createElement(element, props))
} }
} }

@ -1,8 +1,14 @@
import React, {useContext} from "react"; import React, {useState} from "react";
import {Dialog as MuiDialog, DialogActions, DialogContent, DialogContentText, DialogTitle} from "@mui/material"; import {
import {Button} from "@material-ui/core"; Box,
import {LocaleContext} from "../locale"; Button,
import "./dialog.css"; Dialog as MuiDialog,
DialogActions,
DialogContent,
DialogContentText,
DialogTitle,
Input, TextField
} from "@mui/material";
export default function Dialog(props) { export default function Dialog(props) {
@ -10,37 +16,55 @@ export default function Dialog(props) {
const onClose = props.onClose || function() { }; const onClose = props.onClose || function() { };
const onOption = props.onOption || function() { }; const onOption = props.onOption || function() { };
const options = props.options || ["Close"]; const options = props.options || ["Close"];
const type = props.type || "default"; const inputs = props.inputs || [];
const {translate: L} = useContext(LocaleContext);
const [inputData, setInputData] = useState({});
let buttons = []; let buttons = [];
for (let name of options) { for (const [index, name] of options.entries()) {
let type = "default";
if (name === "Yes") type = "warning";
else if(name === "No") type = "danger";
buttons.push( buttons.push(
<Button variant={"outlined"} size={"small"} type="button" key={"button-" + name} <Button variant={"outlined"} size={"small"} key={"button-" + name}
data-dismiss={"modal"} onClick={() => { onClose(); onOption(name); }}> onClick={() => { onClose(); onOption(index, inputData); }}>
{name} {name}
</Button> </Button>
) )
} }
let inputElements = [];
for (const input of inputs) {
let inputProps = { ...input };
delete inputProps.name;
delete inputProps.type;
switch (input.type) {
case 'text':
inputElements.push(<TextField
{...inputProps}
size={"small"} fullWidth={true}
key={"input-" + input.name}
value={inputData[input.name] || ""}
onChange={e => setInputData({ ...inputData, [input.name]: e.target.value })}
/>)
break;
}
}
return <MuiDialog return <MuiDialog
open={show} open={show}
onClose={onClose} onClose={onClose}>
aria-labelledby="alert-dialog-title" <DialogTitle>
aria-describedby="alert-dialog-description"> { props.title }
<DialogTitle>{ props.title }</DialogTitle> </DialogTitle>
<DialogContent> <DialogContent>
<DialogContentText> <DialogContentText>
{ props.message } { props.message }
</DialogContentText> </DialogContentText>
<Box mt={2}>
{ inputElements }
</Box>
</DialogContent> </DialogContent>
<DialogActions> <DialogActions>
{buttons} { buttons }
</DialogActions> </DialogActions>
</MuiDialog> </MuiDialog>
} }

@ -0,0 +1,21 @@
import {useLocation, useParams} from "react-router-dom";
export default function useCurrentPath() {
const location = useLocation();
const params = useParams();
const { pathname } = location;
if (!Object.keys(params).length) {
return pathname; // we don't need to replace anything
}
let path = pathname;
Object.entries(params).forEach(([paramName, paramValue]) => {
if (paramValue) {
path = path.replace(paramValue, `:${paramName}`);
}
});
return path;
}

@ -56,17 +56,23 @@ class Pagination {
renderPagination(L, numEntries, options = null) { renderPagination(L, numEntries, options = null) {
options = options || [10, 25, 50, 100]; options = options || [10, 25, 50, 100];
return <Box> return <Box display={"grid"} gridTemplateColumns={"75px auto"} className={"pagination-controls"}>
<Select <Select
value={this.data.pageSize} value={this.data.pageSize}
className={"pagination-page-size"}
label={L("general.entries_per_page")} label={L("general.entries_per_page")}
onChange={(e) => this.setPageSize(parseInt(e.target.value))} onChange={(e) => this.setPageSize(parseInt(e.target.value))}
size={"small"} size={"small"}
> >
{options.map(size => <MenuItem key={"size-" + size} value={size}>{size}</MenuItem>)} {options.map(size => <MenuItem key={"size-" + size} value={size}>{size}</MenuItem>)}
</Select> </Select>
<MuiPagination count={this.getPageCount()} onChange={(_, page) => this.setPage(page)} /> <MuiPagination
{sprintf(L("general.showing_x_of_y_entries"), numEntries, this.data.total)} count={this.getPageCount()}
onChange={(_, page) => this.setPage(page)}
/>
<Box gridColumn={"1 / 3"} mt={1}>
{sprintf(L("general.showing_x_of_y_entries"), numEntries, this.data.total)}
</Box>
</Box> </Box>
} }
} }

@ -1,6 +1,17 @@
import {format, parse} from "date-fns"; import {format, parse} from "date-fns";
import {API_DATE_FORMAT, API_DATETIME_FORMAT} from "./constants"; import {API_DATE_FORMAT, API_DATETIME_FORMAT} from "./constants";
function createDownload(name, data) {
const url = window.URL.createObjectURL(new Blob([data]));
const link = document.createElement('a');
link.href = url;
link.setAttribute('download', name);
link.setAttribute("target", "_blank");
document.body.appendChild(link);
link.click();
link.remove();
}
function humanReadableSize(bytes, dp = 1) { function humanReadableSize(bytes, dp = 1) {
const thresh = 1024; const thresh = 1024;
@ -87,4 +98,4 @@ const isInt = (value) => {
} }
export { humanReadableSize, removeParameter, getParameter, encodeText, decodeText, getBaseUrl, export { humanReadableSize, removeParameter, getParameter, encodeText, decodeText, getBaseUrl,
formatDate, formatDateTime, upperFirstChars, isInt }; formatDate, formatDateTime, upperFirstChars, isInt, createDownload };