SQL expression rewrite, Pagination, some frontend stuff
This commit is contained in:
@@ -19,7 +19,7 @@ export default class API {
|
||||
|
||||
let res = await response.json();
|
||||
if (!res.success && res.msg === "You are not logged in.") {
|
||||
document.location.reload();
|
||||
this.loggedIn = false;
|
||||
}
|
||||
|
||||
return res;
|
||||
@@ -69,12 +69,16 @@ export default class API {
|
||||
return this.apiCall("user/delete", { id: id });
|
||||
}
|
||||
|
||||
async fetchUsers(pageNum = 1, count = 20) {
|
||||
return this.apiCall("user/fetch", { page: pageNum, count: count });
|
||||
async fetchUsers(pageNum = 1, count = 20, orderBy = 'id', sortOrder = 'asc') {
|
||||
return this.apiCall("user/fetch", { page: pageNum, count: count, orderBy: orderBy, sortOrder: sortOrder });
|
||||
}
|
||||
|
||||
async fetchGroups(pageNum = 1, count = 20) {
|
||||
return this.apiCall("groups/fetch", { page: pageNum, count: count });
|
||||
async fetchGroups(pageNum = 1, count = 20, orderBy = 'id', sortOrder = 'asc') {
|
||||
return this.apiCall("groups/fetch", { page: pageNum, count: count, orderBy: orderBy, sortOrder: sortOrder });
|
||||
}
|
||||
|
||||
async getGroup(id) {
|
||||
return this.apiCall("groups/get", { id: id });
|
||||
}
|
||||
|
||||
async inviteUser(username, email) {
|
||||
|
||||
20
react/shared/elements/data-table.css
Normal file
20
react/shared/elements/data-table.css
Normal file
@@ -0,0 +1,20 @@
|
||||
.data-table {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.data-table td, .data-table th {
|
||||
padding: 2px;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.data-table th {
|
||||
background-color: #bbb;
|
||||
}
|
||||
|
||||
.sortable {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.center {
|
||||
text-align: center;
|
||||
}
|
||||
323
react/shared/elements/data-table.js
Normal file
323
react/shared/elements/data-table.js
Normal file
@@ -0,0 +1,323 @@
|
||||
import {Table, TableBody, TableCell, TableHead, TableRow} from "@material-ui/core";
|
||||
import ArrowUpwardIcon from "@material-ui/icons/ArrowUpward";
|
||||
import ArrowDownwardIcon from "@material-ui/icons/ArrowDownward";
|
||||
import React, {useCallback, useContext, useEffect, useState} from "react";
|
||||
import usePagination from "../hooks/pagination";
|
||||
import {parse} from "date-fns";
|
||||
import "./data-table.css";
|
||||
import {LocaleContext} from "../locale";
|
||||
import clsx from "clsx";
|
||||
import {Box} from "@mui/material";
|
||||
|
||||
|
||||
export function DataTable(props) {
|
||||
|
||||
const { className, placeholder,
|
||||
fetchData, onClick, onFilter,
|
||||
defaultSortColumn, defaultSortOrder,
|
||||
columns, ...other } = props;
|
||||
|
||||
const {currentLocale, requestModules, translate: L} = useContext(LocaleContext);
|
||||
|
||||
const [doFetchData, setFetchData] = useState(true);
|
||||
const [data, setData] = useState(null);
|
||||
const [sortAscending, setSortAscending] = useState(["asc","ascending"].includes(defaultSortOrder?.toLowerCase));
|
||||
const [sortColumn, setSortColumn] = useState(defaultSortColumn || null);
|
||||
const pagination = usePagination();
|
||||
const sortable = props.hasOwnProperty("sortable") ? !!props.sortable : true;
|
||||
|
||||
const onFetchData = useCallback((force = false) => {
|
||||
if (doFetchData || force) {
|
||||
setFetchData(false);
|
||||
const orderBy = columns[sortColumn]?.field || null;
|
||||
const sortOrder = sortAscending ? "asc" : "desc";
|
||||
fetchData(pagination.getPage(), pagination.getPageSize(), orderBy, sortOrder).then(([data, dataPagination]) => {
|
||||
if (data) {
|
||||
setData(data);
|
||||
pagination.update(dataPagination);
|
||||
}
|
||||
});
|
||||
}
|
||||
}, [doFetchData, columns, sortColumn, sortAscending, pagination]);
|
||||
|
||||
// pagination changed?
|
||||
useEffect(() => {
|
||||
let forceFetch = (pagination.getPageSize() < pagination.getTotal());
|
||||
onFetchData(forceFetch);
|
||||
}, [pagination.data.pageSize, pagination.data.current]);
|
||||
|
||||
// sorting changed
|
||||
useEffect(() => {
|
||||
onFetchData(true);
|
||||
}, [sortAscending, sortColumn]);
|
||||
|
||||
let headerRow = [];
|
||||
const onChangeSort = useCallback((index, column) => {
|
||||
if (sortable && column.sortable) {
|
||||
if (sortColumn === index) {
|
||||
setSortAscending(!sortAscending);
|
||||
} else {
|
||||
setSortColumn(index);
|
||||
}
|
||||
}
|
||||
}, [onFetchData, sortColumn, sortAscending]);
|
||||
|
||||
for (const [index, column] of columns.entries()) {
|
||||
if (!(column instanceof DataColumn)) {
|
||||
throw new Error("DataTable can only have DataColumn-objects as column definition, got: " + typeof column);
|
||||
}
|
||||
|
||||
if (sortable && column.sortable) {
|
||||
headerRow.push(<TableCell key={"col-" + index} className={"sortable"}
|
||||
title={L("general.sort_by") + ": " + column.label}
|
||||
onClick={() => onChangeSort(index, column) }>
|
||||
{sortColumn === index ? (sortAscending ? <ArrowUpwardIcon /> : <ArrowDownwardIcon />): <></>}{column.renderHead(index)}
|
||||
</TableCell>);
|
||||
} else {
|
||||
headerRow.push(<TableCell key={"col-" + index}>
|
||||
{column.renderHead(index)}
|
||||
</TableCell>);
|
||||
}
|
||||
}
|
||||
|
||||
const numColumns = columns.length;
|
||||
let rows = [];
|
||||
if (data) {
|
||||
for (const [key, entry] of Object.entries(data)) {
|
||||
let row = [];
|
||||
for (const [index, column] of columns.entries()) {
|
||||
row.push(<TableCell key={"col-" + index}>{column.renderData(entry)}</TableCell>);
|
||||
}
|
||||
|
||||
rows.push(<TableRow key={"row-" + key}>{ row }</TableRow>);
|
||||
}
|
||||
} else if (placeholder) {
|
||||
rows.push(<TableRow key={"row-placeholder"}>
|
||||
<TableCell colSpan={numColumns}>
|
||||
{ placeholder }
|
||||
</TableCell>
|
||||
</TableRow>);
|
||||
}
|
||||
|
||||
/*
|
||||
|
||||
|
||||
let columnElements = [];
|
||||
if (columns) {
|
||||
for (const [key, column] of Object.entries(columns)) {
|
||||
const centered = column.alignment === "center";
|
||||
const sortable = doSort && (!column.hasOwnProperty("sortable") || !!column.sortable);
|
||||
const label = column.label;
|
||||
|
||||
if (!sortable) {
|
||||
columnElements.push(
|
||||
<TableCell key={"column-" + key} className={clsx(centered && classes.columnCenter)}>
|
||||
{ label }
|
||||
</TableCell>
|
||||
);
|
||||
} else {
|
||||
columnElements.push(
|
||||
<TableCell key={"column-" + key} label={L("Sort By") + ": " + label} className={clsx(classes.clickable, centered && classes.columnCenter)}
|
||||
onClick={() => (key === sortColumn ? setSortAscending(!sortAscending) : setSortColumn(key)) }>
|
||||
{ key === sortColumn ?
|
||||
<Grid container alignItems={"center"} spacing={1} direction={"row"} className={classes.gridSorted}>
|
||||
<Grid item>{ sortAscending ? <ArrowUpwardIcon fontSize={"small"} /> : <ArrowDownwardIcon fontSize={"small"} /> }</Grid>
|
||||
<Grid item>{ label }</Grid>
|
||||
<Grid item/>
|
||||
</Grid> :
|
||||
<span><i/>{label}</span>
|
||||
}
|
||||
</TableCell>
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const getValue = useCallback((entry, key) => {
|
||||
if (typeof columns[key]?.value === 'function') {
|
||||
return columns[key].value(entry);
|
||||
} else {
|
||||
return entry[columns[key]?.value] ?? null;
|
||||
}
|
||||
}, [columns]);
|
||||
|
||||
let numColumns = columns ? Object.keys(columns).length : 0;
|
||||
|
||||
const compare = (a,b,callback) => {
|
||||
let definedA = a !== null && typeof a !== 'undefined';
|
||||
let definedB = b !== null && typeof b !== 'undefined';
|
||||
if (!definedA && !definedB) {
|
||||
return 0;
|
||||
} else if (!definedA) {
|
||||
return 1;
|
||||
} else if (!definedB) {
|
||||
return -1;
|
||||
} else {
|
||||
return callback(a,b);
|
||||
}
|
||||
}
|
||||
|
||||
let rows = [];
|
||||
const hasClickHandler = typeof onClick === 'function';
|
||||
if (data !== null && columns) {
|
||||
let hidden = 0;
|
||||
let sortedEntries = data.slice();
|
||||
|
||||
if (sortColumn && columns[sortColumn]) {
|
||||
let sortFunction;
|
||||
if (typeof columns[sortColumn]?.compare === 'function') {
|
||||
sortFunction = columns[sortColumn].compare;
|
||||
} else if (columns[sortColumn]?.type === Date) {
|
||||
sortFunction = (a, b) => compare(a, b, (a,b) => a.getTime() - b.getTime());
|
||||
} else if (columns[sortColumn]?.type === Number) {
|
||||
sortFunction = (a, b) => compare(a, b, (a,b) => a - b);
|
||||
} else {
|
||||
sortFunction = ((a, b) =>
|
||||
compare(a, b, (a,b) => a.toString().toLowerCase().localeCompare(b.toString().toLowerCase()))
|
||||
)
|
||||
}
|
||||
|
||||
sortedEntries.sort((a, b) => {
|
||||
let entryA = getValue(a, sortColumn);
|
||||
let entryB = getValue(b, sortColumn);
|
||||
return sortFunction(entryA, entryB);
|
||||
});
|
||||
|
||||
if (!sortAscending) {
|
||||
sortedEntries = sortedEntries.reverse();
|
||||
}
|
||||
}
|
||||
|
||||
Array.from(Array(sortedEntries.length).keys()).forEach(rowIndex => {
|
||||
if (typeof props.filter === 'function' && !props.filter(sortedEntries[rowIndex])) {
|
||||
hidden++;
|
||||
return;
|
||||
}
|
||||
|
||||
let rowData = [];
|
||||
for (const [key, column] of Object.entries(columns)) {
|
||||
let value = getValue(sortedEntries[rowIndex], key);
|
||||
if (typeof column.render === 'function') {
|
||||
value = column.render(sortedEntries[rowIndex], value);
|
||||
}
|
||||
|
||||
rowData.push(<TableCell key={"column-" + key} className={clsx(column.alignment === "center" && classes.columnCenter)}>
|
||||
{ value }
|
||||
</TableCell>);
|
||||
}
|
||||
|
||||
rows.push(
|
||||
<TableRow key={"entry-" + rowIndex}
|
||||
className={clsx(hasClickHandler && classes.clickable)}
|
||||
onClick={() => hasClickHandler && onClick(sortedEntries[rowIndex])}>
|
||||
{ rowData }
|
||||
</TableRow>
|
||||
);
|
||||
});
|
||||
|
||||
if (hidden > 0) {
|
||||
rows.push(<TableRow key={"row-hidden"}>
|
||||
<TableCell colSpan={numColumns} className={classes.hidden}>
|
||||
{ "(" + (hidden > 1
|
||||
? sprintf(L("%d rows hidden due to filter"), hidden)
|
||||
: L("1 rows hidden due to filter")) + ")"
|
||||
}
|
||||
</TableCell>
|
||||
</TableRow>);
|
||||
} else if (rows.length === 0 && placeholder) {
|
||||
rows.push(<TableRow key={"row-placeholder"}>
|
||||
<TableCell colSpan={numColumns} className={classes.hidden}>
|
||||
{ placeholder }
|
||||
</TableCell>
|
||||
</TableRow>);
|
||||
}
|
||||
} else if (columns && data === null) {
|
||||
rows.push(<TableRow key={"loading"}>
|
||||
<TableCell colSpan={numColumns} className={classes.columnCenter}>
|
||||
<Grid container alignItems={"center"} spacing={1} justifyContent={"center"}>
|
||||
<Grid item>{L("Loading")}…</Grid>
|
||||
<Grid item><CircularProgress size={15}/></Grid>
|
||||
</Grid>
|
||||
</TableCell>
|
||||
</TableRow>)
|
||||
}
|
||||
*/
|
||||
|
||||
return <Box position={"relative"}>
|
||||
<Table className={clsx("data-table", className)} size="small" {...other}>
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
{ headerRow }
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{ rows }
|
||||
</TableBody>
|
||||
</Table>
|
||||
{pagination.renderPagination(L, rows.length)}
|
||||
</Box>
|
||||
}
|
||||
|
||||
export class DataColumn {
|
||||
constructor(label, field = null, sortable = true) {
|
||||
this.label = label;
|
||||
this.field = field;
|
||||
this.sortable = sortable;
|
||||
}
|
||||
|
||||
compare(a, b) {
|
||||
throw new Error("Not implemented: compare");
|
||||
}
|
||||
|
||||
renderData(entry) {
|
||||
return entry[this.field]
|
||||
}
|
||||
|
||||
renderHead() {
|
||||
return this.label;
|
||||
}
|
||||
}
|
||||
|
||||
export class StringColumn extends DataColumn {
|
||||
constructor(label, field = null, sortable = true, caseSensitive = false) {
|
||||
super(label, field, sortable);
|
||||
this.caseSensitve = caseSensitive;
|
||||
}
|
||||
|
||||
compare(a, b) {
|
||||
if (this.caseSensitve) {
|
||||
return a.toString().localeCompare(b.toString());
|
||||
} else {
|
||||
return a.toString().toLowerCase().localeCompare(b.toString().toLowerCase());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export class NumericColumn extends DataColumn {
|
||||
constructor(label, field = null, sortable = true) {
|
||||
super(label, field, sortable);
|
||||
}
|
||||
|
||||
compare(a, b) {
|
||||
return a - b;
|
||||
}
|
||||
}
|
||||
|
||||
export class DateTimeColumn extends DataColumn {
|
||||
constructor(label, field = null, sortable = true, format = "YYYY-MM-dd HH:mm:ss") {
|
||||
super(label, field, sortable);
|
||||
this.format = format;
|
||||
}
|
||||
|
||||
compare(a, b) {
|
||||
if (typeof a === 'string') {
|
||||
a = parse(a, this.format, new Date()).getTime();
|
||||
}
|
||||
|
||||
if (typeof b === 'string') {
|
||||
b = parse(b, this.format, new Date()).getTime();
|
||||
}
|
||||
|
||||
return a - b;
|
||||
}
|
||||
}
|
||||
73
react/shared/hooks/pagination.js
Normal file
73
react/shared/hooks/pagination.js
Normal file
@@ -0,0 +1,73 @@
|
||||
import React, {useState} from "react";
|
||||
import {Box, MenuItem, Select, Pagination as MuiPagination} from "@mui/material";
|
||||
import {sprintf} from "sprintf-js";
|
||||
|
||||
class Pagination {
|
||||
|
||||
constructor(data, setData) {
|
||||
this.data = data;
|
||||
this.setData = setData;
|
||||
}
|
||||
|
||||
getPage() {
|
||||
return this.data.current;
|
||||
}
|
||||
|
||||
getPageSize() {
|
||||
return this.data.pageSize;
|
||||
}
|
||||
|
||||
setPage(page) {
|
||||
this.setData({...this.data, current: page});
|
||||
}
|
||||
|
||||
setPageSize(pageSize) {
|
||||
this.setData({...this.data, pageSize: pageSize});
|
||||
}
|
||||
|
||||
reset() {
|
||||
this.setData({current: 1, pageSize: 25, total: 0});
|
||||
}
|
||||
|
||||
getPageCount() {
|
||||
if (this.data.pageSize && this.data.total) {
|
||||
return Math.max(1, Math.ceil(this.data.total / this.data.pageSize));
|
||||
} else {
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
|
||||
getTotal() {
|
||||
return this.data.total;
|
||||
}
|
||||
|
||||
update(data) {
|
||||
this.setData(data);
|
||||
}
|
||||
|
||||
renderPagination(L, numEntries, options = null) {
|
||||
options = options || [10, 25, 50, 100];
|
||||
|
||||
return <Box>
|
||||
<Select
|
||||
value={this.data.pageSize}
|
||||
label={L("general.entries_per_page")}
|
||||
onChange={(e) => this.setPageSize(parseInt(e.target.value))}
|
||||
size={"small"}
|
||||
>
|
||||
{options.map(size => <MenuItem key={"size-" + size} value={size}>{size}</MenuItem>)}
|
||||
</Select>
|
||||
<MuiPagination count={this.getPageCount()} onChange={(_, page) => this.setPage(page)} />
|
||||
{sprintf(L("general.showing_x_of_y_entries"), numEntries, this.data.total)}
|
||||
</Box>
|
||||
}
|
||||
}
|
||||
|
||||
export default function usePagination() {
|
||||
|
||||
const [pagination, setPagination] = useState({
|
||||
current: 1, pageSize: 25, total: 0
|
||||
});
|
||||
|
||||
return new Pagination(pagination, setPagination);
|
||||
}
|
||||
@@ -1,11 +1,36 @@
|
||||
import React from 'react';
|
||||
import React, {useReducer} from 'react';
|
||||
import {createContext, useCallback, useState} from "react";
|
||||
|
||||
const LocaleContext = createContext(null);
|
||||
|
||||
function reducer(entries, action) {
|
||||
let _entries = entries;
|
||||
|
||||
switch (action.type) {
|
||||
case 'loadModule':
|
||||
if (!_entries.hasOwnProperty(action.code)) {
|
||||
_entries[action.code] = {};
|
||||
}
|
||||
if (_entries[action.code].hasOwnProperty(action.module)) {
|
||||
_entries[action.code][action.module] = {..._entries[action.code][action.module], ...action.newEntries};
|
||||
} else {
|
||||
_entries[action.code][action.module] = action.newEntries;
|
||||
}
|
||||
break;
|
||||
case 'loadModules':
|
||||
_entries = {...entries, [action.code]: { ...entries[action.code], ...action.modules }};
|
||||
break;
|
||||
default:
|
||||
throw new Error();
|
||||
}
|
||||
|
||||
return _entries;
|
||||
}
|
||||
|
||||
function LocaleProvider(props) {
|
||||
|
||||
const [entries, setEntries] = useState(window.languageEntries || {});
|
||||
// const [entries, setEntries] = useState(window.languageEntries || {});
|
||||
const [entries, dispatch] = useReducer(reducer, window.languageEntries || {});
|
||||
const [currentLocale, setCurrentLocale] = useState(window.languageCode || "en_US");
|
||||
|
||||
const translate = useCallback((key) => {
|
||||
@@ -24,23 +49,6 @@ function LocaleProvider(props) {
|
||||
return "[" + key + "]";
|
||||
}, [currentLocale, entries]);
|
||||
|
||||
const loadModule = useCallback((code, module, newEntries) => {
|
||||
let _entries = {...entries};
|
||||
if (!_entries.hasOwnProperty(code)) {
|
||||
_entries[code] = {};
|
||||
}
|
||||
if (_entries[code].hasOwnProperty(module)) {
|
||||
_entries[code][module] = {..._entries[code][module], ...newEntries};
|
||||
} else {
|
||||
_entries[code][module] = newEntries;
|
||||
}
|
||||
setEntries(_entries);
|
||||
}, [entries]);
|
||||
|
||||
const loadModules = useCallback((code, modules) => {
|
||||
setEntries({...entries, [code]: { ...entries[code], ...modules }});
|
||||
}, [entries]);
|
||||
|
||||
const hasModule = useCallback((code, module) => {
|
||||
return entries.hasOwnProperty(code) && !!entries[code][module];
|
||||
}, [entries]);
|
||||
@@ -104,7 +112,7 @@ function LocaleProvider(props) {
|
||||
if (useCache) {
|
||||
if (data && data.success) {
|
||||
// insert into cache
|
||||
loadModules(code, data.entries);
|
||||
dispatch({type: "loadModules", code: code, modules: data.entries});
|
||||
data.entries = {...data.entries, ...languageEntries};
|
||||
data.cached = false;
|
||||
}
|
||||
@@ -114,7 +122,7 @@ function LocaleProvider(props) {
|
||||
} else {
|
||||
return { success: true, msg: "", entries: languageEntries, code: code, cached: true };
|
||||
}
|
||||
}, [currentLocale, getModule, loadModules]);
|
||||
}, [currentLocale, getModule, dispatch]);
|
||||
|
||||
const ctx = {
|
||||
currentLocale: currentLocale,
|
||||
|
||||
Reference in New Issue
Block a user