diff --git a/Core/API/Traits/Pagination.trait.php b/Core/API/Traits/Pagination.trait.php index 63985f7..4aa7e48 100644 --- a/Core/API/Traits/Pagination.trait.php +++ b/Core/API/Traits/Pagination.trait.php @@ -18,7 +18,7 @@ trait Pagination { return [ 'page' => new Parameter('page', Parameter::TYPE_INT, true, 1), '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']), ]; } @@ -36,7 +36,7 @@ trait Pagination { } $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"] = [ "current" => $this->page, @@ -91,18 +91,22 @@ trait Pagination { } if ($orderBy) { - $handler = $baseQuery->getHandler(); - $baseTable = $handler->getTableName(); - $sortColumn = DatabaseEntityHandler::buildColumnName($orderBy); - $fullyQualifiedColumn = "$baseTable.$sortColumn"; - $selectedColumns = $baseQuery->getSelectValues(); - - if (in_array($sortColumn, $selectedColumns)) { + $sortColumn = array_search($orderBy, $this->paginationOrderColumns); + if (is_string($sortColumn)) { $entityQuery->orderBy($sortColumn); - } else if (in_array($fullyQualifiedColumn, $selectedColumns)) { - $entityQuery->orderBy($fullyQualifiedColumn); } 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); + } } } diff --git a/Core/Driver/SQL/Expression/AbstractFunction.class.php b/Core/Driver/SQL/Expression/AbstractFunction.class.php new file mode 100644 index 0000000..d1ef8fe --- /dev/null +++ b/Core/Driver/SQL/Expression/AbstractFunction.class.php @@ -0,0 +1,28 @@ +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; + } +} \ No newline at end of file diff --git a/Core/Driver/SQL/Expression/Distinct.class.php b/Core/Driver/SQL/Expression/Distinct.class.php index 31fa827..fc59dd3 100644 --- a/Core/Driver/SQL/Expression/Distinct.class.php +++ b/Core/Driver/SQL/Expression/Distinct.class.php @@ -2,21 +2,10 @@ namespace Core\Driver\SQL\Expression; -use Core\Driver\SQL\SQL; - -class Distinct extends Expression { - - private mixed $value; +class Distinct extends AbstractFunction { 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) . ")"; - } } \ No newline at end of file diff --git a/Core/Driver/SQL/Expression/Lower.class.php b/Core/Driver/SQL/Expression/Lower.class.php new file mode 100644 index 0000000..0a0ab21 --- /dev/null +++ b/Core/Driver/SQL/Expression/Lower.class.php @@ -0,0 +1,11 @@ +addValue($this->getValue(), $params) . ")"; - } } \ No newline at end of file diff --git a/Core/Driver/SQL/Expression/Upper.class.php b/Core/Driver/SQL/Expression/Upper.class.php new file mode 100644 index 0000000..ca76341 --- /dev/null +++ b/Core/Driver/SQL/Expression/Upper.class.php @@ -0,0 +1,11 @@ + "Umbenennen", "remove" => "Entfernen", "change" => "Bearbeiten", + "close" => "Schließen", "reset" => "Zurücksetzen", "move" => "Verschieben", "delete" => "Löschen", diff --git a/Core/Localization/en_US/general.php b/Core/Localization/en_US/general.php index ce24620..e52d7e8 100644 --- a/Core/Localization/en_US/general.php +++ b/Core/Localization/en_US/general.php @@ -27,6 +27,7 @@ return [ "request" => "Request", "cancel" => "Cancel", "confirm" => "Confirm", + "close" => "Close", "ok" => "OK", "remove" => "Remove", "change" => "Change", diff --git a/Core/Objects/DatabaseEntity/User.class.php b/Core/Objects/DatabaseEntity/User.class.php index 1a162bf..37899a3 100644 --- a/Core/Objects/DatabaseEntity/User.class.php +++ b/Core/Objects/DatabaseEntity/User.class.php @@ -146,12 +146,12 @@ class User extends DatabaseEntity { 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( $sql->select(new Coalesce( new NullIf(new Column("User.full_name"), ""), new NullIf(new Column("User.name"), "")) )->from("User")->whereEq("User.id", new Column($joinColumn)), - "user"); + $alias); } } \ No newline at end of file diff --git a/react/shared/api.js b/react/shared/api.js index 8bac1ab..0088234 100644 --- a/react/shared/api.js +++ b/react/shared/api.js @@ -1,5 +1,9 @@ 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 { constructor() { @@ -14,7 +18,7 @@ export default class API { return this.loggedIn ? this.session.csrfToken : null; } - async apiCall(method, params) { + async apiCall(method, params, expectBinary=false) { params = params || { }; const csrfToken = this.csrfToken(); const config = {method: 'post'}; @@ -32,6 +36,16 @@ export default class API { } 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(); if (!res.success && res.msg === "You are not logged in.") { this.loggedIn = false; @@ -56,7 +70,6 @@ export default class API { return false; } - hasGroup(groupIdOrName) { if (this.loggedIn && this.user?.groups) { if (isInt(groupIdOrName)) { diff --git a/react/shared/elements/data-table.css b/react/shared/elements/data-table.css index f1fcf46..44fb47e 100644 --- a/react/shared/elements/data-table.css +++ b/react/shared/elements/data-table.css @@ -24,4 +24,13 @@ .data-table-clickable { cursor: pointer; +} + +.pagination-controls { + margin-top: 6px; +} + +.pagination-page-size > div { + padding-top: 5px; + padding-bottom: 5px; } \ No newline at end of file diff --git a/react/shared/elements/data-table.js b/react/shared/elements/data-table.js index 336b21f..ca52b5f 100644 --- a/react/shared/elements/data-table.js +++ b/react/shared/elements/data-table.js @@ -85,8 +85,10 @@ export function DataTable(props) { } const numColumns = columns.length; + let numRows = 0; let rows = []; if (data && data?.length) { + numRows = data.length; for (const [rowIndex, entry] of data.entries()) { let row = []; for (const [index, column] of columns.entries()) { @@ -96,14 +98,14 @@ export function DataTable(props) { } rows.push( onRowClick(rowIndex, entry)} + onClick={(e) => ["tr","td"].includes(e.target.tagName.toLowerCase()) && onRowClick(rowIndex, entry)} key={"row-" + rowIndex}> { row } ); } } else if (placeholder) { rows.push( - + { placeholder } ); @@ -126,7 +128,7 @@ export function DataTable(props) { { rows } - {pagination.renderPagination(L, rows.length)} + {pagination.renderPagination(L, numRows)} } @@ -140,7 +142,7 @@ export class DataColumn { } renderData(L, entry, index) { - return entry[this.field] + return typeof this.field === 'function' ? this.field(entry) : entry[this.field]; } renderHead() { @@ -228,14 +230,33 @@ export class ControlsColumn extends DataColumn { renderData(L, entry, index) { let buttonElements = []; for (const [index, button] of this.buttons.entries()) { - let element = button.element; - let props = { - key: "button-" + index, - onClick: (() => button.onClick(entry)), - className: "data-table-clickable" + let element = typeof button.element === 'function' + ? button.element(entry, index) + : button.element; + + 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)) } } diff --git a/react/shared/elements/dialog.jsx b/react/shared/elements/dialog.jsx index 6fe48fa..c126617 100644 --- a/react/shared/elements/dialog.jsx +++ b/react/shared/elements/dialog.jsx @@ -1,8 +1,14 @@ -import React, {useContext} from "react"; -import {Dialog as MuiDialog, DialogActions, DialogContent, DialogContentText, DialogTitle} from "@mui/material"; -import {Button} from "@material-ui/core"; -import {LocaleContext} from "../locale"; -import "./dialog.css"; +import React, {useState} from "react"; +import { + Box, + Button, + Dialog as MuiDialog, + DialogActions, + DialogContent, + DialogContentText, + DialogTitle, + Input, TextField +} from "@mui/material"; export default function Dialog(props) { @@ -10,37 +16,55 @@ export default function Dialog(props) { const onClose = props.onClose || function() { }; const onOption = props.onOption || function() { }; const options = props.options || ["Close"]; - const type = props.type || "default"; - const {translate: L} = useContext(LocaleContext); + const inputs = props.inputs || []; + const [inputData, setInputData] = useState({}); let buttons = []; - for (let name of options) { - let type = "default"; - if (name === "Yes") type = "warning"; - else if(name === "No") type = "danger"; - + for (const [index, name] of options.entries()) { buttons.push( - ) } + let inputElements = []; + for (const input of inputs) { + let inputProps = { ...input }; + delete inputProps.name; + delete inputProps.type; + + switch (input.type) { + case 'text': + inputElements.push( setInputData({ ...inputData, [input.name]: e.target.value })} + />) + break; + } + } + return - { props.title } + onClose={onClose}> + + { props.title } + { props.message } + + { inputElements } + - {buttons} + { buttons } } \ No newline at end of file diff --git a/react/shared/hooks/current-path.js b/react/shared/hooks/current-path.js new file mode 100644 index 0000000..c7696aa --- /dev/null +++ b/react/shared/hooks/current-path.js @@ -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; +} \ No newline at end of file diff --git a/react/shared/hooks/pagination.js b/react/shared/hooks/pagination.js index 80bcf00..7879c44 100644 --- a/react/shared/hooks/pagination.js +++ b/react/shared/hooks/pagination.js @@ -56,17 +56,23 @@ class Pagination { renderPagination(L, numEntries, options = null) { options = options || [10, 25, 50, 100]; - return + return - this.setPage(page)} /> - {sprintf(L("general.showing_x_of_y_entries"), numEntries, this.data.total)} + this.setPage(page)} + /> + + {sprintf(L("general.showing_x_of_y_entries"), numEntries, this.data.total)} + } } diff --git a/react/shared/util.js b/react/shared/util.js index 4d56096..b30bb8d 100644 --- a/react/shared/util.js +++ b/react/shared/util.js @@ -1,6 +1,17 @@ import {format, parse} from "date-fns"; 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) { const thresh = 1024; @@ -87,4 +98,4 @@ const isInt = (value) => { } export { humanReadableSize, removeParameter, getParameter, encodeText, decodeText, getBaseUrl, - formatDate, formatDateTime, upperFirstChars, isInt }; \ No newline at end of file + formatDate, formatDateTime, upperFirstChars, isInt, createDownload }; \ No newline at end of file