snapshot of https://github.com/traggo/server.git (master, 2026-09-16; blobs > 131072B omitted)
Some checks failed
build / build (push) Failing after 3s
Some checks failed
build / build (push) Failing after 3s
This commit is contained in:
2
ui/.prettierignore
Normal file
2
ui/.prettierignore
Normal file
@@ -0,0 +1,2 @@
|
||||
build
|
||||
__generated__
|
||||
11
ui/.prettierrc
Normal file
11
ui/.prettierrc
Normal file
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"printWidth": 130,
|
||||
"tabWidth": 4,
|
||||
"useTabs": false,
|
||||
"semi": true,
|
||||
"singleQuote": true,
|
||||
"trailingComma": "es5",
|
||||
"bracketSpacing": false,
|
||||
"jsxBracketSameLine": true,
|
||||
"arrowParens": "always"
|
||||
}
|
||||
82
ui/package.json
Normal file
82
ui/package.json
Normal file
@@ -0,0 +1,82 @@
|
||||
{
|
||||
"name": "ui",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"homepage": ".",
|
||||
"dependencies": {
|
||||
"@apollo/react-hooks": "^3.1.3",
|
||||
"@date-io/moment": "^1.1.0",
|
||||
"@fullcalendar/core": "^4.3.1",
|
||||
"@fullcalendar/daygrid": "^4.3.0",
|
||||
"@fullcalendar/interaction": "^4.3.0",
|
||||
"@fullcalendar/moment": "^4.3.0",
|
||||
"@fullcalendar/react": "^4.3.0",
|
||||
"@fullcalendar/timegrid": "^4.3.0",
|
||||
"@material-ui/core": "^4.6.1",
|
||||
"@material-ui/icons": "^4.5.1",
|
||||
"@material-ui/pickers": "^3.2.8",
|
||||
"@rooks/use-interval": "^1.2.0",
|
||||
"@rooks/use-timeout": "^3.0.1",
|
||||
"apollo-boost": "^0.4.4",
|
||||
"color-hash": "^1.0.3",
|
||||
"downshift": "^3.2.10",
|
||||
"get-best-contrast-color": "^0.2.2",
|
||||
"graphql": "^14.5.8",
|
||||
"lodash.clonedeep": "^4.5.0",
|
||||
"moment": "^2.30.1",
|
||||
"moment-timezone": "^0.5.45",
|
||||
"notistack": "^0.6.1",
|
||||
"pretty-ms": "^5.0.0",
|
||||
"react": "^16.12.0",
|
||||
"react-apollo": "^3.1.3",
|
||||
"react-color": "3.0.0-beta.3",
|
||||
"react-dom": "^16.12.0",
|
||||
"react-grid-layout": "^0.16.6",
|
||||
"react-infinite": "^0.13.0",
|
||||
"react-router": "^5.1.2",
|
||||
"react-router-dom": "^5.1.2",
|
||||
"recharts": "^1.8.5",
|
||||
"typeface-roboto": "^0.0.54",
|
||||
"typescript": "^3.7.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/graphql": "^14.0.7",
|
||||
"@types/jest": "^24.0.15",
|
||||
"@types/lodash.clonedeep": "^4.5.6",
|
||||
"@types/moment-timezone": "^0.5.12",
|
||||
"@types/node": "11.11.3",
|
||||
"@types/pretty-ms": "^4.0.0",
|
||||
"@types/react": "16.8.8",
|
||||
"@types/react-color": "^2.17.0",
|
||||
"@types/react-dom": "^16.9.4",
|
||||
"@types/react-grid-layout": "^0.16.7",
|
||||
"@types/react-infinite": "^0.0.34",
|
||||
"@types/react-router": "^5.0.1",
|
||||
"@types/react-router-dom": "^5.1.2",
|
||||
"@types/recharts": "^1.8.3",
|
||||
"apollo": "^2.21.0",
|
||||
"prettier": "^1.19.1",
|
||||
"react-scripts": "^3.2.0",
|
||||
"tslint": "^5.20.1",
|
||||
"tslint-sonarts": "^1.9.0"
|
||||
},
|
||||
"scripts": {
|
||||
"lint": "tslint --fix --project .",
|
||||
"lint:check": "tslint --project .",
|
||||
"generate": "apollo client:codegen --target typescript --localSchemaFile ../schema.graphql --excludes **/*local.ts --globalTypesFile src/gql/__generated__/globalTypes.ts",
|
||||
"format": "prettier \"**/*.{html,ts,tsx,css}\" --write",
|
||||
"format:check": "prettier \"**/*.{html,ts,tsx,css}\" --list-different",
|
||||
"start": "react-scripts start",
|
||||
"build": "react-scripts build",
|
||||
"postbuild": "rimraf build/**/*.map",
|
||||
"test": "react-scripts test",
|
||||
"eject": "react-scripts eject"
|
||||
},
|
||||
"browserslist": [
|
||||
">0.2%",
|
||||
"not dead",
|
||||
"not ie <= 11",
|
||||
"not op_mini all"
|
||||
],
|
||||
"proxy": "http://localhost:3030"
|
||||
}
|
||||
BIN
ui/public/apple-touch-icon.png
Normal file
BIN
ui/public/apple-touch-icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 25 KiB |
BIN
ui/public/favicon-16x16.png
Normal file
BIN
ui/public/favicon-16x16.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.4 KiB |
BIN
ui/public/favicon-192x192.png
Normal file
BIN
ui/public/favicon-192x192.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 27 KiB |
BIN
ui/public/favicon-256x256.png
Normal file
BIN
ui/public/favicon-256x256.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 37 KiB |
BIN
ui/public/favicon-32x32.png
Normal file
BIN
ui/public/favicon-32x32.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 3.0 KiB |
BIN
ui/public/favicon.ico
Normal file
BIN
ui/public/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 15 KiB |
21
ui/public/index.html
Normal file
21
ui/public/index.html
Normal file
@@ -0,0 +1,21 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<link rel="shortcut icon" href="%PUBLIC_URL%/favicon.ico" />
|
||||
<link rel="apple-touch-icon" sizes="180x180" href="%PUBLIC_URL%/apple-touch-icon.png" />
|
||||
<link rel="icon" type="image/png" sizes="32x32" href="%PUBLIC_URL%/favicon-32x32.png" />
|
||||
<link rel="icon" type="image/png" sizes="16x16" href="%PUBLIC_URL%/favicon-16x16.png" />
|
||||
<link rel="icon" type="image/png" sizes="192x192" href="%PUBLIC_URL%/favicon-192x192.png" />
|
||||
<link rel="icon" type="image/png" sizes="256x256" href="%PUBLIC_URL%/favicon-256x256.png" />
|
||||
<link rel="manifest" href="%PUBLIC_URL%/manifest.json" />
|
||||
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no" />
|
||||
<meta name="theme-color" content="#000000" />
|
||||
<title>Traggo</title>
|
||||
</head>
|
||||
<body>
|
||||
<noscript>You need to enable JavaScript to run this app.</noscript>
|
||||
<div id="root"></div>
|
||||
</body>
|
||||
</html>
|
||||
29
ui/public/manifest.json
Normal file
29
ui/public/manifest.json
Normal file
@@ -0,0 +1,29 @@
|
||||
{
|
||||
"name": "Traggo",
|
||||
"short_name": "Traggo",
|
||||
"description": "Tag-based time tracking",
|
||||
"theme_color": "#9bd7fe",
|
||||
"background_color": "#3f51b5",
|
||||
"display": "standalone",
|
||||
"scope": "./",
|
||||
"start_url": "./",
|
||||
"icons": [
|
||||
{
|
||||
"src": "/favicon-16x16.png",
|
||||
"sizes": "16x16",
|
||||
"type": "image/png"
|
||||
}, {
|
||||
"src": "/favicon-32x32.png",
|
||||
"sizes": "32x32",
|
||||
"type": "image/png"
|
||||
}, {
|
||||
"src": "/favicon-192x192.png",
|
||||
"sizes": "192x192",
|
||||
"type": "image/png"
|
||||
}, {
|
||||
"src": "/favicon-256x256.png",
|
||||
"sizes": "256x256",
|
||||
"type": "image/png"
|
||||
}
|
||||
]
|
||||
}
|
||||
54
ui/serve.go
Normal file
54
ui/serve.go
Normal file
@@ -0,0 +1,54 @@
|
||||
package ui
|
||||
|
||||
import (
|
||||
"embed"
|
||||
"fmt"
|
||||
"io"
|
||||
"io/fs"
|
||||
"net/http"
|
||||
|
||||
"github.com/gorilla/mux"
|
||||
"github.com/rs/zerolog/log"
|
||||
)
|
||||
|
||||
//go:embed build
|
||||
var uiDir embed.FS
|
||||
var buildDir, _ = fs.Sub(uiDir, "build")
|
||||
|
||||
// Register registers the ui on the root path.
|
||||
func Register(r *mux.Router) {
|
||||
r.Handle("/", serveFile("index.html", "text/html"))
|
||||
r.Handle("/index.html", serveFile("index.html", "text/html"))
|
||||
r.Handle("/manifest.json", serveFile("manifest.json", "application/json"))
|
||||
r.Handle("/service-worker.js", serveFile("service-worker.js", "text/javascript"))
|
||||
r.Handle("/asset-manifest.json", serveFile("asset-manifest.json", "application/json"))
|
||||
|
||||
fileServer := http.FileServer(http.FS(buildDir))
|
||||
r.Handle("/static/{type}/{resource}", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Cache-Control", "public, max-age=31536000, immutable")
|
||||
fileServer.ServeHTTP(w, r)
|
||||
}))
|
||||
|
||||
r.Handle("/favicon.ico", serveFile("favicon.ico", "image/x-icon"))
|
||||
for _, size := range []string{"16x16", "32x32", "192x192", "256x256"} {
|
||||
fileName := fmt.Sprintf("favicon-%s.png", size)
|
||||
r.Handle("/"+fileName, serveFile(fileName, "image/png"))
|
||||
}
|
||||
}
|
||||
|
||||
func serveFile(name, contentType string) http.HandlerFunc {
|
||||
file, err := buildDir.Open(name)
|
||||
if err != nil {
|
||||
log.Panic().Err(err).Msgf("could not find %s", file)
|
||||
}
|
||||
defer file.Close()
|
||||
content, err := io.ReadAll(file)
|
||||
if err != nil {
|
||||
log.Panic().Err(err).Msgf("could not read %s", file)
|
||||
}
|
||||
|
||||
return func(writer http.ResponseWriter, reg *http.Request) {
|
||||
writer.Header().Set("Content-Type", contentType)
|
||||
_, _ = writer.Write(content)
|
||||
}
|
||||
}
|
||||
31
ui/src/Root.tsx
Normal file
31
ui/src/Root.tsx
Normal file
@@ -0,0 +1,31 @@
|
||||
import * as React from 'react';
|
||||
import './global.css';
|
||||
import 'react-resizable/css/styles.css';
|
||||
import 'react-grid-layout/css/styles.css';
|
||||
import 'typeface-roboto';
|
||||
import {ThemeProvider} from './provider/ThemeProvider';
|
||||
import {ApolloProvider} from './provider/ApolloProvider';
|
||||
import {SnackbarProvider} from './provider/SnackbarProvider';
|
||||
import {MuiPickersUtilsProvider} from '@material-ui/pickers';
|
||||
import MomentUtils from '@date-io/moment';
|
||||
import {Router} from './Router';
|
||||
import {HashRouter} from 'react-router-dom';
|
||||
import {BootUserSettings} from './provider/UserSettingsProvider';
|
||||
|
||||
export const Root = () => {
|
||||
return (
|
||||
<ApolloProvider>
|
||||
<BootUserSettings>
|
||||
<ThemeProvider>
|
||||
<MuiPickersUtilsProvider utils={MomentUtils}>
|
||||
<SnackbarProvider>
|
||||
<HashRouter>
|
||||
<Router />
|
||||
</HashRouter>
|
||||
</SnackbarProvider>
|
||||
</MuiPickersUtilsProvider>
|
||||
</ThemeProvider>
|
||||
</BootUserSettings>
|
||||
</ApolloProvider>
|
||||
);
|
||||
};
|
||||
81
ui/src/Router.tsx
Normal file
81
ui/src/Router.tsx
Normal file
@@ -0,0 +1,81 @@
|
||||
import * as React from 'react';
|
||||
import {useQuery} from '@apollo/react-hooks';
|
||||
import {CurrentUser} from './gql/__generated__/CurrentUser';
|
||||
import * as gqlUser from './gql/user';
|
||||
import {CenteredSpinner} from './common/CenteredSpinner';
|
||||
import {LoginPage} from './login/LoginPage';
|
||||
import {Typography} from '@material-ui/core';
|
||||
import Button from '@material-ui/core/Button';
|
||||
import {ApolloError} from 'apollo-boost';
|
||||
import Grid from '@material-ui/core/Grid';
|
||||
import {DefaultPaper} from './common/DefaultPaper';
|
||||
import {Page} from './common/Page';
|
||||
import {Redirect, Route, Switch} from 'react-router';
|
||||
import {DailyPage} from './timespan/DailyPage';
|
||||
import {DevicesPage} from './devices/DevicesPage';
|
||||
import {UsersPage} from './user/UsersPage';
|
||||
import {DashboardPage} from './dashboard/DashboardPage';
|
||||
import {DashboardsPage} from './dashboard/DashboardsPage';
|
||||
import {TagPage} from './tag/TagPage';
|
||||
import {SettingsPage} from './setting/SettingsPage';
|
||||
import {CalendarPage} from './timespan/calendar/CalendarPage';
|
||||
|
||||
export const Router = () => {
|
||||
const {loading, error, data, refetch} = useQuery<CurrentUser>(gqlUser.CurrentUser);
|
||||
if (loading) {
|
||||
return <CenteredSpinner />;
|
||||
}
|
||||
if (error) {
|
||||
return <Error refetch={refetch} error={error} />;
|
||||
}
|
||||
const loggedIn = data && data.user;
|
||||
const admin = data && data.user && data.user.admin;
|
||||
|
||||
return (
|
||||
<Switch>
|
||||
<Route path="/user/login">{loggedIn ? <Redirect to="/" /> : <LoginPage />}</Route>
|
||||
{loggedIn ? null : <Redirect to="/user/login" />}
|
||||
|
||||
<Page>
|
||||
<Route path="/dashboards" component={DashboardsPage} />
|
||||
<Route path="/dashboard/:id" component={DashboardPage} />
|
||||
<Route exact path="/timesheet/list" component={DailyPage} />
|
||||
<Route exact path="/timesheet/calendar" component={CalendarPage} />
|
||||
<Route exact path="/user/settings" component={SettingsPage} />
|
||||
<Route exact path="/user/devices" component={DevicesPage} />
|
||||
<Route exact path="/user/tags" component={TagPage} />
|
||||
{admin ? <Route exact path="/admin/users" component={UsersPage} /> : null}
|
||||
<Route exact path="/" render={() => <Redirect to="/timesheet/list" />} />
|
||||
</Page>
|
||||
</Switch>
|
||||
);
|
||||
};
|
||||
|
||||
const Error: React.FC<{error: ApolloError; refetch: () => void}> = ({error, refetch}) => {
|
||||
return (
|
||||
<Grid container direction="row" alignItems="center" justify="center" style={{height: '100%'}}>
|
||||
<Grid item>
|
||||
<DefaultPaper>
|
||||
<Typography variant="h3" component="h1" gutterBottom={true}>
|
||||
Error
|
||||
</Typography>
|
||||
<Typography component="p">
|
||||
{error.networkError && error.networkError.name + ': ' + error.networkError.message}
|
||||
{error.graphQLErrors.map((gqlError) => gqlError.message).join(', ')}
|
||||
</Typography>
|
||||
<Button style={{marginTop: 10}} size="large" variant="outlined" onClick={() => refetch()}>
|
||||
Retry
|
||||
</Button>
|
||||
</DefaultPaper>
|
||||
</Grid>
|
||||
</Grid>
|
||||
);
|
||||
};
|
||||
|
||||
export const TODO = () => {
|
||||
return (
|
||||
<Typography align="center" variant={'h2'}>
|
||||
TODO
|
||||
</Typography>
|
||||
);
|
||||
};
|
||||
5
ui/src/common/Center.tsx
Normal file
5
ui/src/common/Center.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
import * as React from 'react';
|
||||
|
||||
export const Center: React.FC = ({children}) => {
|
||||
return <div style={{display: 'flex', alignItems: 'center', justifyContent: 'center', height: '100%'}}>{children}</div>;
|
||||
};
|
||||
11
ui/src/common/CenteredSpinner.tsx
Normal file
11
ui/src/common/CenteredSpinner.tsx
Normal file
@@ -0,0 +1,11 @@
|
||||
import CircularProgress from '@material-ui/core/CircularProgress';
|
||||
import Grid from '@material-ui/core/Grid';
|
||||
import * as React from 'react';
|
||||
|
||||
export const CenteredSpinner = () => (
|
||||
<Grid container={true} direction="row" alignItems="center" justify="center" style={{height: '95%'}}>
|
||||
<Grid item>
|
||||
<CircularProgress size={100} />
|
||||
</Grid>
|
||||
</Grid>
|
||||
);
|
||||
36
ui/src/common/ConfirmDialog.tsx
Normal file
36
ui/src/common/ConfirmDialog.tsx
Normal file
@@ -0,0 +1,36 @@
|
||||
import Button from '@material-ui/core/Button';
|
||||
import Dialog from '@material-ui/core/Dialog';
|
||||
import DialogActions from '@material-ui/core/DialogActions';
|
||||
import DialogContent from '@material-ui/core/DialogContent';
|
||||
import DialogContentText from '@material-ui/core/DialogContentText';
|
||||
import DialogTitle from '@material-ui/core/DialogTitle';
|
||||
import React from 'react';
|
||||
|
||||
interface Props {
|
||||
title: string;
|
||||
fClose: () => void;
|
||||
fOnSubmit: () => void;
|
||||
}
|
||||
|
||||
export const ConfirmDialog: React.FC<Props> = ({children, title, fClose, fOnSubmit}) => {
|
||||
const submitAndClose = () => {
|
||||
fOnSubmit();
|
||||
fClose();
|
||||
};
|
||||
return (
|
||||
<Dialog open={true} onClose={fClose} aria-labelledby="form-dialog-title" className="confirm-dialog">
|
||||
<DialogTitle id="form-dialog-title">{title}</DialogTitle>
|
||||
<DialogContent>
|
||||
<DialogContentText>{children}</DialogContentText>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={fClose} className="cancel">
|
||||
No
|
||||
</Button>
|
||||
<Button onClick={submitAndClose} autoFocus color="primary" variant="contained" className="confirm">
|
||||
Yes
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
86
ui/src/common/DateTimeSelector.tsx
Normal file
86
ui/src/common/DateTimeSelector.tsx
Normal file
@@ -0,0 +1,86 @@
|
||||
import * as React from 'react';
|
||||
import {KeyboardDateTimePicker} from '@material-ui/pickers';
|
||||
import * as moment from 'moment';
|
||||
import {uglyConvertToLocalTime} from '../timespan/timeutils';
|
||||
import {useSettings} from '../gql/settings';
|
||||
import {DateTimeInputStyle} from '../gql/__generated__/globalTypes';
|
||||
|
||||
interface DateTimeSelectorProps {
|
||||
selectedDate: moment.Moment;
|
||||
onSelectDate: (date: moment.Moment) => void;
|
||||
showDate: boolean;
|
||||
label: string;
|
||||
popoverOpen?: (open: boolean) => void;
|
||||
}
|
||||
|
||||
export const DateTimeSelector: React.FC<DateTimeSelectorProps> = React.memo(
|
||||
({selectedDate, onSelectDate, showDate, label, popoverOpen = () => {}}) => {
|
||||
const {done, dateTimeInputStyle} = useSettings();
|
||||
|
||||
if (!done) {
|
||||
return <span>...</span>;
|
||||
}
|
||||
|
||||
if (dateTimeInputStyle === DateTimeInputStyle.Native) {
|
||||
return (
|
||||
<input
|
||||
type="datetime-local"
|
||||
value={selectedDate.format(selectedDate.format('YYYY-MM-DDTHH:mm'))}
|
||||
onChange={(e) => {
|
||||
onSelectDate(moment.default(e.target.value));
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const [open, setOpen] = React.useState(false);
|
||||
const localeData = moment.localeData();
|
||||
const time = localeData.longDateFormat('LT').replace('A', 'a');
|
||||
const ampm = time.indexOf('a') !== -1;
|
||||
const format = showDate ? localeData.longDateFormat('L') + ' ' + time : time;
|
||||
|
||||
return (
|
||||
<KeyboardDateTimePicker
|
||||
className="time-picker"
|
||||
variant="inline"
|
||||
InputProps={{disableUnderline: true}}
|
||||
title={selectedDate.format()}
|
||||
style={{width: (showDate ? 185 : 105) + (ampm ? 20 : 0)}}
|
||||
PopoverProps={{
|
||||
onEntered: () => {
|
||||
popoverOpen(true);
|
||||
setOpen(true);
|
||||
},
|
||||
onExited: () => {
|
||||
popoverOpen(false);
|
||||
setOpen(false);
|
||||
},
|
||||
}}
|
||||
margin="none"
|
||||
value={uglyConvertToLocalTime(selectedDate).format()}
|
||||
onChange={(date: moment.Moment) => {
|
||||
if (!date || !date.isValid()) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!showDate && !open) {
|
||||
date = date.set({
|
||||
date: selectedDate.date(),
|
||||
month: selectedDate.month(),
|
||||
year: selectedDate.year(),
|
||||
});
|
||||
}
|
||||
if (uglyConvertToLocalTime(selectedDate).isSame(date)) {
|
||||
return;
|
||||
}
|
||||
|
||||
onSelectDate(date);
|
||||
}}
|
||||
ampm={ampm}
|
||||
format={format}
|
||||
label={label}
|
||||
openTo={showDate ? 'date' : 'hours'}
|
||||
/>
|
||||
);
|
||||
}
|
||||
);
|
||||
22
ui/src/common/DefaultPaper.tsx
Normal file
22
ui/src/common/DefaultPaper.tsx
Normal file
@@ -0,0 +1,22 @@
|
||||
import Paper from '@material-ui/core/Paper';
|
||||
import {makeStyles} from '@material-ui/core/styles';
|
||||
import * as React from 'react';
|
||||
|
||||
const useStyles = makeStyles((theme) => ({
|
||||
root: {
|
||||
...theme.mixins.gutters(),
|
||||
paddingTop: theme.spacing(4),
|
||||
paddingBottom: theme.spacing(3),
|
||||
textAlign: 'center',
|
||||
maxWidth: 400,
|
||||
borderTop: `5px solid ${theme.palette.primary.main}`,
|
||||
},
|
||||
}));
|
||||
export const DefaultPaper: React.FC = ({children}) => {
|
||||
const classes = useStyles();
|
||||
return (
|
||||
<Paper elevation={10} square={true} className={classes.root}>
|
||||
{children}
|
||||
</Paper>
|
||||
);
|
||||
};
|
||||
31
ui/src/common/Fade.tsx
Normal file
31
ui/src/common/Fade.tsx
Normal file
@@ -0,0 +1,31 @@
|
||||
import * as React from 'react';
|
||||
import {CSSTransition} from 'react-transition-group';
|
||||
|
||||
const duration = 100;
|
||||
|
||||
const defaultStyle = {
|
||||
transition: `all ${duration}ms ease-in-out`,
|
||||
opacity: 0,
|
||||
};
|
||||
|
||||
export const Fade: React.FC<{fullyVisible: boolean; opacity?: number}> = ({fullyVisible, children, opacity = 0.4}) => {
|
||||
const transitionStyles: Record<string, React.CSSProperties> = {
|
||||
entering: {opacity: 1},
|
||||
entered: {opacity: 1},
|
||||
exiting: {opacity},
|
||||
exited: {opacity},
|
||||
};
|
||||
return (
|
||||
<CSSTransition in={fullyVisible} timeout={duration}>
|
||||
{(state) => (
|
||||
<div
|
||||
style={{
|
||||
...defaultStyle,
|
||||
...transitionStyles[state],
|
||||
}}>
|
||||
{children}
|
||||
</div>
|
||||
)}
|
||||
</CSSTransition>
|
||||
);
|
||||
};
|
||||
308
ui/src/common/Page.tsx
Normal file
308
ui/src/common/Page.tsx
Normal file
@@ -0,0 +1,308 @@
|
||||
import * as React from 'react';
|
||||
import AppBar from '@material-ui/core/AppBar';
|
||||
import CssBaseline from '@material-ui/core/CssBaseline';
|
||||
import Divider from '@material-ui/core/Divider';
|
||||
import Drawer from '@material-ui/core/Drawer';
|
||||
import Hidden from '@material-ui/core/Hidden';
|
||||
import IconButton from '@material-ui/core/IconButton';
|
||||
import UsersIcon from '@material-ui/icons/SupervisorAccount';
|
||||
import List from '@material-ui/core/List';
|
||||
import ListItem from '@material-ui/core/ListItem';
|
||||
import ListItemIcon from '@material-ui/core/ListItemIcon';
|
||||
import ListItemText from '@material-ui/core/ListItemText';
|
||||
import DashboardIcon from '@material-ui/icons/Dashboard';
|
||||
import MenuIcon from '@material-ui/icons/Menu';
|
||||
import SettingsIcon from '@material-ui/icons/Settings';
|
||||
import DevicesIcon from '@material-ui/icons/DevicesOther';
|
||||
import LabelIcon from '@material-ui/icons/Label';
|
||||
import DashboardManageIcon from '@material-ui/icons/ListAlt';
|
||||
import TimeLineIcon from '@material-ui/icons/Timeline';
|
||||
import CalendarIcon from '@material-ui/icons/CalendarToday';
|
||||
import Toolbar from '@material-ui/core/Toolbar';
|
||||
import Typography from '@material-ui/core/Typography';
|
||||
import {ListSubheader, Menu} from '@material-ui/core';
|
||||
import HrefLink from '@material-ui/core/Link';
|
||||
import * as gqlUser from '../gql/user';
|
||||
import * as gqlVersion from '../gql/version';
|
||||
import Button from '@material-ui/core/Button';
|
||||
import AccountCircle from '@material-ui/icons/AccountCircle';
|
||||
import {Link} from 'react-router-dom';
|
||||
import MenuItem from '@material-ui/core/MenuItem';
|
||||
import {useMutation, useQuery} from '@apollo/react-hooks';
|
||||
import {Logout} from '../gql/__generated__/Logout';
|
||||
import {Version} from '../gql/__generated__/Version';
|
||||
import {CurrentUser} from '../gql/__generated__/CurrentUser';
|
||||
import * as gqlDashboard from '../gql/dashboard';
|
||||
import {Dashboards} from '../gql/__generated__/Dashboards';
|
||||
import makeStyles from '@material-ui/core/styles/makeStyles';
|
||||
import {Route, RouteChildrenProps, Switch} from 'react-router';
|
||||
|
||||
const drawerWidth = 240;
|
||||
|
||||
const useStyles = makeStyles((theme) => ({
|
||||
root: {
|
||||
display: 'flex',
|
||||
height: '100%',
|
||||
},
|
||||
drawer: {
|
||||
[theme.breakpoints.up('md')]: {
|
||||
width: drawerWidth,
|
||||
flexShrink: 0,
|
||||
},
|
||||
},
|
||||
appBar: {
|
||||
marginLeft: drawerWidth,
|
||||
[theme.breakpoints.up('md')]: {
|
||||
width: `calc(100% - ${drawerWidth}px)`,
|
||||
},
|
||||
},
|
||||
menuButton: {
|
||||
marginRight: 20,
|
||||
[theme.breakpoints.up('md')]: {
|
||||
display: 'none',
|
||||
},
|
||||
},
|
||||
toolbar: {
|
||||
...theme.mixins.toolbar,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
drawerPaper: {
|
||||
width: drawerWidth,
|
||||
},
|
||||
content: {
|
||||
flexGrow: 1,
|
||||
paddingTop: theme.spacing(2),
|
||||
paddingBottom: theme.spacing(2),
|
||||
paddingLeft: theme.spacing(1),
|
||||
paddingRight: theme.spacing(1),
|
||||
},
|
||||
grow: {
|
||||
flexGrow: 1,
|
||||
},
|
||||
sectionDesktop: {
|
||||
display: 'none',
|
||||
[theme.breakpoints.up('xs')]: {
|
||||
display: 'flex',
|
||||
},
|
||||
},
|
||||
sectionMobile: {
|
||||
display: 'flex',
|
||||
[theme.breakpoints.up('xs')]: {
|
||||
display: 'none',
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
// tslint:disable-next-line:no-any
|
||||
const routerLink = (to: string): any => {
|
||||
return React.forwardRef<HTMLAnchorElement>((props, ref) => <Link innerRef={ref} to={to} {...props} />);
|
||||
};
|
||||
|
||||
export const Page: React.FC = ({children}) => {
|
||||
const classes = useStyles();
|
||||
const {data} = useQuery<CurrentUser>(gqlUser.CurrentUser);
|
||||
|
||||
const [mobileOpen, setMobileOpen] = React.useState(false);
|
||||
const [userMenuOpen, setUserMenuOpen] = React.useState<null | HTMLElement>(null);
|
||||
const [logout] = useMutation<Logout>(gqlUser.Logout, {refetchQueries: [{query: gqlUser.CurrentUser}]});
|
||||
const {data: {version = gqlVersion.VersionDefault.version} = gqlVersion.VersionDefault} = useQuery<Version>(
|
||||
gqlVersion.Version
|
||||
);
|
||||
const dashboardsQuery = useQuery<Dashboards>(gqlDashboard.Dashboards);
|
||||
const dashboards = (dashboardsQuery.data && dashboardsQuery.data.dashboards) || [];
|
||||
|
||||
const username = (data && data.user && data.user.name) || 'unknown';
|
||||
const admin = data && data.user && data.user.admin;
|
||||
|
||||
const drawer = (
|
||||
<div>
|
||||
<div className={classes.toolbar}>
|
||||
<HrefLink href="https://github.com/traggo" underline="none" target="_blank">
|
||||
<Typography variant="h5" align="center" color="textPrimary">
|
||||
traggo
|
||||
</Typography>
|
||||
</HrefLink>
|
||||
<HrefLink href="https://github.com/traggo/server/releases" underline="none" target="_blank">
|
||||
<Typography variant="subtitle2" align="center" color="textPrimary">
|
||||
{version.name}@{version.commit.slice(0, 8)}
|
||||
</Typography>
|
||||
</HrefLink>
|
||||
</div>
|
||||
<Divider />
|
||||
<List subheader={<ListSubheader>Dashboards</ListSubheader>} dense={true}>
|
||||
{dashboards.map(({id, name}) => (
|
||||
<ListItem key={id} button component={routerLink(`/dashboard/${id}/${encodeURIComponent(name)}`)}>
|
||||
<ListItemIcon>
|
||||
<DashboardIcon />
|
||||
</ListItemIcon>
|
||||
<ListItemText primary={name} />
|
||||
</ListItem>
|
||||
))}
|
||||
{dashboards.length === 0 ? (
|
||||
<ListItem button dense={true} disabled={true}>
|
||||
<ListItemText primary={'no dashboards added'} />
|
||||
</ListItem>
|
||||
) : null}
|
||||
<ListItem button component={routerLink(`/dashboards`)}>
|
||||
<ListItemIcon>
|
||||
<DashboardManageIcon />
|
||||
</ListItemIcon>
|
||||
<ListItemText primary={'Manage'} />
|
||||
</ListItem>
|
||||
</List>
|
||||
<Divider />
|
||||
<List subheader={<ListSubheader>Timesheet</ListSubheader>} dense={true}>
|
||||
<ListItem button component={routerLink('/timesheet/list')}>
|
||||
<ListItemIcon>
|
||||
<TimeLineIcon />
|
||||
</ListItemIcon>
|
||||
<ListItemText primary="List" />
|
||||
</ListItem>
|
||||
<ListItem button component={routerLink('/timesheet/calendar')}>
|
||||
<ListItemIcon>
|
||||
<CalendarIcon />
|
||||
</ListItemIcon>
|
||||
<ListItemText primary="Calendar" />
|
||||
</ListItem>
|
||||
</List>
|
||||
<Divider />
|
||||
<List subheader={<ListSubheader>User</ListSubheader>} dense={true}>
|
||||
<ListItem button component={routerLink('/user/tags')}>
|
||||
<ListItemIcon>
|
||||
<LabelIcon />
|
||||
</ListItemIcon>
|
||||
<ListItemText primary="Tags" />
|
||||
</ListItem>
|
||||
<ListItem button component={routerLink('/user/devices')}>
|
||||
<ListItemIcon>
|
||||
<DevicesIcon />
|
||||
</ListItemIcon>
|
||||
<ListItemText primary="Devices" />
|
||||
</ListItem>
|
||||
<ListItem button component={routerLink('/user/settings')}>
|
||||
<ListItemIcon>
|
||||
<SettingsIcon />
|
||||
</ListItemIcon>
|
||||
<ListItemText primary="Settings" />
|
||||
</ListItem>
|
||||
</List>
|
||||
{admin ? (
|
||||
<>
|
||||
<Divider />
|
||||
<List subheader={<ListSubheader>Admin</ListSubheader>} dense={true}>
|
||||
<ListItem button component={routerLink('/admin/users')}>
|
||||
<ListItemIcon>
|
||||
<UsersIcon />
|
||||
</ListItemIcon>
|
||||
<ListItemText primary="Users" />
|
||||
</ListItem>
|
||||
</List>
|
||||
</>
|
||||
) : null}
|
||||
<Divider />
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className={classes.root}>
|
||||
<CssBaseline />
|
||||
<AppBar position="fixed" className={classes.appBar}>
|
||||
<Toolbar>
|
||||
<IconButton
|
||||
color="inherit"
|
||||
aria-label="Open drawer"
|
||||
onClick={() => setMobileOpen(!mobileOpen)}
|
||||
className={classes.menuButton}>
|
||||
<MenuIcon />
|
||||
</IconButton>
|
||||
<Typography variant="h6" color="inherit" noWrap>
|
||||
<Switch>
|
||||
<Route exact path="/timesheet/list">
|
||||
Timesheet / List
|
||||
</Route>
|
||||
<Route exact path="/timesheet/calendar">
|
||||
Timesheet / Calendar
|
||||
</Route>
|
||||
<Route exact path="/user/settings">
|
||||
User / Settings
|
||||
</Route>
|
||||
<Route exact path="/user/devices">
|
||||
User / Devices
|
||||
</Route>
|
||||
<Route exact path="/user/tags">
|
||||
User / Tags
|
||||
</Route>
|
||||
<Route exact path="/admin/users">
|
||||
Admin / Users
|
||||
</Route>
|
||||
<Route exact path="/dashboards">
|
||||
Dashboards / Manage
|
||||
</Route>
|
||||
<Route exact path="/dashboard/:id/:name">
|
||||
{(props: RouteChildrenProps<{id: string}>) => {
|
||||
const db = dashboards.find(
|
||||
(dashboard) => dashboard.id === parseInt(props.match!.params.id, 10)
|
||||
);
|
||||
return 'Dashboards / ' + (db ? db.name : '...');
|
||||
}}
|
||||
</Route>
|
||||
</Switch>
|
||||
</Typography>
|
||||
<div className={classes.grow} />
|
||||
<div className={classes.sectionDesktop}>
|
||||
<Button color="inherit" onClick={(e) => setUserMenuOpen(e.currentTarget)}>
|
||||
<AccountCircle />
|
||||
{username}
|
||||
</Button>
|
||||
<Menu
|
||||
anchorEl={userMenuOpen}
|
||||
anchorOrigin={{vertical: 'top', horizontal: 'right'}}
|
||||
transformOrigin={{vertical: 'top', horizontal: 'right'}}
|
||||
open={!!userMenuOpen}
|
||||
onClose={() => setUserMenuOpen(null)}>
|
||||
<MenuItem onClick={() => setUserMenuOpen(null)} component={routerLink('/user/settings')}>
|
||||
Settings
|
||||
</MenuItem>
|
||||
<MenuItem
|
||||
onClick={() => {
|
||||
setUserMenuOpen(null);
|
||||
logout();
|
||||
}}>
|
||||
Logout
|
||||
</MenuItem>
|
||||
</Menu>
|
||||
</div>
|
||||
</Toolbar>
|
||||
</AppBar>
|
||||
<nav className={classes.drawer}>
|
||||
<Hidden mdUp implementation="js">
|
||||
<Drawer
|
||||
variant="temporary"
|
||||
open={mobileOpen}
|
||||
onClose={() => setMobileOpen(false)}
|
||||
classes={{
|
||||
paper: classes.drawerPaper,
|
||||
}}>
|
||||
{drawer}
|
||||
</Drawer>
|
||||
</Hidden>
|
||||
<Hidden smDown implementation="js">
|
||||
<Drawer
|
||||
classes={{
|
||||
paper: classes.drawerPaper,
|
||||
}}
|
||||
variant="permanent"
|
||||
open>
|
||||
{drawer}
|
||||
</Drawer>
|
||||
</Hidden>
|
||||
</nav>
|
||||
<main className={classes.content}>
|
||||
<div className={classes.toolbar} />
|
||||
{children}
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
68
ui/src/common/RelativeDateTimeSelector.tsx
Normal file
68
ui/src/common/RelativeDateTimeSelector.tsx
Normal file
@@ -0,0 +1,68 @@
|
||||
import * as React from 'react';
|
||||
import {TextField} from '@material-ui/core';
|
||||
import {parseRelativeTime} from '../utils/time';
|
||||
import Typography from '@material-ui/core/Typography';
|
||||
import useTimeout from '@rooks/use-timeout';
|
||||
|
||||
interface RelativeDateTimeSelectorProps {
|
||||
value: string;
|
||||
onChange: (value: string, valid: boolean) => void;
|
||||
type: 'startOf' | 'endOf';
|
||||
label?: string;
|
||||
disabled?: boolean;
|
||||
disableUnderline?: boolean;
|
||||
style?: React.CSSProperties;
|
||||
small?: boolean;
|
||||
}
|
||||
|
||||
export const RelativeDateTimeSelector: React.FC<RelativeDateTimeSelectorProps> = ({
|
||||
value,
|
||||
onChange: setValue,
|
||||
type,
|
||||
style,
|
||||
label,
|
||||
small = false,
|
||||
disableUnderline = false,
|
||||
disabled = false,
|
||||
}) => {
|
||||
const [errVisible, setErrVisible] = React.useState(false);
|
||||
const [error, setError] = React.useState('');
|
||||
const {start, stop} = useTimeout(() => setErrVisible(true), 200);
|
||||
|
||||
const parsed = parseRelativeTime(value, type);
|
||||
return (
|
||||
<TextField
|
||||
fullWidth
|
||||
style={style}
|
||||
value={value}
|
||||
disabled={disabled}
|
||||
InputProps={{disableUnderline}}
|
||||
onChange={(e) => {
|
||||
const newValue = e.target.value;
|
||||
const result = parseRelativeTime(newValue, type);
|
||||
setErrVisible(false);
|
||||
stop();
|
||||
if (!result.success) {
|
||||
setError(result.error);
|
||||
start();
|
||||
} else {
|
||||
setError('');
|
||||
}
|
||||
setValue(newValue, result.success);
|
||||
}}
|
||||
error={error !== ''}
|
||||
helperText={
|
||||
small ? (
|
||||
undefined
|
||||
) : errVisible ? (
|
||||
<Typography color={'secondary'} variant={'caption'}>
|
||||
{error}
|
||||
</Typography>
|
||||
) : (
|
||||
<Typography variant={'caption'}>{!parsed.success ? '...' : parsed.value.format('llll')}</Typography>
|
||||
)
|
||||
}
|
||||
label={label}
|
||||
/>
|
||||
);
|
||||
};
|
||||
21
ui/src/common/RelativeTime.tsx
Normal file
21
ui/src/common/RelativeTime.tsx
Normal file
@@ -0,0 +1,21 @@
|
||||
import moment from 'moment';
|
||||
import * as React from 'react';
|
||||
import {timeRunning} from '../timespan/timeutils';
|
||||
import useInterval from '@rooks/use-interval';
|
||||
|
||||
export const RelativeToNow: React.FC<{from: moment.Moment}> = ({from}) => {
|
||||
const [now, setNow] = React.useState(moment());
|
||||
|
||||
useInterval(
|
||||
() => {
|
||||
setNow(moment());
|
||||
},
|
||||
1000,
|
||||
true
|
||||
);
|
||||
return <RelativeTime from={from} to={now} />;
|
||||
};
|
||||
|
||||
export const RelativeTime: React.FC<{from: moment.Moment; to: moment.Moment}> = ({from, to}) => {
|
||||
return <>{timeRunning(from, to)}</>;
|
||||
};
|
||||
37
ui/src/common/TagChip.tsx
Normal file
37
ui/src/common/TagChip.tsx
Normal file
@@ -0,0 +1,37 @@
|
||||
import Chip from '@material-ui/core/Chip';
|
||||
import * as React from 'react';
|
||||
// @ts-ignore
|
||||
import bestContrast from 'get-best-contrast-color';
|
||||
import {makeStyles, Theme} from '@material-ui/core';
|
||||
|
||||
const useStyles = makeStyles((theme: Theme) => ({
|
||||
chip: {
|
||||
margin: theme.spacing(0.5, 0.6),
|
||||
cursor: 'text',
|
||||
minHeight: '32px',
|
||||
height: 'fit-content',
|
||||
whiteSpace: 'normal',
|
||||
wordBreak: 'break-word',
|
||||
},
|
||||
}));
|
||||
|
||||
interface TagChipProps {
|
||||
label: string;
|
||||
color: string;
|
||||
onClick?: (e: React.MouseEvent) => void;
|
||||
}
|
||||
|
||||
export const TagChip: React.FC<TagChipProps> = ({color, label, onClick}) => {
|
||||
const classes = useStyles();
|
||||
const textColor = bestContrast(color, ['#fff', '#000']);
|
||||
return (
|
||||
<Chip
|
||||
tabIndex={-1}
|
||||
variant="outlined"
|
||||
className={classes.chip}
|
||||
style={{background: color, color: textColor}}
|
||||
label={label}
|
||||
onClick={onClick}
|
||||
/>
|
||||
);
|
||||
};
|
||||
2
ui/src/common/tsutil.ts
Normal file
2
ui/src/common/tsutil.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export type Diff<T extends string, U extends string> = ({[P in T]: P} & {[P in U]: never} & {[x: string]: never})[T];
|
||||
export type Omit<T, K extends Extract<keyof T, string>> = Pick<T, Diff<Extract<keyof T, string>, K>>;
|
||||
63
ui/src/dashboard/AddDashboardDialog.tsx
Normal file
63
ui/src/dashboard/AddDashboardDialog.tsx
Normal file
@@ -0,0 +1,63 @@
|
||||
import * as React from 'react';
|
||||
import Button from '@material-ui/core/Button';
|
||||
import TextField from '@material-ui/core/TextField';
|
||||
import Dialog from '@material-ui/core/Dialog';
|
||||
import DialogActions from '@material-ui/core/DialogActions';
|
||||
import DialogContent from '@material-ui/core/DialogContent';
|
||||
import DialogTitle from '@material-ui/core/DialogTitle';
|
||||
import {useMutation} from '@apollo/react-hooks';
|
||||
import {useSnackbar} from 'notistack';
|
||||
import {handleError} from '../utils/errors';
|
||||
import * as gqlDashboard from '../gql/dashboard';
|
||||
import {CreateDashboard, CreateDashboardVariables} from '../gql/__generated__/CreateDashboard';
|
||||
|
||||
interface AddTagDialogProps {
|
||||
open: boolean;
|
||||
close: () => void;
|
||||
}
|
||||
|
||||
export const AddDashboardDialog: React.FC<AddTagDialogProps> = ({close, open}) => {
|
||||
const [name, setName] = React.useState('');
|
||||
const {enqueueSnackbar} = useSnackbar();
|
||||
|
||||
const [addUser] = useMutation<CreateDashboard, CreateDashboardVariables>(gqlDashboard.CreateDashboard, {
|
||||
refetchQueries: [{query: gqlDashboard.Dashboards}],
|
||||
});
|
||||
const submit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
addUser({variables: {name}})
|
||||
.then(() => {
|
||||
enqueueSnackbar('Dashboard created', {variant: 'success'});
|
||||
close();
|
||||
})
|
||||
.catch(handleError('Create Dashboard', enqueueSnackbar));
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onClose={close} aria-labelledby="form-dialog-title" fullWidth>
|
||||
<form onSubmit={submit} noValidate autoComplete="off">
|
||||
<DialogTitle id="form-dialog-title">Create Dashboard</DialogTitle>
|
||||
<DialogContent>
|
||||
<TextField
|
||||
autoFocus
|
||||
margin="dense"
|
||||
id="name"
|
||||
label="Name"
|
||||
type="text"
|
||||
fullWidth
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
/>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={close} color="primary">
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" onClick={submit} color="primary">
|
||||
Create Dashboard
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</form>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
293
ui/src/dashboard/DashboardPage.tsx
Normal file
293
ui/src/dashboard/DashboardPage.tsx
Normal file
@@ -0,0 +1,293 @@
|
||||
import * as React from 'react';
|
||||
import {useMutation, useQuery} from '@apollo/react-hooks';
|
||||
import * as gqlDashboard from '../gql/dashboard';
|
||||
import {default as ReactGrid, Layout, WidthProvider} from 'react-grid-layout';
|
||||
import {Dashboards, Dashboards_dashboards_items} from '../gql/__generated__/Dashboards';
|
||||
import {UpdatePos, UpdatePosVariables} from '../gql/__generated__/UpdatePos';
|
||||
import {EntryType, StatsInterval} from '../gql/__generated__/globalTypes';
|
||||
import {DashboardEntry} from './Entry/DashboardEntry';
|
||||
import Button from '@material-ui/core/Button';
|
||||
import clone from 'lodash.clonedeep';
|
||||
import {EditPopup} from './Entry/EditPopup';
|
||||
import {EditGlass} from './Entry/EditGlass';
|
||||
import {Fade} from '../common/Fade';
|
||||
import {CenteredSpinner} from '../common/CenteredSpinner';
|
||||
import {AddPopup} from './Entry/AddPopup';
|
||||
import {Paper} from '@material-ui/core';
|
||||
import {Center} from '../common/Center';
|
||||
import {RemoveDashboardEntry, RemoveDashboardEntryVariables} from '../gql/__generated__/RemoveDashboardEntry';
|
||||
import {RouteChildrenProps} from 'react-router';
|
||||
import {useSnackbar} from 'notistack';
|
||||
import {DateRanges} from './DateRanges';
|
||||
import {Range} from '../utils/range';
|
||||
import {useStateAndDelegateWithDelayOnChange} from '../utils/hooks';
|
||||
|
||||
enum ViewType {
|
||||
Mobile = 'mobile',
|
||||
Desktop = 'desktop',
|
||||
}
|
||||
|
||||
const cols: Record<ViewType, number> = {
|
||||
[ViewType.Mobile]: 4,
|
||||
[ViewType.Desktop]: 20,
|
||||
};
|
||||
|
||||
const WidthAwareReactGrid = WidthProvider(ReactGrid);
|
||||
const EditId = -1;
|
||||
const newEntry = (): Dashboards_dashboards_items => {
|
||||
return {
|
||||
__typename: 'DashboardEntry',
|
||||
title: '',
|
||||
id: EditId,
|
||||
entryType: EntryType.PieChart,
|
||||
statsSelection: {
|
||||
__typename: 'StatsSelection',
|
||||
interval: StatsInterval.Single,
|
||||
range: {
|
||||
__typename: 'RelativeOrStaticRange',
|
||||
from: 'now-1w/w',
|
||||
to: 'now-1w/w',
|
||||
},
|
||||
rangeId: null,
|
||||
tags: [],
|
||||
excludeTags: [],
|
||||
includeTags: [],
|
||||
},
|
||||
pos: {
|
||||
__typename: 'ResponsiveDashboardEntryPos',
|
||||
[ViewType.Desktop]: {
|
||||
x: 0,
|
||||
y: 0,
|
||||
h: 6,
|
||||
w: 6,
|
||||
minH: 0,
|
||||
minW: 0,
|
||||
__typename: 'DashboardEntryPos',
|
||||
},
|
||||
[ViewType.Mobile]: {
|
||||
x: 0,
|
||||
y: 0,
|
||||
h: 5,
|
||||
w: 2,
|
||||
minH: 0,
|
||||
minW: 0,
|
||||
__typename: 'DashboardEntryPos',
|
||||
},
|
||||
},
|
||||
total: false,
|
||||
};
|
||||
};
|
||||
|
||||
type RouterProps = RouteChildrenProps<{id?: string}>;
|
||||
|
||||
export const DashboardPage: React.FC<RouterProps> = ({match, history}) => {
|
||||
const [addRef, setAddRef] = React.useState<null | HTMLElement>(null);
|
||||
const endRef = React.useRef<null | HTMLDivElement>(null);
|
||||
const [changeMode, setChangeMode] = React.useState(false);
|
||||
const [viewType, setViewType] = React.useState<ViewType>(ViewType.Desktop);
|
||||
const [preview, setPreview] = React.useState(false);
|
||||
const [diagramRanges, setDiagramRanges] = React.useState<Record<number, Range>>({});
|
||||
const [ranges, setRanges] = useStateAndDelegateWithDelayOnChange<Record<number, Range>>({}, setDiagramRanges, 2000);
|
||||
const [addEntry, setAddEntry] = React.useState<null | Dashboards_dashboards_items>(null);
|
||||
const [[editElement, editEntry], setEdit] = React.useState<[null] | [HTMLElement, Dashboards_dashboards_items]>([null]);
|
||||
const {loading, data, error} = useQuery<Dashboards>(gqlDashboard.Dashboards);
|
||||
const [updatePos] = useMutation<UpdatePos, UpdatePosVariables>(gqlDashboard.UpdatePos, {
|
||||
refetchQueries: [{query: gqlDashboard.Dashboards}],
|
||||
});
|
||||
const [removeDashboardEntry] = useMutation<RemoveDashboardEntry, RemoveDashboardEntryVariables>(
|
||||
gqlDashboard.RemoveDashboardEntry,
|
||||
{
|
||||
refetchQueries: [{query: gqlDashboard.Dashboards}],
|
||||
}
|
||||
);
|
||||
const {enqueueSnackbar} = useSnackbar();
|
||||
if (loading) {
|
||||
return <CenteredSpinner />;
|
||||
}
|
||||
if (error || !data) {
|
||||
return <span>error</span>;
|
||||
}
|
||||
|
||||
const dashboards = data.dashboards || [];
|
||||
|
||||
if (!match || !match.params.id) {
|
||||
enqueueSnackbar('id parameter is missing in url', {variant: 'warning'});
|
||||
history.push('/dashboards');
|
||||
return <></>;
|
||||
}
|
||||
const dashboard = dashboards.find((db) => '' + db.id === match.params.id);
|
||||
if (!dashboard) {
|
||||
enqueueSnackbar('dashboard does not exist', {variant: 'warning'});
|
||||
history.push('/dashboards');
|
||||
return <></>;
|
||||
}
|
||||
|
||||
const dashboardEntries = dashboard.items;
|
||||
|
||||
const l: Layout[] = dashboard.items.map((item) => ({
|
||||
i: '' + item.id,
|
||||
x: item.pos[viewType].x,
|
||||
w: item.pos[viewType].w,
|
||||
h: item.pos[viewType].h,
|
||||
y: item.pos[viewType].y,
|
||||
minH: item.pos[viewType].minH,
|
||||
minW: item.pos[viewType].minW,
|
||||
}));
|
||||
const maxY = l.reduce((a, b) => Math.max(a, b.y + b.h), 0);
|
||||
if (addEntry) {
|
||||
const newEntryLayout: Layout = {i: '' + EditId, ...addEntry.pos[viewType], y: maxY, static: true};
|
||||
l.push(newEntryLayout);
|
||||
}
|
||||
|
||||
const rangeToName = () =>
|
||||
dashboard.ranges.reduce(
|
||||
(a, range) => ({...a, [range.id]: `${range.name} (${range.range.from} - ${range.range.to})`}),
|
||||
{}
|
||||
);
|
||||
|
||||
const indexedRanges = dashboard.ranges.reduce((a, range) => ({...a, [range.id]: diagramRanges[range.id] || range.range}), {});
|
||||
|
||||
const updatePosHandler = (_1: unknown, _2: unknown, newItem: Layout) => {
|
||||
updatePos({
|
||||
variables: {
|
||||
entryId: parseInt(newItem.i!, 10),
|
||||
pos: {[viewType]: {x: newItem.x, y: newItem.y, w: newItem.w, h: newItem.h}},
|
||||
},
|
||||
}).then(console.log);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div style={{display: 'flex'}}>
|
||||
<div style={{flex: 1}}>
|
||||
<DateRanges setRanges={setRanges} ranges={ranges} changeMode={changeMode} dashboard={dashboard} />
|
||||
</div>
|
||||
<div>
|
||||
{changeMode ? (
|
||||
<Button
|
||||
onClick={() => setViewType(viewType === ViewType.Desktop ? ViewType.Mobile : ViewType.Desktop)}
|
||||
variant={'outlined'}
|
||||
style={{marginRight: 10}}>
|
||||
{viewType === ViewType.Mobile ? 'Desktop View' : 'Mobile View'}
|
||||
</Button>
|
||||
) : null}
|
||||
{changeMode ? (
|
||||
<Button
|
||||
onClick={() => {
|
||||
setAddEntry(newEntry());
|
||||
endRef.current!.scrollIntoView({behavior: 'smooth'});
|
||||
}}
|
||||
variant={'outlined'}
|
||||
style={{marginRight: 10}}>
|
||||
Add Entry
|
||||
</Button>
|
||||
) : null}
|
||||
<Button
|
||||
onClick={() => {
|
||||
setViewType(ViewType.Desktop);
|
||||
setChangeMode(!changeMode);
|
||||
}}
|
||||
variant={'outlined'}
|
||||
color={'primary'}
|
||||
style={{marginRight: 10}}>
|
||||
{changeMode ? 'Exit Editing' : 'Edit Dashboard'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
style={
|
||||
viewType === ViewType.Desktop
|
||||
? {}
|
||||
: {borderRight: '5px dotted grey', borderLeft: '5px dotted grey', maxWidth: 700, margin: '0 auto'}
|
||||
}>
|
||||
<WidthAwareReactGrid
|
||||
key={viewType}
|
||||
cols={cols[viewType]}
|
||||
className="layout"
|
||||
rowHeight={50}
|
||||
preventCollision={true}
|
||||
useCSSTransforms={false}
|
||||
autoSize={true}
|
||||
layout={l}
|
||||
onDragStop={updatePosHandler}
|
||||
onResizeStop={updatePosHandler}
|
||||
compactType={null}
|
||||
isResizable={changeMode}
|
||||
isDraggable={changeMode}>
|
||||
{dashboardEntries.map((entry) => {
|
||||
const currentEditedAndPreviewed = preview && editEntry && editEntry.id === entry.id;
|
||||
return (
|
||||
<div key={'' + entry.id}>
|
||||
<DashboardEntry ranges={indexedRanges} entry={currentEditedAndPreviewed ? editEntry! : entry} />
|
||||
{changeMode ? (
|
||||
<Fade fullyVisible={!currentEditedAndPreviewed} opacity={0}>
|
||||
<EditGlass
|
||||
doEdit={(elm) => setEdit([elm, clone(entry)])}
|
||||
doDelete={() => removeDashboardEntry({variables: {id: entry.id}})}
|
||||
/>
|
||||
</Fade>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{addEntry ? (
|
||||
<div
|
||||
key={'' + EditId}
|
||||
ref={(ref: HTMLDivElement) => {
|
||||
setAddRef(ref);
|
||||
if (ref) {
|
||||
ref.scrollIntoView({behavior: 'smooth'});
|
||||
}
|
||||
}}>
|
||||
{preview ? (
|
||||
<DashboardEntry ranges={indexedRanges} key="uff" entry={addEntry} />
|
||||
) : (
|
||||
<Paper style={{width: '100%', height: '100%'}}>
|
||||
<Center>New Entry</Center>
|
||||
</Paper>
|
||||
)}
|
||||
|
||||
{addRef ? (
|
||||
<AddPopup
|
||||
maxY={maxY}
|
||||
dashboardId={dashboard.id}
|
||||
preview={preview}
|
||||
ranges={rangeToName()}
|
||||
doPreview={setPreview}
|
||||
entry={addEntry}
|
||||
anchorEl={addRef}
|
||||
onChange={(e) => {
|
||||
return setAddEntry(clone(e));
|
||||
}}
|
||||
finish={() => {
|
||||
setAddEntry(null);
|
||||
setAddRef(null);
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
) : (
|
||||
[]
|
||||
)}
|
||||
</WidthAwareReactGrid>
|
||||
</div>
|
||||
{editElement !== null && editEntry !== null ? (
|
||||
<EditPopup
|
||||
preview={preview}
|
||||
ranges={rangeToName()}
|
||||
doPreview={setPreview}
|
||||
entry={editEntry!}
|
||||
anchorEl={editElement}
|
||||
onChange={(entry) => {
|
||||
if (entry === null) {
|
||||
setEdit([null]);
|
||||
} else {
|
||||
setEdit([editElement, entry]);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
<div ref={endRef} />
|
||||
</>
|
||||
);
|
||||
};
|
||||
147
ui/src/dashboard/DashboardsPage.tsx
Normal file
147
ui/src/dashboard/DashboardsPage.tsx
Normal file
@@ -0,0 +1,147 @@
|
||||
import * as React from 'react';
|
||||
import Paper from '@material-ui/core/Paper';
|
||||
import {useMutation, useQuery} from '@apollo/react-hooks';
|
||||
import {CenteredSpinner} from '../common/CenteredSpinner';
|
||||
import Table from '@material-ui/core/Table';
|
||||
import TableBody from '@material-ui/core/TableBody';
|
||||
import TableCell from '@material-ui/core/TableCell';
|
||||
import TableHead from '@material-ui/core/TableHead';
|
||||
import TableRow from '@material-ui/core/TableRow';
|
||||
import DeleteIcon from '@material-ui/icons/Delete';
|
||||
import EditIcon from '@material-ui/icons/Edit';
|
||||
import DoneIcon from '@material-ui/icons/Done';
|
||||
import CloseIcon from '@material-ui/icons/Close';
|
||||
import IconButton from '@material-ui/core/IconButton';
|
||||
import {useSnackbar} from 'notistack';
|
||||
import * as gqlDashboard from '../gql/dashboard';
|
||||
import {TextField} from '@material-ui/core';
|
||||
import Button from '@material-ui/core/Button';
|
||||
import {Dashboards} from '../gql/__generated__/Dashboards';
|
||||
import {RemoveDashboard, RemoveDashboardVariables} from '../gql/__generated__/RemoveDashboard';
|
||||
import {UpdateDashboard, UpdateDashboardVariables} from '../gql/__generated__/UpdateDashboard';
|
||||
import {AddDashboardDialog} from './AddDashboardDialog';
|
||||
import makeStyles from '@material-ui/core/styles/makeStyles';
|
||||
import {ConfirmDialog} from '../common/ConfirmDialog';
|
||||
|
||||
const useStyles = makeStyles((theme) => ({
|
||||
root: {
|
||||
...theme.mixins.gutters(),
|
||||
paddingTop: theme.spacing(3),
|
||||
paddingBottom: theme.spacing(3),
|
||||
textAlign: 'center',
|
||||
maxWidth: 800,
|
||||
margin: '0 auto',
|
||||
},
|
||||
}));
|
||||
|
||||
const NoEdit = [-1, ''] as const;
|
||||
|
||||
export const DashboardsPage = () => {
|
||||
const classes = useStyles();
|
||||
const {loading, data} = useQuery<Dashboards>(gqlDashboard.Dashboards);
|
||||
const [addDashboard, setAddDashboard] = React.useState(false);
|
||||
const [removeDashboardConfirm, setRemoveDashboardConfirm] = React.useState<false | [number, string]>(false);
|
||||
const refetch = {refetchQueries: [{query: gqlDashboard.Dashboards}]};
|
||||
const {enqueueSnackbar} = useSnackbar();
|
||||
const [removeDashboard] = useMutation<RemoveDashboard, RemoveDashboardVariables>(gqlDashboard.RemoveDashboard, refetch);
|
||||
const [[editId, editName], setEditing] = React.useState<Readonly<[number, string]>>(NoEdit);
|
||||
const [updateDashboard] = useMutation<UpdateDashboard, UpdateDashboardVariables>(gqlDashboard.UpdateDashboard, refetch);
|
||||
if (loading || !data || !data.dashboards) {
|
||||
return <CenteredSpinner />;
|
||||
}
|
||||
const onClickDelete = () => {
|
||||
if (removeDashboardConfirm) {
|
||||
removeDashboard({variables: {id: removeDashboardConfirm[0]}}).then(() =>
|
||||
enqueueSnackbar('dashboard deleted', {variant: 'success'})
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const dashboards = data.dashboards.map((dashboard) => {
|
||||
const onClickSubmit = () => {
|
||||
setEditing(NoEdit);
|
||||
updateDashboard({
|
||||
variables: {
|
||||
id: editId,
|
||||
name: editName,
|
||||
},
|
||||
}).then(() => enqueueSnackbar('dashboard edited', {variant: 'success'}));
|
||||
};
|
||||
const isEdited = editId === dashboard.id;
|
||||
return (
|
||||
<TableRow key={dashboard.id}>
|
||||
<TableCell>{dashboard.id}</TableCell>
|
||||
<TableCell>
|
||||
{isEdited ? (
|
||||
<TextField
|
||||
value={editName}
|
||||
onChange={(e) => setEditing([editId, e.target.value])}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
onClickSubmit();
|
||||
}
|
||||
}}
|
||||
onSubmit={onClickSubmit}
|
||||
/>
|
||||
) : (
|
||||
dashboard.name
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell align="right">
|
||||
{isEdited ? (
|
||||
<>
|
||||
<IconButton onClick={onClickSubmit} title="Save">
|
||||
<DoneIcon />
|
||||
</IconButton>
|
||||
<IconButton onClick={() => setEditing(NoEdit)} title="Cancel">
|
||||
<CloseIcon />
|
||||
</IconButton>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<IconButton onClick={() => setEditing([dashboard.id, dashboard.name])} title="Edit">
|
||||
<EditIcon />
|
||||
</IconButton>
|
||||
<IconButton onClick={() => setRemoveDashboardConfirm([dashboard.id, dashboard.name])} title="Delete">
|
||||
<DeleteIcon />
|
||||
</IconButton>
|
||||
</>
|
||||
)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
});
|
||||
|
||||
return (
|
||||
<Paper elevation={1} square={true} className={classes.root}>
|
||||
<Button
|
||||
color={'primary'}
|
||||
variant={'outlined'}
|
||||
size="small"
|
||||
onClick={() => setAddDashboard(true)}
|
||||
fullWidth
|
||||
style={{marginBottom: 10}}>
|
||||
Create Dashboard
|
||||
</Button>
|
||||
{addDashboard && <AddDashboardDialog open={true} close={() => setAddDashboard(false)} />}
|
||||
<Table>
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableCell>ID</TableCell>
|
||||
<TableCell>Name</TableCell>
|
||||
<TableCell style={{width: 150}} />
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>{dashboards}</TableBody>
|
||||
</Table>
|
||||
{removeDashboardConfirm ? (
|
||||
<ConfirmDialog
|
||||
title={`Delete Dashboard ${removeDashboardConfirm[1]}`}
|
||||
fClose={() => setRemoveDashboardConfirm(false)}
|
||||
fOnSubmit={onClickDelete}>
|
||||
<b>This operation cannot be undone.</b> Deleting the dashboard will remove all its dashboard entries.
|
||||
</ConfirmDialog>
|
||||
) : null}
|
||||
</Paper>
|
||||
);
|
||||
};
|
||||
213
ui/src/dashboard/DateRanges.tsx
Normal file
213
ui/src/dashboard/DateRanges.tsx
Normal file
@@ -0,0 +1,213 @@
|
||||
import * as React from 'react';
|
||||
import {useMutation} from '@apollo/react-hooks';
|
||||
import * as gqlDashboard from '../gql/dashboard';
|
||||
import {Dashboards_dashboards, Dashboards_dashboards_ranges} from '../gql/__generated__/Dashboards';
|
||||
import {IconButton, Paper, Typography} from '@material-ui/core';
|
||||
import {RelativeDateTimeSelector} from '../common/RelativeDateTimeSelector';
|
||||
import ArrowForwardIcon from '@material-ui/icons/ArrowForward';
|
||||
import MoreVert from '@material-ui/icons/MoreVert';
|
||||
import PlusIcon from '@material-ui/icons/Add';
|
||||
import Menu from '@material-ui/core/Menu';
|
||||
import MenuItem from '@material-ui/core/MenuItem';
|
||||
import {RemoveDashboardRange, RemoveDashboardRangeVariables} from '../gql/__generated__/RemoveDashboardRange';
|
||||
import {UpdateDashboardRange, UpdateDashboardRangeVariables} from '../gql/__generated__/UpdateDashboardRange';
|
||||
import {stripTypename} from '../utils/strip';
|
||||
import {Range} from '../utils/range';
|
||||
import {AddDashboardRange, AddDashboardRangeVariables} from '../gql/__generated__/AddDashboardRange';
|
||||
import Input from '@material-ui/core/Input';
|
||||
|
||||
interface Props {
|
||||
changeMode: boolean;
|
||||
dashboard: Dashboards_dashboards;
|
||||
setRanges: (cb: (ranges: Record<number, Range>) => Record<number, Range>) => void;
|
||||
ranges: Record<number, Range>;
|
||||
}
|
||||
|
||||
export const DateRanges: React.FC<Props> = ({changeMode, dashboard, ranges, setRanges}) => {
|
||||
const saveRef = React.useRef<number | null>(null);
|
||||
const [editedRanges, setEditedRanges] = React.useState<Record<number, Range>>({});
|
||||
const [editedNames, setEditedNames] = React.useState<Record<number, string>>({});
|
||||
const [openMenu, setOpenMenu] = React.useState<null | [HTMLButtonElement, number]>(null);
|
||||
|
||||
const [removeRange] = useMutation<RemoveDashboardRange, RemoveDashboardRangeVariables>(gqlDashboard.RemoveDashboardRange, {
|
||||
refetchQueries: [{query: gqlDashboard.Dashboards}],
|
||||
});
|
||||
const [updateRange] = useMutation<UpdateDashboardRange, UpdateDashboardRangeVariables>(gqlDashboard.UpdateDashboardRange, {
|
||||
refetchQueries: [{query: gqlDashboard.Dashboards}],
|
||||
});
|
||||
const [addRange] = useMutation<AddDashboardRange, AddDashboardRangeVariables>(gqlDashboard.AddDashboardRange, {
|
||||
refetchQueries: [{query: gqlDashboard.Dashboards}],
|
||||
});
|
||||
const saveRanges = (range: Dashboards_dashboards_ranges, newRange: Range, newName: string) => {
|
||||
if (saveRef.current) {
|
||||
clearTimeout(saveRef.current);
|
||||
}
|
||||
saveRef.current = window.setTimeout(() => {
|
||||
updateRange({
|
||||
variables: {
|
||||
rangeId: range.id,
|
||||
range: {
|
||||
editable: !range.editable,
|
||||
range: newRange,
|
||||
name: newName,
|
||||
},
|
||||
},
|
||||
});
|
||||
}, 500);
|
||||
};
|
||||
React.useEffect(() => {
|
||||
if (changeMode) {
|
||||
setEditedRanges({});
|
||||
setEditedNames({});
|
||||
}
|
||||
}, [changeMode]);
|
||||
|
||||
return (
|
||||
<>
|
||||
{dashboard.ranges
|
||||
.filter((range) => range.editable || changeMode)
|
||||
.map((range) => {
|
||||
const name = editedNames[range.id] !== undefined ? editedNames[range.id] : range.name;
|
||||
const dateRange = (changeMode ? editedRanges[range.id] : ranges[range.id]) || stripTypename(range.range);
|
||||
return (
|
||||
<React.Fragment key={range.id}>
|
||||
<Paper
|
||||
style={{display: 'inline-block', padding: '3px 10px', marginLeft: 10, marginBottom: 10}}
|
||||
elevation={1}>
|
||||
<div style={{display: 'inline-block'}}>
|
||||
<Typography variant={'subtitle2'} component="div">
|
||||
{changeMode ? (
|
||||
<Input
|
||||
disableUnderline={true}
|
||||
fullWidth
|
||||
onChange={(e) => {
|
||||
const value = e.target.value;
|
||||
setEditedNames((old) => ({
|
||||
...old,
|
||||
[range.id]: value,
|
||||
}));
|
||||
saveRanges(range, dateRange, value);
|
||||
}}
|
||||
value={name}
|
||||
/>
|
||||
) : (
|
||||
name
|
||||
)}
|
||||
</Typography>
|
||||
<RelativeDateTimeSelector
|
||||
small={!changeMode}
|
||||
disableUnderline={!changeMode}
|
||||
style={{width: changeMode ? 170 : 120}}
|
||||
value={dateRange.from}
|
||||
onChange={(value, valid) => {
|
||||
(changeMode ? setEditedRanges : setRanges)((old) => ({
|
||||
...old,
|
||||
[range.id]: {...dateRange, from: value},
|
||||
}));
|
||||
if (valid && changeMode) {
|
||||
saveRanges(range, {...dateRange, from: value}, name);
|
||||
}
|
||||
}}
|
||||
type="startOf"
|
||||
/>
|
||||
<ArrowForwardIcon style={{margin: '0 10px'}} />
|
||||
<RelativeDateTimeSelector
|
||||
small={!changeMode}
|
||||
disableUnderline={!changeMode}
|
||||
style={{width: changeMode ? 170 : 120}}
|
||||
value={dateRange.to}
|
||||
onChange={(value, valid) => {
|
||||
(changeMode ? setEditedRanges : setRanges)((old) => ({
|
||||
...old,
|
||||
[range.id]: {...dateRange, to: value},
|
||||
}));
|
||||
if (valid && changeMode) {
|
||||
saveRanges(
|
||||
range,
|
||||
{
|
||||
...dateRange,
|
||||
to: value,
|
||||
},
|
||||
name
|
||||
);
|
||||
}
|
||||
}}
|
||||
type="endOf"
|
||||
/>
|
||||
</div>
|
||||
{changeMode ? (
|
||||
<>
|
||||
<Typography style={{height: 50, display: 'inline-block'}} component="div">
|
||||
<IconButton size="medium" onClick={(e) => setOpenMenu([e.currentTarget, range.id])}>
|
||||
<MoreVert />
|
||||
</IconButton>
|
||||
</Typography>
|
||||
</>
|
||||
) : (
|
||||
undefined
|
||||
)}
|
||||
{openMenu && openMenu[1] === range.id ? (
|
||||
<Menu
|
||||
key="uff"
|
||||
aria-haspopup="true"
|
||||
anchorEl={openMenu[0]}
|
||||
onClose={() => setOpenMenu(null)}
|
||||
open={openMenu !== null}>
|
||||
<MenuItem
|
||||
onClick={() => {
|
||||
const used = dashboard.items.filter(
|
||||
(entry) => entry.statsSelection.rangeId === range.id
|
||||
);
|
||||
if (used.length === 0) {
|
||||
removeRange({variables: {rangeId: range.id}});
|
||||
} else {
|
||||
alert('Range is used by ' + used.map((entry) => entry.title).join(', '));
|
||||
}
|
||||
setOpenMenu(null);
|
||||
}}>
|
||||
Delete
|
||||
</MenuItem>
|
||||
<MenuItem
|
||||
onClick={() => {
|
||||
updateRange({
|
||||
variables: {
|
||||
rangeId: range.id,
|
||||
range: {
|
||||
editable: !range.editable,
|
||||
range: stripTypename(range.range),
|
||||
name: range.name,
|
||||
},
|
||||
},
|
||||
});
|
||||
setOpenMenu(null);
|
||||
}}>
|
||||
{range.editable ? 'make static' : 'make editable'}
|
||||
</MenuItem>
|
||||
</Menu>
|
||||
) : (
|
||||
undefined
|
||||
)}
|
||||
</Paper>
|
||||
</React.Fragment>
|
||||
);
|
||||
})}
|
||||
{changeMode ? (
|
||||
<Paper style={{display: 'inline-block', padding: '3px 10px', marginLeft: 10, marginBottom: 10}} elevation={1}>
|
||||
<IconButton
|
||||
onClick={() => {
|
||||
addRange({
|
||||
variables: {
|
||||
dashboardId: dashboard.id,
|
||||
range: {name: 'new range', editable: true, range: {from: 'now/w', to: 'now/w'}},
|
||||
},
|
||||
});
|
||||
}}>
|
||||
<PlusIcon />
|
||||
</IconButton>
|
||||
</Paper>
|
||||
) : (
|
||||
undefined
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
121
ui/src/dashboard/Entry/AddPopup.tsx
Normal file
121
ui/src/dashboard/Entry/AddPopup.tsx
Normal file
@@ -0,0 +1,121 @@
|
||||
import * as React from 'react';
|
||||
import Popper from '@material-ui/core/Popper';
|
||||
import ClickAwayListener from '@material-ui/core/ClickAwayListener';
|
||||
import {Paper} from '@material-ui/core';
|
||||
import Typography from '@material-ui/core/Typography';
|
||||
import Button from '@material-ui/core/Button';
|
||||
import {Dashboards_dashboards_items} from '../../gql/__generated__/Dashboards';
|
||||
import {useMutation} from '@apollo/react-hooks';
|
||||
import * as gqlDashboard from '../../gql/dashboard';
|
||||
import {Fade} from '../../common/Fade';
|
||||
import {DashboardEntryForm, isValidDashboardEntry} from './DashboardEntryForm';
|
||||
import {AddDashboardEntry, AddDashboardEntryVariables} from '../../gql/__generated__/AddDashboardEntry';
|
||||
import {handleError} from '../../utils/errors';
|
||||
import {useSnackbar} from 'notistack';
|
||||
|
||||
interface EditPopupProps {
|
||||
dashboardId: number;
|
||||
entry: Dashboards_dashboards_items;
|
||||
anchorEl: HTMLElement;
|
||||
onChange: (entry: Dashboards_dashboards_items | null) => void;
|
||||
doPreview: (preview: boolean) => void;
|
||||
preview: boolean;
|
||||
maxY: number;
|
||||
finish: () => void;
|
||||
ranges: Record<number, string>;
|
||||
}
|
||||
|
||||
export const AddPopup: React.FC<EditPopupProps> = ({
|
||||
entry,
|
||||
maxY,
|
||||
anchorEl,
|
||||
onChange: setEdit,
|
||||
doPreview,
|
||||
preview,
|
||||
dashboardId,
|
||||
finish,
|
||||
ranges,
|
||||
}) => {
|
||||
const [addEntry] = useMutation<AddDashboardEntry, AddDashboardEntryVariables>(gqlDashboard.AddDashboardEntry, {
|
||||
refetchQueries: [{query: gqlDashboard.Dashboards}],
|
||||
});
|
||||
const valid = isValidDashboardEntry(entry);
|
||||
|
||||
const {enqueueSnackbar} = useSnackbar();
|
||||
|
||||
return (
|
||||
<Popper
|
||||
key="popup"
|
||||
open={true}
|
||||
anchorEl={anchorEl}
|
||||
placement={'right-end'}
|
||||
disablePortal={false}
|
||||
style={{zIndex: 99999}}
|
||||
keepMounted={true}>
|
||||
<Fade fullyVisible={!preview} opacity={0.4}>
|
||||
<ClickAwayListener onClickAway={() => setEdit(null)}>
|
||||
<Paper style={{padding: 10, maxWidth: 500}}>
|
||||
<Typography variant="h5">Add</Typography>
|
||||
<DashboardEntryForm ranges={ranges} entry={entry} onChange={setEdit} disabled={preview} />
|
||||
<div style={{textAlign: 'right', display: 'flex', justifyContent: 'flex-end', paddingTop: 10}}>
|
||||
<Button
|
||||
color={'primary'}
|
||||
variant={'outlined'}
|
||||
style={{marginRight: 10, flex: 1}}
|
||||
disabled={!valid}
|
||||
onClick={() => doPreview(!preview)}>
|
||||
{preview ? 'Exit Preview' : 'Preview'}
|
||||
</Button>
|
||||
<Button
|
||||
color={'secondary'}
|
||||
variant={'outlined'}
|
||||
style={{marginRight: 10}}
|
||||
onClick={() => setEdit(null)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
color={'primary'}
|
||||
disabled={!valid}
|
||||
variant={'contained'}
|
||||
onClick={() => {
|
||||
addEntry({
|
||||
variables: {
|
||||
dashboardId,
|
||||
entryType: entry.entryType,
|
||||
title: entry.title,
|
||||
total: entry.total,
|
||||
stats: {
|
||||
tags: entry.statsSelection.tags,
|
||||
interval: entry.statsSelection.interval,
|
||||
range: entry.statsSelection.range
|
||||
? {
|
||||
from: entry.statsSelection.range.from,
|
||||
to: entry.statsSelection.range.to,
|
||||
}
|
||||
: null,
|
||||
rangeId: entry.statsSelection.rangeId,
|
||||
excludeTags: entry.statsSelection.excludeTags,
|
||||
includeTags: entry.statsSelection.includeTags,
|
||||
},
|
||||
pos: {
|
||||
desktop: {
|
||||
y: maxY,
|
||||
x: 0,
|
||||
w: 6,
|
||||
h: 6,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
.then(finish)
|
||||
.catch(handleError('Add Dashboard Entry', enqueueSnackbar));
|
||||
}}>
|
||||
Add
|
||||
</Button>
|
||||
</div>
|
||||
</Paper>
|
||||
</ClickAwayListener>
|
||||
</Fade>
|
||||
</Popper>
|
||||
);
|
||||
};
|
||||
69
ui/src/dashboard/Entry/DashboardBarChart.tsx
Normal file
69
ui/src/dashboard/Entry/DashboardBarChart.tsx
Normal file
@@ -0,0 +1,69 @@
|
||||
import {Stats_stats} from '../../gql/__generated__/Stats';
|
||||
import {Bar, BarChart, CartesianGrid, Legend, ResponsiveContainer, Tooltip, XAxis, YAxis} from 'recharts';
|
||||
import * as React from 'react';
|
||||
import {Colors} from './colors';
|
||||
import {ofSeconds} from './unit';
|
||||
import {ofInterval} from './dateformat';
|
||||
import {StatsInterval} from '../../gql/__generated__/globalTypes';
|
||||
import moment from 'moment';
|
||||
import {TagTooltip} from './TagTooltip';
|
||||
|
||||
interface DashboardPieChartProps {
|
||||
entries: Stats_stats[];
|
||||
interval: StatsInterval;
|
||||
type: 'stacked' | 'normal';
|
||||
total: boolean;
|
||||
}
|
||||
|
||||
interface Indexed {
|
||||
start: string;
|
||||
end: string;
|
||||
data: Record<string, number>;
|
||||
}
|
||||
|
||||
export const DashboardBarChart: React.FC<DashboardPieChartProps> = ({entries, interval, type, total}) => {
|
||||
const indexedEntries: Indexed[] = entries
|
||||
.map((entry) => {
|
||||
return {
|
||||
start: entry.start,
|
||||
end: entry.end,
|
||||
data: entry.entries!.reduce((all: Record<string, number>, current) => {
|
||||
return {...all, [current.key + ':' + current.value]: current.timeSpendInSeconds};
|
||||
}, {}),
|
||||
};
|
||||
})
|
||||
.sort((left, right) => moment(left.start).diff(right.start));
|
||||
const dataMax = indexedEntries.reduce((max, entry) => {
|
||||
return Math.max(
|
||||
max,
|
||||
Object.values(entry.data).reduce((a: number, b: number) => Math.max(a, b), 0),
|
||||
0
|
||||
);
|
||||
}, 0);
|
||||
const unit = ofSeconds(dataMax);
|
||||
const dateFormat = ofInterval(interval);
|
||||
return (
|
||||
<ResponsiveContainer>
|
||||
<BarChart data={indexedEntries}>
|
||||
<CartesianGrid strokeDasharray="3 3" />
|
||||
<YAxis type="number" unit={unit.short} />
|
||||
<Tooltip content={<TagTooltip dateFormat={dateFormat} total={total} />} />
|
||||
<Legend />
|
||||
<XAxis dataKey={(entry) => dateFormat(moment(entry.start))} interval={'preserveStartEnd'} />
|
||||
|
||||
{indexedEntries[0] &&
|
||||
Object.keys(indexedEntries[0].data).map((key, index) => {
|
||||
return (
|
||||
<Bar
|
||||
key={key}
|
||||
dataKey={(entry) => unit.toUnit(entry.data[key])}
|
||||
fill={Colors[index % Colors.length]}
|
||||
stackId={type === 'stacked' ? 'a' : undefined}
|
||||
name={key}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
);
|
||||
};
|
||||
99
ui/src/dashboard/Entry/DashboardEntry.tsx
Normal file
99
ui/src/dashboard/Entry/DashboardEntry.tsx
Normal file
@@ -0,0 +1,99 @@
|
||||
import {Dashboards_dashboards_items} from '../../gql/__generated__/Dashboards';
|
||||
import * as React from 'react';
|
||||
import {DashboardPieChart} from './DashboardPieChart';
|
||||
import {Stats_stats_entries} from '../../gql/__generated__/Stats';
|
||||
import {useQuery} from '@apollo/react-hooks';
|
||||
import * as gqlStats from '../../gql/statistics';
|
||||
import {Paper} from '@material-ui/core';
|
||||
import Typography from '@material-ui/core/Typography';
|
||||
import moment from 'moment-timezone';
|
||||
import {EntryType} from '../../gql/__generated__/globalTypes';
|
||||
import {expectNever} from '../../utils/never';
|
||||
import {Stats2, Stats2Variables} from '../../gql/__generated__/Stats2';
|
||||
import {DashboardBarChart} from './DashboardBarChart';
|
||||
import {DashboardLineChart} from './DashboardLineChart';
|
||||
import {CenteredSpinner} from '../../common/CenteredSpinner';
|
||||
import {Center} from '../../common/Center';
|
||||
import {findRange, Range} from '../../utils/range';
|
||||
import {DashboardTable} from './DashboardTable';
|
||||
|
||||
interface DashboardEntryProps {
|
||||
entry: Dashboards_dashboards_items;
|
||||
ranges: Record<number, Range>;
|
||||
ref?: React.Ref<HTMLElement>;
|
||||
}
|
||||
|
||||
export const DashboardEntry: React.FC<DashboardEntryProps> = React.forwardRef<{}, DashboardEntryProps>(({entry, ranges}, ref) => {
|
||||
const range = findRange(entry.statsSelection, ranges);
|
||||
return (
|
||||
<Paper style={{width: '100%', height: '100%'}} ref={ref}>
|
||||
<Typography style={{lineHeight: '20px', paddingTop: 15}} component="h4" align="center" variant="h6">
|
||||
{entry.title}
|
||||
</Typography>
|
||||
<Typography style={{position: 'absolute', right: 10, top: 35, fontSize: 10, color: 'gray'}}>
|
||||
{range.from} to {range.to}
|
||||
</Typography>
|
||||
<div style={{height: 'calc(100% - 35px)'}}>
|
||||
<SpecificDashboardEntry range={range} entry={entry} />
|
||||
</div>
|
||||
</Paper>
|
||||
);
|
||||
});
|
||||
|
||||
// tslint:disable-next-line:cyclomatic-complexity mccabe-complexity
|
||||
const SpecificDashboardEntry: React.FC<{entry: Dashboards_dashboards_items; range: Range}> = ({entry, range}) => {
|
||||
const interval = entry.statsSelection.interval;
|
||||
const stats = useQuery<Stats2, Stats2Variables>(gqlStats.Stats2, {
|
||||
variables: {
|
||||
now: moment()
|
||||
.startOf('hour')
|
||||
.format(),
|
||||
stats: {
|
||||
range,
|
||||
interval,
|
||||
tags: entry.statsSelection.tags,
|
||||
excludeTags: entry.statsSelection.excludeTags,
|
||||
includeTags: entry.statsSelection.includeTags,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (stats.loading) {
|
||||
return <CenteredSpinner />;
|
||||
}
|
||||
|
||||
if (stats.error) {
|
||||
return (
|
||||
<Center>
|
||||
<Typography>error: {stats.error.message}</Typography>
|
||||
</Center>
|
||||
);
|
||||
}
|
||||
const firstEntries: Stats_stats_entries[] =
|
||||
(stats.data && stats.data.stats && stats.data.stats[0] && stats.data.stats[0].entries) || [];
|
||||
if (firstEntries.length === 0) {
|
||||
return (
|
||||
<Center>
|
||||
<Typography>no data</Typography>
|
||||
</Center>
|
||||
);
|
||||
}
|
||||
|
||||
const entries = (stats.data && stats.data.stats) || [];
|
||||
switch (entry.entryType) {
|
||||
case EntryType.PieChart:
|
||||
return <DashboardPieChart entries={firstEntries} />;
|
||||
case EntryType.BarChart:
|
||||
return <DashboardBarChart entries={entries} interval={interval} type="normal" total={entry.total} />;
|
||||
case EntryType.StackedBarChart:
|
||||
return <DashboardBarChart entries={entries} interval={interval} type="stacked" total={entry.total} />;
|
||||
case EntryType.LineChart:
|
||||
return <DashboardLineChart entries={entries} interval={interval} total={entry.total} />;
|
||||
case EntryType.VerticalTable:
|
||||
return <DashboardTable mode="vertical" entries={entries} interval={interval} total={entry.total} />;
|
||||
case EntryType.HorizontalTable:
|
||||
return <DashboardTable mode="horizontal" entries={entries} interval={interval} total={entry.total} />;
|
||||
default:
|
||||
return expectNever(entry.entryType);
|
||||
}
|
||||
};
|
||||
256
ui/src/dashboard/Entry/DashboardEntryForm.tsx
Normal file
256
ui/src/dashboard/Entry/DashboardEntryForm.tsx
Normal file
@@ -0,0 +1,256 @@
|
||||
import * as React from 'react';
|
||||
import TextField from '@material-ui/core/TextField';
|
||||
import FormControl from '@material-ui/core/FormControl';
|
||||
import InputLabel from '@material-ui/core/InputLabel';
|
||||
import Select from '@material-ui/core/NativeSelect/NativeSelect';
|
||||
import {useQuery} from 'react-apollo';
|
||||
import {Tags} from '../../gql/__generated__/Tags';
|
||||
import * as gqlTags from '../../gql/tags';
|
||||
import {EntryType, StatsInterval} from '../../gql/__generated__/globalTypes';
|
||||
import {toTagSelectorEntry} from '../../tag/tagSelectorEntry';
|
||||
import {FormTagSelector} from '../../tag/FormTagSelector';
|
||||
import {Dashboards_dashboards_items, Dashboards_dashboards_items_statsSelection_range} from '../../gql/__generated__/Dashboards';
|
||||
import {RelativeDateTimeSelector} from '../../common/RelativeDateTimeSelector';
|
||||
import {parseRelativeTime} from '../../utils/time';
|
||||
import {Grid, Typography, Switch} from '@material-ui/core';
|
||||
|
||||
interface EditPopupProps {
|
||||
entry: Dashboards_dashboards_items;
|
||||
onChange: (entry: Dashboards_dashboards_items | null) => void;
|
||||
disabled?: boolean;
|
||||
ranges: Record<string, string>;
|
||||
}
|
||||
|
||||
export const isValidDashboardEntry = (item: Dashboards_dashboards_items): boolean => {
|
||||
return (
|
||||
item.statsSelection.tags !== null &&
|
||||
item.statsSelection.tags.length > 0 &&
|
||||
((item.statsSelection.range &&
|
||||
parseRelativeTime(item.statsSelection.range.from, 'startOf').success &&
|
||||
parseRelativeTime(item.statsSelection.range.to, 'startOf').success) ||
|
||||
!!item.statsSelection.rangeId) &&
|
||||
item.title.length > 0
|
||||
);
|
||||
};
|
||||
export const DashboardEntryForm: React.FC<EditPopupProps> = ({entry, onChange: setEntry, disabled = false, ranges}) => {
|
||||
const [staticRange, setStaticRange] = React.useState(!entry.statsSelection.rangeId);
|
||||
|
||||
const tagsResult = useQuery<Tags>(gqlTags.Tags);
|
||||
|
||||
let tagKeys;
|
||||
let excludeTags;
|
||||
let includeTags;
|
||||
if (!tagsResult.error && !tagsResult.loading && tagsResult.data && tagsResult.data.tags) {
|
||||
const keyInputTags = (entry.statsSelection.tags || []).map((key) => ({key, value: ''}));
|
||||
tagKeys = toTagSelectorEntry(tagsResult.data.tags, keyInputTags);
|
||||
excludeTags = toTagSelectorEntry(tagsResult.data.tags, entry.statsSelection.excludeTags || []);
|
||||
includeTags = toTagSelectorEntry(tagsResult.data.tags, entry.statsSelection.includeTags || []);
|
||||
}
|
||||
|
||||
const range: Dashboards_dashboards_items_statsSelection_range = entry.statsSelection.range
|
||||
? entry.statsSelection.range
|
||||
: {
|
||||
from: 'now/w',
|
||||
to: 'now/w',
|
||||
__typename: 'RelativeOrStaticRange',
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<TextField
|
||||
label={'Title'}
|
||||
value={entry.title}
|
||||
disabled={disabled}
|
||||
onChange={(e) => {
|
||||
entry.title = e.target.value;
|
||||
setEntry(entry);
|
||||
}}
|
||||
fullWidth
|
||||
/>
|
||||
<FormControl margin={'normal'} fullWidth>
|
||||
<InputLabel>Type</InputLabel>
|
||||
<Select
|
||||
fullWidth
|
||||
value={entry.entryType}
|
||||
disabled={disabled}
|
||||
onChange={(e) => {
|
||||
entry.entryType = e.target.value as EntryType;
|
||||
setEntry(entry);
|
||||
}}>
|
||||
{Object.values(EntryType).map((type) => (
|
||||
<option key={type} value={type}>
|
||||
{type}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
</FormControl>
|
||||
<FormControl margin={'normal'} fullWidth>
|
||||
<InputLabel>Interval</InputLabel>
|
||||
<Select
|
||||
fullWidth
|
||||
disabled={disabled}
|
||||
value={entry.statsSelection.interval}
|
||||
onChange={(e) => {
|
||||
entry.statsSelection.interval = e.target.value as StatsInterval;
|
||||
setEntry(entry);
|
||||
}}>
|
||||
{Object.values(StatsInterval).map((type) => (
|
||||
<option key={type} value={type}>
|
||||
{type}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
</FormControl>
|
||||
<Typography component="div">
|
||||
<Grid component="label" container alignItems="center" spacing={1}>
|
||||
<Grid item>Range: </Grid>
|
||||
<Grid item>Global</Grid>
|
||||
<Grid item>
|
||||
<Switch
|
||||
checked={staticRange}
|
||||
onChange={(e) => {
|
||||
if (e.target.checked) {
|
||||
setStaticRange(true);
|
||||
entry.statsSelection.rangeId = null;
|
||||
setEntry(entry);
|
||||
} else {
|
||||
setStaticRange(false);
|
||||
|
||||
if (entry.statsSelection.range === null) {
|
||||
entry.statsSelection.range = range;
|
||||
setEntry(entry);
|
||||
}
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</Grid>
|
||||
<Grid item>Static</Grid>
|
||||
</Grid>
|
||||
</Typography>
|
||||
{staticRange ? (
|
||||
<>
|
||||
<RelativeDateTimeSelector
|
||||
label={'From'}
|
||||
disabled={disabled}
|
||||
value={range.from}
|
||||
onChange={(startDate) => {
|
||||
entry.statsSelection.rangeId = null;
|
||||
entry.statsSelection.range = {...range, from: startDate};
|
||||
setEntry(entry);
|
||||
}}
|
||||
type="startOf"
|
||||
/>
|
||||
<RelativeDateTimeSelector
|
||||
label={'To'}
|
||||
disabled={disabled}
|
||||
value={range.to}
|
||||
onChange={(endDate) => {
|
||||
entry.statsSelection.rangeId = null;
|
||||
entry.statsSelection.range = {...range, to: endDate};
|
||||
setEntry(entry);
|
||||
}}
|
||||
type="endOf"
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<Select
|
||||
fullWidth
|
||||
disabled={disabled}
|
||||
value={entry.statsSelection.rangeId || ''}
|
||||
onChange={(e) => {
|
||||
if (e.target.value === '') {
|
||||
return;
|
||||
}
|
||||
entry.statsSelection.rangeId = parseInt(e.target.value, 10);
|
||||
entry.statsSelection.range = null;
|
||||
setEntry(entry);
|
||||
}}>
|
||||
{entry.statsSelection.rangeId ? (
|
||||
undefined
|
||||
) : (
|
||||
<option key={''} value={''}>
|
||||
Select a date range
|
||||
</option>
|
||||
)}
|
||||
{Object.keys(ranges).map((key) => (
|
||||
<option key={key} value={key}>
|
||||
{ranges[key]}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
)}
|
||||
{entry.entryType !== EntryType.PieChart ? (
|
||||
<Typography component="div">
|
||||
<Grid component="label" container alignItems="center" spacing={1}>
|
||||
<Grid item>{getTotalTitle(entry.entryType)}</Grid>
|
||||
<Grid item>
|
||||
<Switch
|
||||
checked={entry.total}
|
||||
onChange={(e) => {
|
||||
entry.total = e.target.checked;
|
||||
setEntry(entry);
|
||||
}}
|
||||
/>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</Typography>
|
||||
) : (
|
||||
undefined
|
||||
)}
|
||||
<FormTagSelector
|
||||
label="Tags"
|
||||
selectedEntries={tagKeys || []}
|
||||
onSelectedEntriesChanged={(tags) => {
|
||||
entry.statsSelection.tags = tags.map((tag) => tag.tag.key);
|
||||
setEntry(entry);
|
||||
}}
|
||||
createTags={false}
|
||||
onlySelectKeys
|
||||
/>
|
||||
|
||||
<FormTagSelector
|
||||
label="Exclude"
|
||||
selectedEntries={excludeTags || []}
|
||||
onSelectedEntriesChanged={(tags) => {
|
||||
entry.statsSelection.excludeTags = tags.map((tag) => ({
|
||||
key: tag.tag.key,
|
||||
value: tag.value,
|
||||
__typename: 'TimeSpanTag',
|
||||
}));
|
||||
setEntry(entry);
|
||||
}}
|
||||
allowDuplicateKeys
|
||||
createTags={false}
|
||||
/>
|
||||
<FormTagSelector
|
||||
label="Include"
|
||||
selectedEntries={includeTags || []}
|
||||
onSelectedEntriesChanged={(tags) => {
|
||||
entry.statsSelection.includeTags = tags.map((tag) => ({
|
||||
key: tag.tag.key,
|
||||
value: tag.value,
|
||||
__typename: 'TimeSpanTag',
|
||||
}));
|
||||
setEntry(entry);
|
||||
}}
|
||||
allowDuplicateKeys
|
||||
createTags={false}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const getTotalTitle = (entryType: EntryType) => {
|
||||
switch (entryType) {
|
||||
case EntryType.HorizontalTable:
|
||||
return 'Show "Total" row:';
|
||||
case EntryType.VerticalTable:
|
||||
return 'Show "Total" column:';
|
||||
case EntryType.BarChart:
|
||||
case EntryType.StackedBarChart:
|
||||
case EntryType.LineChart:
|
||||
return 'Show "Total" in tooltip:';
|
||||
default:
|
||||
return '';
|
||||
}
|
||||
};
|
||||
68
ui/src/dashboard/Entry/DashboardLineChart.tsx
Normal file
68
ui/src/dashboard/Entry/DashboardLineChart.tsx
Normal file
@@ -0,0 +1,68 @@
|
||||
import {Stats_stats} from '../../gql/__generated__/Stats';
|
||||
import {CartesianGrid, Legend, Line, LineChart, ResponsiveContainer, Tooltip, XAxis, YAxis} from 'recharts';
|
||||
import * as React from 'react';
|
||||
import {Colors} from './colors';
|
||||
import {ofSeconds} from './unit';
|
||||
import {ofInterval} from './dateformat';
|
||||
import {StatsInterval} from '../../gql/__generated__/globalTypes';
|
||||
import moment from 'moment';
|
||||
import {TagTooltip} from './TagTooltip';
|
||||
|
||||
interface DashboardPieChartProps {
|
||||
entries: Stats_stats[];
|
||||
interval: StatsInterval;
|
||||
total: boolean;
|
||||
}
|
||||
|
||||
interface Indexed {
|
||||
start: string;
|
||||
end: string;
|
||||
data: Record<string, number>;
|
||||
}
|
||||
|
||||
export const DashboardLineChart: React.FC<DashboardPieChartProps> = ({entries, interval, total}) => {
|
||||
const indexedEntries: Indexed[] = entries
|
||||
.map((entry) => {
|
||||
return {
|
||||
start: entry.start,
|
||||
end: entry.end,
|
||||
data: entry.entries!.reduce((all: Record<string, number>, current) => {
|
||||
return {...all, [current.key + ':' + current.value]: current.timeSpendInSeconds};
|
||||
}, {}),
|
||||
};
|
||||
})
|
||||
.sort((left, right) => moment(left.start).diff(right.start));
|
||||
const dataMax = indexedEntries.reduce((max, entry) => {
|
||||
return Math.max(
|
||||
max,
|
||||
Object.values(entry.data).reduce((a: number, b: number) => Math.max(a, b), 0),
|
||||
0
|
||||
);
|
||||
}, 0);
|
||||
const unit = ofSeconds(dataMax);
|
||||
const dateFormat = ofInterval(interval);
|
||||
return (
|
||||
<ResponsiveContainer>
|
||||
<LineChart data={indexedEntries}>
|
||||
<CartesianGrid strokeDasharray="3 3" />
|
||||
<YAxis type="number" unit={unit.short} />
|
||||
<Tooltip content={<TagTooltip dateFormat={dateFormat} total={total} />} />
|
||||
<Legend />
|
||||
<XAxis dataKey={(entry) => dateFormat(moment(entry.start))} interval={'preserveStartEnd'} />
|
||||
|
||||
{indexedEntries[0] &&
|
||||
Object.keys(indexedEntries[0].data).map((key, index) => {
|
||||
return (
|
||||
<Line
|
||||
key={key}
|
||||
dataKey={(entry) => unit.toUnit(entry.data[key])}
|
||||
strokeWidth={3}
|
||||
stroke={Colors[index % Colors.length]}
|
||||
name={key}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
);
|
||||
};
|
||||
58
ui/src/dashboard/Entry/DashboardPieChart.tsx
Normal file
58
ui/src/dashboard/Entry/DashboardPieChart.tsx
Normal file
@@ -0,0 +1,58 @@
|
||||
import {Stats_stats_entries} from '../../gql/__generated__/Stats';
|
||||
import {Cell, Legend, Pie, PieChart, ResponsiveContainer, Tooltip, TooltipProps} from 'recharts';
|
||||
import * as React from 'react';
|
||||
import {Colors} from './colors';
|
||||
import {Typography} from '@material-ui/core';
|
||||
import prettyMs from 'pretty-ms';
|
||||
import Paper from '@material-ui/core/Paper';
|
||||
|
||||
interface DashboardPieChartProps {
|
||||
entries: Stats_stats_entries[];
|
||||
}
|
||||
|
||||
export const DashboardPieChart: React.FC<DashboardPieChartProps> = ({entries}) => {
|
||||
const total = entries.map((e) => e.timeSpendInSeconds).reduce((x, y) => x + y, 0);
|
||||
return (
|
||||
<ResponsiveContainer>
|
||||
<PieChart>
|
||||
<Pie
|
||||
isAnimationActive={false}
|
||||
dataKey="timeSpendInSeconds"
|
||||
nameKey={(entry) => {
|
||||
// tslint:disable-next-line:no-any
|
||||
return (entry.key + ':' + entry.value) as any;
|
||||
}}
|
||||
data={entries}
|
||||
labelLine={false}
|
||||
fill="#8884d8"
|
||||
legendType={'square'}>
|
||||
{entries.map((_, index) => (
|
||||
<Cell key={index} fill={Colors[index % Colors.length]} />
|
||||
))}
|
||||
</Pie>
|
||||
<Tooltip content={<CustomTooltip total={total} />} />
|
||||
<Legend />
|
||||
</PieChart>
|
||||
</ResponsiveContainer>
|
||||
);
|
||||
};
|
||||
|
||||
interface CustomTooltipProps extends TooltipProps {
|
||||
total: number;
|
||||
}
|
||||
|
||||
const CustomTooltip = ({active, payload, total}: CustomTooltipProps) => {
|
||||
if (active && payload) {
|
||||
const first = payload[0];
|
||||
return (
|
||||
<Paper style={{padding: 10}} elevation={4}>
|
||||
<Typography>
|
||||
{first.payload.key}:{first.payload.value}: {prettyMs(first.payload.timeSpendInSeconds * 1000)} (
|
||||
{total > 0 ? ((first.payload.timeSpendInSeconds / total) * 100).toFixed(2) : '0.00'}%)
|
||||
</Typography>
|
||||
</Paper>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
94
ui/src/dashboard/Entry/DashboardTable.tsx
Normal file
94
ui/src/dashboard/Entry/DashboardTable.tsx
Normal file
@@ -0,0 +1,94 @@
|
||||
import {Stats_stats} from '../../gql/__generated__/Stats';
|
||||
import * as React from 'react';
|
||||
import {StatsInterval} from '../../gql/__generated__/globalTypes';
|
||||
import moment from 'moment';
|
||||
import {Table, TableBody, TableCell, TableRow} from '@material-ui/core';
|
||||
import TableHead from '@material-ui/core/TableHead';
|
||||
import {ofInterval} from './dateformat';
|
||||
import prettyMs from 'pretty-ms';
|
||||
|
||||
interface DashboardTableProps {
|
||||
entries: Stats_stats[];
|
||||
interval: StatsInterval;
|
||||
mode: 'vertical' | 'horizontal';
|
||||
total: boolean;
|
||||
}
|
||||
|
||||
interface Indexed {
|
||||
start: string;
|
||||
end: string;
|
||||
data: Record<string, number>;
|
||||
}
|
||||
|
||||
export const DashboardTable: React.FC<DashboardTableProps> = ({entries, interval, mode, total}) => {
|
||||
const indexedEntries: Indexed[] = entries
|
||||
.map((entry) => {
|
||||
const result = {
|
||||
start: entry.start,
|
||||
end: entry.end,
|
||||
data: entry.entries!.reduce((all: Record<string, number>, current) => {
|
||||
return {...all, [current.key + ':' + current.value]: current.timeSpendInSeconds};
|
||||
}, {}),
|
||||
};
|
||||
if (total) {
|
||||
result.data['Total'] = Object.values(result.data).reduce((acc, value) => acc + value, 0);
|
||||
}
|
||||
return result;
|
||||
})
|
||||
.sort((left, right) => moment(left.start).diff(right.start));
|
||||
const dateFormat = ofInterval(interval);
|
||||
const keys = Object.keys((indexedEntries[0] && indexedEntries[0].data) || {});
|
||||
return (
|
||||
<div style={{overflow: 'auto', height: '100%', margin: 2}}>
|
||||
<Table size="small">
|
||||
{mode === 'vertical' ? (
|
||||
<>
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableCell>Date</TableCell>
|
||||
{keys.map((key) => {
|
||||
return <TableCell key={key}>{key}</TableCell>;
|
||||
})}
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{indexedEntries.map((entry) => {
|
||||
return (
|
||||
<TableRow key={entry.start}>
|
||||
<TableCell>{dateFormat(moment(entry.start))}</TableCell>
|
||||
{keys.map((key) => (
|
||||
<TableCell key={key + entry.start}>{prettyMs(entry.data[key] * 1000)}</TableCell>
|
||||
))}
|
||||
</TableRow>
|
||||
);
|
||||
})}
|
||||
</TableBody>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableCell>Date</TableCell>
|
||||
{indexedEntries.map((entry) => {
|
||||
return <TableCell key={entry.start}>{dateFormat(moment(entry.start))}</TableCell>;
|
||||
})}
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{keys.map((key) => {
|
||||
return (
|
||||
<TableRow key={key}>
|
||||
<TableCell>{key}</TableCell>
|
||||
{indexedEntries.map((entry) => (
|
||||
<TableCell key={key + entry.start}>{prettyMs(entry.data[key] * 1000)}</TableCell>
|
||||
))}
|
||||
</TableRow>
|
||||
);
|
||||
})}
|
||||
</TableBody>
|
||||
</>
|
||||
)}
|
||||
</Table>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
67
ui/src/dashboard/Entry/EditGlass.tsx
Normal file
67
ui/src/dashboard/Entry/EditGlass.tsx
Normal file
@@ -0,0 +1,67 @@
|
||||
import * as React from 'react';
|
||||
import EditIcon from '@material-ui/icons/Edit';
|
||||
import Typography from '@material-ui/core/Typography';
|
||||
import {Center} from '../../common/Center';
|
||||
import DeleteIcon from '@material-ui/icons/Delete';
|
||||
import {Button} from '@material-ui/core';
|
||||
|
||||
interface EditGlassProps {
|
||||
doEdit: (elm: HTMLElement) => void;
|
||||
doDelete: () => void;
|
||||
}
|
||||
|
||||
export const EditGlass: React.FC<EditGlassProps> = ({doEdit, doDelete}) => {
|
||||
const [confirm, askConfirmation] = React.useState(false);
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
bottom: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
background: 'rgba(110,110,110,.5)',
|
||||
}}>
|
||||
{confirm ? (
|
||||
<div>
|
||||
<Typography>Confirm Deletion</Typography>
|
||||
<Button onClick={() => askConfirmation(false)}>Cancel</Button>
|
||||
<Button color={'primary'} onClick={doDelete}>
|
||||
Yeah, Delete
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div style={{height: '30%', display: 'flex', cursor: 'pointer'}}>
|
||||
<div
|
||||
style={{height: '100%', width: '65%', background: 'rgba(255,255,255,.4)'}}
|
||||
onClick={(e) => doEdit(e.currentTarget)}>
|
||||
<Center>
|
||||
<EditIcon />
|
||||
</Center>
|
||||
</div>
|
||||
<div
|
||||
style={{height: '100%', width: '35%', background: 'rgba(192, 57, 43, .4)'}}
|
||||
onClick={() => askConfirmation(true)}>
|
||||
<Center>
|
||||
<DeleteIcon fontSize={'small'} />
|
||||
</Center>
|
||||
</div>
|
||||
</div>
|
||||
<div style={{height: '70%', cursor: 'grab'}}>
|
||||
<Center>
|
||||
<div style={{background: 'rgba(120,120,120, .8)', padding: 10}}>
|
||||
<Typography variant={'h5'} style={{textAlign: 'center', width: '100%'}}>
|
||||
drag here
|
||||
</Typography>
|
||||
<Typography variant={'body1'} style={{textAlign: 'center'}}>
|
||||
resize on corner
|
||||
</Typography>
|
||||
</div>
|
||||
</Center>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
99
ui/src/dashboard/Entry/EditPopup.tsx
Normal file
99
ui/src/dashboard/Entry/EditPopup.tsx
Normal file
@@ -0,0 +1,99 @@
|
||||
import * as React from 'react';
|
||||
import Popper from '@material-ui/core/Popper';
|
||||
import ClickAwayListener from '@material-ui/core/ClickAwayListener';
|
||||
import {Paper} from '@material-ui/core';
|
||||
import Typography from '@material-ui/core/Typography';
|
||||
import Button from '@material-ui/core/Button';
|
||||
import {Dashboards_dashboards_items} from '../../gql/__generated__/Dashboards';
|
||||
import {useMutation} from '@apollo/react-hooks';
|
||||
import * as gqlDashboard from '../../gql/dashboard';
|
||||
import {UpdateDashboardEntry, UpdateDashboardEntryVariables} from '../../gql/__generated__/UpdateDashboardEntry';
|
||||
import {Fade} from '../../common/Fade';
|
||||
import {DashboardEntryForm, isValidDashboardEntry} from './DashboardEntryForm';
|
||||
import {handleError} from '../../utils/errors';
|
||||
import {useSnackbar} from 'notistack';
|
||||
|
||||
interface EditPopupProps {
|
||||
entry: Dashboards_dashboards_items;
|
||||
anchorEl: HTMLElement;
|
||||
onChange: (entry: Dashboards_dashboards_items | null) => void;
|
||||
doPreview: (preview: boolean) => void;
|
||||
preview: boolean;
|
||||
ranges: Record<number, string>;
|
||||
}
|
||||
export const EditPopup: React.FC<EditPopupProps> = ({entry, anchorEl, onChange: setEdit, doPreview, preview, ranges}) => {
|
||||
const [updateEntry] = useMutation<UpdateDashboardEntry, UpdateDashboardEntryVariables>(gqlDashboard.UpdateDashboardEntry, {
|
||||
refetchQueries: [{query: gqlDashboard.Dashboards}],
|
||||
});
|
||||
const valid = isValidDashboardEntry(entry);
|
||||
|
||||
const {enqueueSnackbar} = useSnackbar();
|
||||
|
||||
return (
|
||||
<Popper
|
||||
key="popup"
|
||||
open={true}
|
||||
anchorEl={anchorEl}
|
||||
placement={'right-start'}
|
||||
disablePortal={false}
|
||||
style={{zIndex: 99999}}
|
||||
keepMounted={true}>
|
||||
<Fade fullyVisible={!preview} opacity={0.2}>
|
||||
<ClickAwayListener onClickAway={() => setEdit(null)}>
|
||||
<Paper style={{padding: 10, maxWidth: 500}}>
|
||||
<Typography variant="h5">Edit</Typography>
|
||||
<DashboardEntryForm ranges={ranges} entry={entry} onChange={setEdit} disabled={preview} />
|
||||
<div style={{textAlign: 'right', display: 'flex', justifyContent: 'flex-end', paddingTop: 10}}>
|
||||
<Button
|
||||
color={'primary'}
|
||||
variant={'outlined'}
|
||||
style={{marginRight: 10, flex: 1}}
|
||||
disabled={!valid}
|
||||
onClick={() => doPreview(!preview)}>
|
||||
{preview ? 'Exit Preview' : 'Preview'}
|
||||
</Button>
|
||||
<Button
|
||||
color={'secondary'}
|
||||
variant={'outlined'}
|
||||
style={{marginRight: 10}}
|
||||
onClick={() => setEdit(null)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
color={'primary'}
|
||||
variant={'contained'}
|
||||
disabled={!valid}
|
||||
onClick={() => {
|
||||
updateEntry({
|
||||
variables: {
|
||||
entryId: entry.id,
|
||||
entryType: entry.entryType,
|
||||
title: entry.title,
|
||||
total: entry.total,
|
||||
stats: {
|
||||
tags: entry.statsSelection.tags,
|
||||
interval: entry.statsSelection.interval,
|
||||
range: entry.statsSelection.range
|
||||
? {
|
||||
from: entry.statsSelection.range.from,
|
||||
to: entry.statsSelection.range.to,
|
||||
}
|
||||
: null,
|
||||
rangeId: entry.statsSelection.rangeId,
|
||||
excludeTags: entry.statsSelection.excludeTags,
|
||||
includeTags: entry.statsSelection.includeTags,
|
||||
},
|
||||
},
|
||||
})
|
||||
.then(() => setEdit(null))
|
||||
.catch(handleError('Edit Dashboard Entry', enqueueSnackbar));
|
||||
}}>
|
||||
Save
|
||||
</Button>
|
||||
</div>
|
||||
</Paper>
|
||||
</ClickAwayListener>
|
||||
</Fade>
|
||||
</Popper>
|
||||
);
|
||||
};
|
||||
39
ui/src/dashboard/Entry/TagTooltip.tsx
Normal file
39
ui/src/dashboard/Entry/TagTooltip.tsx
Normal file
@@ -0,0 +1,39 @@
|
||||
import {TooltipProps, TooltipPayload} from 'recharts';
|
||||
import {FInterval} from './dateformat';
|
||||
import Paper from '@material-ui/core/Paper';
|
||||
import {Typography} from '@material-ui/core';
|
||||
import moment from 'moment-timezone';
|
||||
import prettyMs from 'pretty-ms';
|
||||
import * as React from 'react';
|
||||
|
||||
export const TagTooltip = ({active, payload, dateFormat, total}: TooltipProps & {dateFormat: FInterval; total: boolean}) => {
|
||||
if (active && payload) {
|
||||
const first = payload[0];
|
||||
const start = dateFormat(moment(first.payload.start));
|
||||
const end = dateFormat(moment(first.payload.end));
|
||||
|
||||
return (
|
||||
<Paper style={{padding: 10}} elevation={4}>
|
||||
{first && <Typography variant={'subtitle2'}>{start === end ? `${start}` : `${start} - ${end}`}</Typography>}
|
||||
{payload.map((entry) => (
|
||||
<Typography key={entry.name}>
|
||||
{entry.name}: {prettyMs((entry.payload.data[entry.name] as number) * 1000)}
|
||||
</Typography>
|
||||
))}
|
||||
{total ? (
|
||||
<Typography>
|
||||
Total:
|
||||
{prettyMs(sum(payload) * 1000)}
|
||||
</Typography>
|
||||
) : (
|
||||
undefined
|
||||
)}
|
||||
</Paper>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
const sum = (payload: readonly TooltipPayload[]): number =>
|
||||
payload.reduce((acc, entry) => (acc + entry.payload.data[entry.name]) as number, 0);
|
||||
1
ui/src/dashboard/Entry/colors.ts
Normal file
1
ui/src/dashboard/Entry/colors.ts
Normal file
@@ -0,0 +1 @@
|
||||
export const Colors = ['#5DA5DA', '#60BD68', '#B2912F', '#F17CB0', '#FAA43A', '#F15854', '#DECF3F', '#B276B2', '#4D4D4D'];
|
||||
21
ui/src/dashboard/Entry/dateformat.ts
Normal file
21
ui/src/dashboard/Entry/dateformat.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import {StatsInterval} from '../../gql/__generated__/globalTypes';
|
||||
import * as moment from 'moment-timezone';
|
||||
import {expectNever} from '../../utils/never';
|
||||
|
||||
export type FInterval = (date: moment.Moment) => string;
|
||||
|
||||
export const ofInterval = (interval: StatsInterval): FInterval => {
|
||||
switch (interval) {
|
||||
case StatsInterval.Weekly:
|
||||
case StatsInterval.Monthly:
|
||||
case StatsInterval.Yearly:
|
||||
return (d) => d.tz('utc').format('l');
|
||||
case StatsInterval.Hourly:
|
||||
return (d) => d.tz('utc').format('lll');
|
||||
case StatsInterval.Single:
|
||||
case StatsInterval.Daily:
|
||||
return (d) => d.tz('utc').format('dddd') + ', ' + d.format('l');
|
||||
default:
|
||||
return expectNever(interval);
|
||||
}
|
||||
};
|
||||
33
ui/src/dashboard/Entry/unit.ts
Normal file
33
ui/src/dashboard/Entry/unit.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
interface Unit {
|
||||
toUnit: (seconds: number) => number;
|
||||
short: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
const Minutes: Unit = {
|
||||
toUnit: (s) => s / 60,
|
||||
name: 'minutes',
|
||||
short: 'm',
|
||||
};
|
||||
const Hours: Unit = {
|
||||
toUnit: (s) => Minutes.toUnit(s) / 60,
|
||||
name: 'hours',
|
||||
short: 'h',
|
||||
};
|
||||
const Days: Unit = {
|
||||
toUnit: (s) => Hours.toUnit(s) / 24,
|
||||
name: 'days',
|
||||
short: 'd',
|
||||
};
|
||||
|
||||
export const ofSeconds = (seconds: number): Unit => {
|
||||
if (seconds < 2 * 60 * 60) {
|
||||
return Minutes;
|
||||
}
|
||||
|
||||
if (seconds < 30 * 60 * 60) {
|
||||
return Hours;
|
||||
}
|
||||
|
||||
return Days;
|
||||
};
|
||||
110
ui/src/devices/AddDeviceDialog.tsx
Normal file
110
ui/src/devices/AddDeviceDialog.tsx
Normal file
@@ -0,0 +1,110 @@
|
||||
import * as React from 'react';
|
||||
import Button from '@material-ui/core/Button';
|
||||
import TextField from '@material-ui/core/TextField';
|
||||
import Dialog from '@material-ui/core/Dialog';
|
||||
import DialogActions from '@material-ui/core/DialogActions';
|
||||
import DialogContent from '@material-ui/core/DialogContent';
|
||||
import DialogContentText from '@material-ui/core/DialogContentText';
|
||||
import DialogTitle from '@material-ui/core/DialogTitle';
|
||||
import * as gqlDevice from '../gql/device';
|
||||
import {MutationFetchResult} from 'react-apollo';
|
||||
import {useMutation} from '@apollo/react-hooks';
|
||||
import {CreateDevice, CreateDeviceVariables} from '../gql/__generated__/CreateDevice';
|
||||
import {useSnackbar} from 'notistack';
|
||||
import {handleError} from '../utils/errors';
|
||||
import {DeviceType} from '../gql/__generated__/globalTypes';
|
||||
import {deviceTypeToString} from './typeutils';
|
||||
import Select from '@material-ui/core/NativeSelect';
|
||||
import FormControl from '@material-ui/core/FormControl';
|
||||
import InputLabel from '@material-ui/core/InputLabel';
|
||||
|
||||
interface AddTagDialogProps {
|
||||
initialName: string;
|
||||
open: boolean;
|
||||
close: () => void;
|
||||
}
|
||||
|
||||
export const AddDeviceDialog: React.FC<AddTagDialogProps> = ({close, open, initialName}) => {
|
||||
const [token, setToken] = React.useState('');
|
||||
const [name, setName] = React.useState(initialName);
|
||||
const [deviceType, setDeviceType] = React.useState(DeviceType.NoExpiry);
|
||||
const {enqueueSnackbar} = useSnackbar();
|
||||
|
||||
const [addDevice] = useMutation<CreateDevice, CreateDeviceVariables>(gqlDevice.CreateDevice, {
|
||||
refetchQueries: [{query: gqlDevice.Devices}],
|
||||
});
|
||||
const submit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
addDevice({variables: {deviceType, name}})
|
||||
.then((result: MutationFetchResult<CreateDevice>) => {
|
||||
if (result.data && result.data.device) {
|
||||
enqueueSnackbar('Client created', {variant: 'success'});
|
||||
setToken(result.data.device.token);
|
||||
}
|
||||
})
|
||||
.catch(handleError('Create Dialog', enqueueSnackbar));
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
open={open}
|
||||
onClose={() => {
|
||||
if (token === '') {
|
||||
close();
|
||||
}
|
||||
}}
|
||||
aria-labelledby="form-dialog-title"
|
||||
fullWidth>
|
||||
{token === '' ? (
|
||||
<form onSubmit={submit} noValidate autoComplete="off">
|
||||
<DialogTitle id="form-dialog-title">Create Device</DialogTitle>
|
||||
<DialogContent>
|
||||
<DialogContentText />
|
||||
<TextField
|
||||
autoFocus
|
||||
margin="dense"
|
||||
id="name"
|
||||
label="Name"
|
||||
type="text"
|
||||
fullWidth
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
/>
|
||||
<FormControl margin={'normal'} fullWidth>
|
||||
<InputLabel>Expires after</InputLabel>
|
||||
<Select value={deviceType} onChange={(e) => setDeviceType(e.target.value as DeviceType)} fullWidth>
|
||||
<option value={DeviceType.NoExpiry}>{deviceTypeToString(DeviceType.NoExpiry)}</option>
|
||||
<option value={DeviceType.ShortExpiry}>{deviceTypeToString(DeviceType.ShortExpiry)}</option>
|
||||
<option value={DeviceType.LongExpiry}>{deviceTypeToString(DeviceType.LongExpiry)}</option>
|
||||
</Select>
|
||||
</FormControl>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={close} color="primary">
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" onClick={submit} color="primary">
|
||||
Create Device
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</form>
|
||||
) : (
|
||||
<>
|
||||
<DialogTitle id="form-dialog-title">Device Created</DialogTitle>
|
||||
<DialogContent>
|
||||
<DialogContentText>
|
||||
The device has the following authentication token, copy it and save it somewhere because you cannot
|
||||
obtain the token after closing this dialog
|
||||
<br /> <b>{token}</b>
|
||||
</DialogContentText>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={close} color="primary">
|
||||
Close
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</>
|
||||
)}
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
162
ui/src/devices/DevicesPage.tsx
Normal file
162
ui/src/devices/DevicesPage.tsx
Normal file
@@ -0,0 +1,162 @@
|
||||
import * as React from 'react';
|
||||
import Paper from '@material-ui/core/Paper';
|
||||
import {makeStyles} from '@material-ui/core/styles';
|
||||
import {useMutation, useQuery} from '@apollo/react-hooks';
|
||||
import * as gqlDevice from '../gql/device';
|
||||
import * as gqlUser from '../gql/user';
|
||||
import {Devices} from '../gql/__generated__/Devices';
|
||||
import {CenteredSpinner} from '../common/CenteredSpinner';
|
||||
import Table from '@material-ui/core/Table';
|
||||
import TableBody from '@material-ui/core/TableBody';
|
||||
import TableCell from '@material-ui/core/TableCell';
|
||||
import TableHead from '@material-ui/core/TableHead';
|
||||
import TableRow from '@material-ui/core/TableRow';
|
||||
import moment from 'moment-timezone';
|
||||
import DeleteIcon from '@material-ui/icons/Delete';
|
||||
import EditIcon from '@material-ui/icons/Edit';
|
||||
import DoneIcon from '@material-ui/icons/Done';
|
||||
import CloseIcon from '@material-ui/icons/Close';
|
||||
import IconButton from '@material-ui/core/IconButton';
|
||||
import {RemoveDevice, RemoveDeviceVariables} from '../gql/__generated__/RemoveDevice';
|
||||
import {UpdateDevice, UpdateDeviceVariables} from '../gql/__generated__/UpdateDevice';
|
||||
import {useSnackbar} from 'notistack';
|
||||
import {TextField} from '@material-ui/core';
|
||||
import Button from '@material-ui/core/Button';
|
||||
import {AddDeviceDialog} from './AddDeviceDialog';
|
||||
import {DeviceType} from '../gql/__generated__/globalTypes';
|
||||
import {deviceTypeToString} from './typeutils';
|
||||
import Select from '@material-ui/core/NativeSelect';
|
||||
|
||||
const useStyles = makeStyles((theme) => ({
|
||||
root: {
|
||||
...theme.mixins.gutters(),
|
||||
paddingTop: theme.spacing(3),
|
||||
paddingBottom: theme.spacing(3),
|
||||
textAlign: 'center',
|
||||
maxWidth: 1200,
|
||||
margin: '0 auto',
|
||||
},
|
||||
}));
|
||||
|
||||
export const DevicesPage = () => {
|
||||
const classes = useStyles();
|
||||
const {data, loading} = useQuery<Devices>(gqlDevice.Devices);
|
||||
const refetch = {refetchQueries: [{query: gqlDevice.Devices}, {query: gqlUser.CurrentUser}]};
|
||||
const {enqueueSnackbar} = useSnackbar();
|
||||
const [removeDevice] = useMutation<RemoveDevice, RemoveDeviceVariables>(gqlDevice.RemoveDevice, refetch);
|
||||
const [[editId, editName, editDeviceType], setEditing] = React.useState<[number, string, DeviceType]>([
|
||||
-1,
|
||||
'',
|
||||
DeviceType.NoExpiry,
|
||||
]);
|
||||
const [addActive, setAddActive] = React.useState(false);
|
||||
const [updateDevice] = useMutation<UpdateDevice, UpdateDeviceVariables>(gqlDevice.UpdateDevice, refetch);
|
||||
if (loading || !data || !data.currentDevice || !data.devices) {
|
||||
return <CenteredSpinner />;
|
||||
}
|
||||
|
||||
const devices = data.devices.map((device) => {
|
||||
const onClickDelete = () =>
|
||||
removeDevice({variables: {id: device.id}}).then(() => enqueueSnackbar('device deleted', {variant: 'success'}));
|
||||
const onClickSubmit = () => {
|
||||
setEditing([-1, '', DeviceType.NoExpiry]);
|
||||
updateDevice({
|
||||
variables: {
|
||||
id: editId,
|
||||
name: editName,
|
||||
deviceType: editDeviceType,
|
||||
},
|
||||
}).then(() => enqueueSnackbar('device edited', {variant: 'success'}));
|
||||
};
|
||||
const isCurrent = device.id === data.currentDevice!.id;
|
||||
const isEdited = editId === device.id;
|
||||
return (
|
||||
<TableRow key={device.id} selected={isCurrent}>
|
||||
<TableCell>{device.id}</TableCell>
|
||||
<TableCell>
|
||||
{isEdited ? (
|
||||
<TextField
|
||||
value={editName}
|
||||
onChange={(e) => setEditing([editId, e.target.value, editDeviceType])}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
onClickSubmit();
|
||||
}
|
||||
}}
|
||||
onSubmit={onClickSubmit}
|
||||
style={{minWidth: 128}}
|
||||
/>
|
||||
) : (
|
||||
device.name + (isCurrent ? ' (current)' : '')
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell title={device.createdAt}>{moment(device.createdAt).fromNow()}</TableCell>
|
||||
<TableCell title={device.type} style={{minWidth: 128}}>
|
||||
{isEdited ? (
|
||||
<Select
|
||||
value={editDeviceType}
|
||||
onChange={(e) => setEditing([editId, editName, e.target.value as DeviceType])}>
|
||||
<option value={DeviceType.NoExpiry}>{deviceTypeToString(DeviceType.NoExpiry)}</option>
|
||||
<option value={DeviceType.ShortExpiry}>{deviceTypeToString(DeviceType.ShortExpiry)}</option>
|
||||
<option value={DeviceType.LongExpiry}>{deviceTypeToString(DeviceType.LongExpiry)}</option>
|
||||
</Select>
|
||||
) : (
|
||||
deviceTypeToString(device.type)
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell title={device.activeAt}>{moment(device.activeAt).fromNow()}</TableCell>
|
||||
<TableCell align="right">
|
||||
{isEdited ? (
|
||||
<>
|
||||
<IconButton onClick={onClickSubmit} title="Save">
|
||||
<DoneIcon />
|
||||
</IconButton>
|
||||
<IconButton onClick={() => setEditing([-1, '', DeviceType.NoExpiry])} title="Cancel">
|
||||
<CloseIcon />
|
||||
</IconButton>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<IconButton onClick={() => setEditing([device.id, device.name, device.type])} title="Edit">
|
||||
<EditIcon />
|
||||
</IconButton>
|
||||
<IconButton onClick={onClickDelete} title="Delete">
|
||||
<DeleteIcon />
|
||||
</IconButton>
|
||||
</>
|
||||
)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
});
|
||||
|
||||
return (
|
||||
<Paper elevation={1} square={true} className={classes.root}>
|
||||
<Button
|
||||
color={'primary'}
|
||||
variant={'outlined'}
|
||||
size="small"
|
||||
onClick={() => setAddActive(true)}
|
||||
fullWidth
|
||||
style={{marginBottom: 10}}>
|
||||
Create Device
|
||||
</Button>
|
||||
<Table>
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableCell>ID</TableCell>
|
||||
<TableCell>Name</TableCell>
|
||||
<TableCell>Created</TableCell>
|
||||
<TableCell>Expires after</TableCell>
|
||||
<TableCell>Last Active</TableCell>
|
||||
<TableCell style={{width: 150}} />
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{addActive ? <AddDeviceDialog initialName={''} open={true} close={() => setAddActive(false)} /> : null}
|
||||
{devices}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</Paper>
|
||||
);
|
||||
};
|
||||
15
ui/src/devices/typeutils.ts
Normal file
15
ui/src/devices/typeutils.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import {DeviceType} from '../gql/__generated__/globalTypes';
|
||||
import {expectNever} from '../utils/never';
|
||||
|
||||
export const deviceTypeToString = (type: DeviceType) => {
|
||||
switch (type) {
|
||||
case DeviceType.LongExpiry:
|
||||
return 'A month of inactivity';
|
||||
case DeviceType.ShortExpiry:
|
||||
return 'An hour of inactivity';
|
||||
case DeviceType.NoExpiry:
|
||||
return 'Never';
|
||||
default:
|
||||
return expectNever(type);
|
||||
}
|
||||
};
|
||||
37
ui/src/global.css
Normal file
37
ui/src/global.css
Normal file
@@ -0,0 +1,37 @@
|
||||
#root,
|
||||
body,
|
||||
html {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
/* Allows the tag Chips to wrap lines */
|
||||
.MuiChip-label {
|
||||
white-space: normal !important;
|
||||
}
|
||||
|
||||
/* The wrapping box around the input and icon for the datetime picker */
|
||||
.time-picker .MuiInputBase-root.MuiInput-root.MuiInputBase-formControl.MuiInput-formControl.MuiInputBase-adornedEnd {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
/* The calendar icon for the datetime picker */
|
||||
.time-picker
|
||||
.MuiInputBase-root.MuiInputBase-formControl.MuiInputBase-adornedEnd
|
||||
.MuiInputAdornment-root.MuiInputAdornment-positionEnd
|
||||
.MuiButtonBase-root.MuiIconButton-root {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
}
|
||||
|
||||
/* The label for the datetime picker */
|
||||
.time-picker.MuiFormControl-root.MuiTextField-root .MuiFormLabel-root.MuiInputLabel-root.MuiInputLabel-formControl {
|
||||
margin-left: 48px;
|
||||
}
|
||||
|
||||
/* The input box for the datetime picker */
|
||||
.time-picker.MuiFormControl-root.MuiTextField-root .MuiInputBase-input.MuiInput-input.MuiInputBase-inputAdornedEnd {
|
||||
margin-left: 48px;
|
||||
/* margin-top: 8px; */
|
||||
margin-top: 12px;
|
||||
min-width: calc(100% - 48px);
|
||||
}
|
||||
157
ui/src/gql/dashboard.ts
Normal file
157
ui/src/gql/dashboard.ts
Normal file
@@ -0,0 +1,157 @@
|
||||
import {gql} from 'apollo-boost';
|
||||
|
||||
export const Dashboards = gql`
|
||||
query Dashboards {
|
||||
dashboards {
|
||||
id
|
||||
name
|
||||
ranges {
|
||||
id
|
||||
name
|
||||
editable
|
||||
range {
|
||||
from
|
||||
to
|
||||
}
|
||||
}
|
||||
items {
|
||||
id
|
||||
title
|
||||
total
|
||||
entryType
|
||||
statsSelection {
|
||||
range {
|
||||
from
|
||||
to
|
||||
}
|
||||
rangeId
|
||||
interval
|
||||
tags
|
||||
excludeTags {
|
||||
key
|
||||
value
|
||||
}
|
||||
includeTags {
|
||||
key
|
||||
value
|
||||
}
|
||||
}
|
||||
pos {
|
||||
mobile {
|
||||
w
|
||||
h
|
||||
x
|
||||
y
|
||||
minH
|
||||
minW
|
||||
}
|
||||
desktop {
|
||||
w
|
||||
h
|
||||
x
|
||||
y
|
||||
minH
|
||||
minW
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
export const RemoveDashboard = gql`
|
||||
mutation RemoveDashboard($id: Int!) {
|
||||
removeDashboard(id: $id) {
|
||||
id
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
export const UpdateDashboard = gql`
|
||||
mutation UpdateDashboard($id: Int!, $name: String!) {
|
||||
updateDashboard(id: $id, name: $name) {
|
||||
id
|
||||
}
|
||||
}
|
||||
`;
|
||||
export const CreateDashboard = gql`
|
||||
mutation CreateDashboard($name: String!) {
|
||||
createDashboard(name: $name) {
|
||||
id
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
export const UpdatePos = gql`
|
||||
mutation UpdatePos($entryId: Int!, $pos: InputResponsiveDashboardEntryPos!) {
|
||||
updateDashboardEntry(entryId: $entryId, pos: $pos) {
|
||||
id
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
export const UpdateDashboardEntry = gql`
|
||||
mutation UpdateDashboardEntry(
|
||||
$entryId: Int!
|
||||
$entryType: EntryType!
|
||||
$title: String!
|
||||
$total: Boolean!
|
||||
$stats: InputStatsSelection!
|
||||
) {
|
||||
updateDashboardEntry(entryId: $entryId, entryType: $entryType, title: $title, total: $total, stats: $stats) {
|
||||
id
|
||||
}
|
||||
}
|
||||
`;
|
||||
export const AddDashboardEntry = gql`
|
||||
mutation AddDashboardEntry(
|
||||
$dashboardId: Int!
|
||||
$entryType: EntryType!
|
||||
$title: String!
|
||||
$total: Boolean!
|
||||
$stats: InputStatsSelection!
|
||||
$pos: InputResponsiveDashboardEntryPos
|
||||
) {
|
||||
addDashboardEntry(
|
||||
dashboardId: $dashboardId
|
||||
entryType: $entryType
|
||||
title: $title
|
||||
total: $total
|
||||
stats: $stats
|
||||
pos: $pos
|
||||
) {
|
||||
id
|
||||
}
|
||||
}
|
||||
`;
|
||||
export const RemoveDashboardEntry = gql`
|
||||
mutation RemoveDashboardEntry($id: Int!) {
|
||||
removeDashboardEntry(id: $id) {
|
||||
id
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
export const RemoveDashboardRange = gql`
|
||||
mutation RemoveDashboardRange($rangeId: Int!) {
|
||||
removeDashboardRange(rangeId: $rangeId) {
|
||||
id
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
export const UpdateDashboardRange = gql`
|
||||
mutation UpdateDashboardRange($rangeId: Int!, $range: InputNamedDateRange!) {
|
||||
updateDashboardRange(rangeId: $rangeId, range: $range) {
|
||||
name
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
export const AddDashboardRange = gql`
|
||||
mutation AddDashboardRange($dashboardId: Int!, $range: InputNamedDateRange!) {
|
||||
addDashboardRange(dashboardId: $dashboardId, range: $range) {
|
||||
name
|
||||
}
|
||||
}
|
||||
`;
|
||||
38
ui/src/gql/device.ts
Normal file
38
ui/src/gql/device.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import {gql} from 'apollo-boost';
|
||||
|
||||
export const Devices = gql`
|
||||
query Devices {
|
||||
devices {
|
||||
id
|
||||
name
|
||||
type
|
||||
createdAt
|
||||
activeAt
|
||||
}
|
||||
currentDevice {
|
||||
id
|
||||
}
|
||||
}
|
||||
`;
|
||||
export const RemoveDevice = gql`
|
||||
mutation RemoveDevice($id: Int!) {
|
||||
removeDevice(id: $id) {
|
||||
id
|
||||
}
|
||||
}
|
||||
`;
|
||||
export const UpdateDevice = gql`
|
||||
mutation UpdateDevice($id: Int!, $name: String!, $deviceType: DeviceType!) {
|
||||
updateDevice(id: $id, name: $name, type: $deviceType) {
|
||||
id
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
export const CreateDevice = gql`
|
||||
mutation CreateDevice($name: String!, $deviceType: DeviceType!) {
|
||||
device: createDevice(name: $name, type: $deviceType) {
|
||||
token
|
||||
}
|
||||
}
|
||||
`;
|
||||
41
ui/src/gql/settings.ts
Normal file
41
ui/src/gql/settings.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
import {gql} from 'apollo-boost';
|
||||
import {useQuery} from '@apollo/react-hooks';
|
||||
import {Settings as SettingsQueryResponse} from './__generated__/Settings';
|
||||
import {DateLocale, Theme, WeekDay, DateTimeInputStyle} from './__generated__/globalTypes';
|
||||
import {stripTypename} from '../utils/strip';
|
||||
|
||||
export const Settings = gql`
|
||||
query Settings {
|
||||
userSettings {
|
||||
theme
|
||||
dateLocale
|
||||
firstDayOfTheWeek
|
||||
dateTimeInputStyle
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
export const SetSettings = gql`
|
||||
mutation SetSettings($settings: InputUserSettings!) {
|
||||
setUserSettings(settings: $settings) {
|
||||
theme
|
||||
dateTimeInputStyle
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
const defaultSettings = {
|
||||
theme: Theme.GruvboxDark,
|
||||
dateLocale: DateLocale.American,
|
||||
firstDayOfTheWeek: WeekDay.Monday,
|
||||
dateTimeInputStyle: DateTimeInputStyle.Fancy,
|
||||
} as const;
|
||||
|
||||
export const useSettings = (): {done: boolean} & Omit<SettingsQueryResponse['userSettings'], '__typename'> => {
|
||||
const data = useQuery<SettingsQueryResponse>(Settings);
|
||||
|
||||
if (data.loading || data.error || !data.data) {
|
||||
return {...defaultSettings, done: false};
|
||||
}
|
||||
return {done: true, ...stripTypename(data.data.userSettings)};
|
||||
};
|
||||
28
ui/src/gql/statistics.ts
Normal file
28
ui/src/gql/statistics.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import {gql} from 'apollo-boost';
|
||||
|
||||
export const Stats = gql`
|
||||
query Stats($ranges: [Range!], $tags: [String!], $excludeTags: [InputTimeSpanTag!], $requireTags: [InputTimeSpanTag!]) {
|
||||
stats(ranges: $ranges, tags: $tags, excludeTags: $excludeTags, requireTags: $requireTags) {
|
||||
start
|
||||
end
|
||||
entries {
|
||||
key
|
||||
value
|
||||
timeSpendInSeconds
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
export const Stats2 = gql`
|
||||
query Stats2($now: Time!, $stats: InputStatsSelection!) {
|
||||
stats: stats2(now: $now, stats: $stats) {
|
||||
start
|
||||
end
|
||||
entries {
|
||||
key
|
||||
value
|
||||
timeSpendInSeconds
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
52
ui/src/gql/tags.ts
Normal file
52
ui/src/gql/tags.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
import {gql} from 'apollo-boost';
|
||||
|
||||
export const SuggestTag = gql`
|
||||
query SuggestTag($query: String!) {
|
||||
tags: suggestTag(query: $query) {
|
||||
color
|
||||
key
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
export const Tags = gql`
|
||||
query Tags {
|
||||
tags {
|
||||
color
|
||||
key
|
||||
usages
|
||||
}
|
||||
}
|
||||
`;
|
||||
export const SuggestTagValue = gql`
|
||||
query SuggestTagValue($tag: String!, $query: String!) {
|
||||
values: suggestTagValue(key: $tag, query: $query)
|
||||
}
|
||||
`;
|
||||
|
||||
export const AddTag = gql`
|
||||
mutation AddTag($name: String!, $color: String!) {
|
||||
createTag(key: $name, color: $color) {
|
||||
color
|
||||
key
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
export const UpdateTag = gql`
|
||||
mutation UpdateTag($key: String!, $newKey: String, $color: String!) {
|
||||
updateTag(key: $key, newKey: $newKey, color: $color) {
|
||||
color
|
||||
key
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
export const RemoveTag = gql`
|
||||
mutation RemoveTag($key: String!) {
|
||||
removeTag(key: $key) {
|
||||
color
|
||||
key
|
||||
}
|
||||
}
|
||||
`;
|
||||
134
ui/src/gql/timeSpan.ts
Normal file
134
ui/src/gql/timeSpan.ts
Normal file
@@ -0,0 +1,134 @@
|
||||
import {gql} from 'apollo-boost';
|
||||
|
||||
export const Trackers = gql`
|
||||
query Trackers {
|
||||
timers {
|
||||
id
|
||||
start
|
||||
end
|
||||
tags {
|
||||
key
|
||||
value
|
||||
}
|
||||
oldStart
|
||||
note
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
export const TimeSpansInRange = gql`
|
||||
query TimeSpansInRange($start: Time!, $end: Time!) {
|
||||
timeSpans(fromInclusive: $start, toInclusive: $end) {
|
||||
timeSpans {
|
||||
id
|
||||
start
|
||||
end
|
||||
tags {
|
||||
key
|
||||
value
|
||||
}
|
||||
oldStart
|
||||
note
|
||||
}
|
||||
cursor {
|
||||
startId
|
||||
offset
|
||||
pageSize
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
export const TimeSpans = gql`
|
||||
query TimeSpans($cursor: InputCursor) {
|
||||
timeSpans(cursor: $cursor) @connection(key: "AllTimeSpans") {
|
||||
timeSpans {
|
||||
id
|
||||
start
|
||||
end
|
||||
tags {
|
||||
key
|
||||
value
|
||||
}
|
||||
oldStart
|
||||
note
|
||||
}
|
||||
cursor {
|
||||
startId
|
||||
offset
|
||||
pageSize
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
export const StartTimer = gql`
|
||||
mutation StartTimer($start: Time!, $tags: [InputTimeSpanTag!], $note: String!) {
|
||||
createTimeSpan(start: $start, tags: $tags, note: $note) {
|
||||
id
|
||||
start
|
||||
end
|
||||
tags {
|
||||
key
|
||||
value
|
||||
}
|
||||
oldStart
|
||||
note
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
export const StopTimer = gql`
|
||||
mutation StopTimer($id: Int!, $end: Time!) {
|
||||
stopTimeSpan(id: $id, end: $end) {
|
||||
id
|
||||
start
|
||||
end
|
||||
tags {
|
||||
key
|
||||
value
|
||||
}
|
||||
oldStart
|
||||
note
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
export const AddTimeSpan = gql`
|
||||
mutation AddTimeSpan($start: Time!, $end: Time!, $tags: [InputTimeSpanTag!], $note: String!) {
|
||||
createTimeSpan(start: $start, end: $end, tags: $tags, note: $note) {
|
||||
id
|
||||
start
|
||||
end
|
||||
tags {
|
||||
key
|
||||
value
|
||||
}
|
||||
oldStart
|
||||
note
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
export const UpdateTimeSpan = gql`
|
||||
mutation UpdateTimeSpan($id: Int!, $start: Time!, $end: Time, $tags: [InputTimeSpanTag!], $oldStart: Time, $note: String!) {
|
||||
updateTimeSpan(id: $id, start: $start, end: $end, tags: $tags, oldStart: $oldStart, note: $note) {
|
||||
id
|
||||
start
|
||||
end
|
||||
tags {
|
||||
key
|
||||
value
|
||||
}
|
||||
oldStart
|
||||
note
|
||||
}
|
||||
}
|
||||
`;
|
||||
export const RemoveTimeSpan = gql`
|
||||
mutation RemoveTimeSpan($id: Int!) {
|
||||
removeTimeSpan(id: $id) {
|
||||
id
|
||||
}
|
||||
}
|
||||
`;
|
||||
72
ui/src/gql/user.ts
Normal file
72
ui/src/gql/user.ts
Normal file
@@ -0,0 +1,72 @@
|
||||
import {gql} from 'apollo-boost';
|
||||
|
||||
export const CurrentUser = gql`
|
||||
query CurrentUser {
|
||||
user: currentUser {
|
||||
name
|
||||
admin
|
||||
id
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
export const Login = gql`
|
||||
mutation Login($name: String!, $pass: String!, $deviceType: DeviceType!) {
|
||||
login(username: $name, pass: $pass, deviceName: "web ui", type: $deviceType, cookie: true) {
|
||||
user {
|
||||
id
|
||||
name
|
||||
admin
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
export const Logout = gql`
|
||||
mutation Logout {
|
||||
user: removeCurrentDevice {
|
||||
name
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
export const Users = gql`
|
||||
query Users {
|
||||
users {
|
||||
id
|
||||
name
|
||||
admin
|
||||
}
|
||||
currentUser {
|
||||
id
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
export const RemoveUser = gql`
|
||||
mutation RemoveUser($id: Int!) {
|
||||
removeUser(id: $id) {
|
||||
id
|
||||
name
|
||||
admin
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
export const UpdateUser = gql`
|
||||
mutation UpdateUser($id: Int!, $name: String!, $admin: Boolean!, $pass: String) {
|
||||
updateUser(id: $id, name: $name, admin: $admin, pass: $pass) {
|
||||
id
|
||||
name
|
||||
admin
|
||||
}
|
||||
}
|
||||
`;
|
||||
export const CreateUser = gql`
|
||||
mutation CreateUser($name: String!, $admin: Boolean!, $pass: String!) {
|
||||
createUser(name: $name, admin: $admin, pass: $pass) {
|
||||
id
|
||||
name
|
||||
admin
|
||||
}
|
||||
}
|
||||
`;
|
||||
79
ui/src/gql/utils.ts
Normal file
79
ui/src/gql/utils.ts
Normal file
@@ -0,0 +1,79 @@
|
||||
import {DataProxy} from 'apollo-cache';
|
||||
import {TimeSpans} from './__generated__/TimeSpans';
|
||||
import * as gqlTimeSpan from './timeSpan';
|
||||
import moment from 'moment';
|
||||
import {AddTimeSpan_createTimeSpan} from './__generated__/AddTimeSpan';
|
||||
import {TimeSpansInRange, TimeSpansInRangeVariables} from './__generated__/TimeSpansInRange';
|
||||
import {Trackers} from './__generated__/Trackers';
|
||||
import {StopTimer} from './__generated__/StopTimer';
|
||||
|
||||
export const addTimeSpanToCache = (cache: DataProxy, ts: AddTimeSpan_createTimeSpan) => {
|
||||
let oldTimeSpans: TimeSpans | null = null;
|
||||
try {
|
||||
oldTimeSpans = cache.readQuery<TimeSpans>({query: gqlTimeSpan.TimeSpans});
|
||||
} catch {}
|
||||
if (!oldTimeSpans) {
|
||||
return;
|
||||
}
|
||||
cache.writeQuery<TimeSpans>({
|
||||
query: gqlTimeSpan.TimeSpans,
|
||||
data: {
|
||||
timeSpans: {
|
||||
__typename: 'PagedTimeSpans',
|
||||
timeSpans: oldTimeSpans.timeSpans.timeSpans
|
||||
.concat([ts])
|
||||
.sort((a, b) => moment(b.start).unix() - moment(a.start).unix()),
|
||||
cursor: oldTimeSpans.timeSpans.cursor,
|
||||
},
|
||||
},
|
||||
});
|
||||
};
|
||||
export const addTimeSpanInRangeToCache = (cache: DataProxy, ts: AddTimeSpan_createTimeSpan, vars: TimeSpansInRangeVariables) => {
|
||||
const oldTimeSpans = cache.readQuery<TimeSpansInRange>({query: gqlTimeSpan.TimeSpansInRange, variables: vars});
|
||||
if (!oldTimeSpans) {
|
||||
return;
|
||||
}
|
||||
cache.writeQuery<TimeSpansInRange>({
|
||||
query: gqlTimeSpan.TimeSpansInRange,
|
||||
variables: vars,
|
||||
data: {
|
||||
timeSpans: {
|
||||
__typename: 'PagedTimeSpans',
|
||||
timeSpans: oldTimeSpans.timeSpans.timeSpans
|
||||
.concat([ts])
|
||||
.sort((a, b) => moment(b.start).unix() - moment(a.start).unix()),
|
||||
cursor: oldTimeSpans.timeSpans.cursor,
|
||||
},
|
||||
},
|
||||
});
|
||||
};
|
||||
export const removeFromTrackersCache = (cache: DataProxy, data: StopTimer) => {
|
||||
const oldTrackers = cache.readQuery<Trackers>({query: gqlTimeSpan.Trackers});
|
||||
if (!oldTrackers || !data || !data.stopTimeSpan) {
|
||||
return;
|
||||
}
|
||||
cache.writeQuery<Trackers>({
|
||||
query: gqlTimeSpan.Trackers,
|
||||
data: {
|
||||
timers: (oldTrackers.timers || []).filter((tracker) => tracker.id !== data.stopTimeSpan!.id),
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
export const removeFromTimeSpanInRangeCache = (cache: DataProxy, id: number, vars: TimeSpansInRangeVariables) => {
|
||||
const old = cache.readQuery<TimeSpansInRange>({query: gqlTimeSpan.TimeSpansInRange, variables: vars});
|
||||
if (!old) {
|
||||
return;
|
||||
}
|
||||
cache.writeQuery<TimeSpansInRange>({
|
||||
query: gqlTimeSpan.TimeSpansInRange,
|
||||
variables: vars,
|
||||
data: {
|
||||
timeSpans: {
|
||||
__typename: 'PagedTimeSpans',
|
||||
timeSpans: old.timeSpans.timeSpans.filter((ts) => ts.id !== id),
|
||||
cursor: old.timeSpans.cursor,
|
||||
},
|
||||
},
|
||||
});
|
||||
};
|
||||
16
ui/src/gql/version.ts
Normal file
16
ui/src/gql/version.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import {gql} from 'apollo-boost';
|
||||
import {Version as VersionResponse} from './__generated__/Version';
|
||||
|
||||
export const Version = gql`
|
||||
query Version {
|
||||
version {
|
||||
name
|
||||
commit
|
||||
buildDate
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
export const VersionDefault: VersionResponse = {
|
||||
version: {__typename: 'Version', commit: 'unknown', buildDate: 'unknown', name: 'vUnknown'},
|
||||
};
|
||||
8
ui/src/index.tsx
Normal file
8
ui/src/index.tsx
Normal file
@@ -0,0 +1,8 @@
|
||||
import React from 'react';
|
||||
import ReactDOM from 'react-dom';
|
||||
import {Root} from './Root';
|
||||
import 'moment/locale/de';
|
||||
import 'moment/locale/en-au';
|
||||
import 'moment/locale/en-gb';
|
||||
|
||||
ReactDOM.render(<Root />, document.getElementById('root'));
|
||||
83
ui/src/login/LoginForm.tsx
Normal file
83
ui/src/login/LoginForm.tsx
Normal file
@@ -0,0 +1,83 @@
|
||||
import * as React from 'react';
|
||||
import TextField from '@material-ui/core/TextField';
|
||||
import Button from '@material-ui/core/Button';
|
||||
import {useSnackbar} from 'notistack';
|
||||
import {handleError} from '../utils/errors';
|
||||
import * as gqlUser from '../gql/user';
|
||||
import {Login, LoginVariables} from '../gql/__generated__/Login';
|
||||
import {useMutation} from '@apollo/react-hooks';
|
||||
import {Checkbox} from '@material-ui/core';
|
||||
import {DeviceType} from '../gql/__generated__/globalTypes';
|
||||
import FormControlLabel from '@material-ui/core/FormControlLabel';
|
||||
import makeStyles from '@material-ui/core/styles/makeStyles';
|
||||
import {Settings as SettingsGQL} from '../gql/settings';
|
||||
|
||||
const useStyles = makeStyles((theme) => ({
|
||||
button: {
|
||||
marginTop: theme.spacing(1),
|
||||
},
|
||||
}));
|
||||
|
||||
export const LoginForm = () => {
|
||||
const classes = useStyles();
|
||||
const [login] = useMutation<Login, LoginVariables>(gqlUser.Login, {
|
||||
update: (cache, {data}) => {
|
||||
cache.writeQuery({query: gqlUser.CurrentUser, data: {user: data && data.login && data.login.user}});
|
||||
},
|
||||
refetchQueries: [{query: SettingsGQL}],
|
||||
});
|
||||
|
||||
const {enqueueSnackbar} = useSnackbar();
|
||||
const [username, setUsername] = React.useState('');
|
||||
const [password, setPassword] = React.useState('');
|
||||
const [remember, setRemember] = React.useState(false);
|
||||
const submit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
login({
|
||||
variables: {
|
||||
name: username,
|
||||
pass: password,
|
||||
deviceType: remember ? DeviceType.LongExpiry : DeviceType.ShortExpiry,
|
||||
},
|
||||
})
|
||||
.then(() => enqueueSnackbar('Login successful', {variant: 'success'}))
|
||||
.catch(handleError('Login failed', enqueueSnackbar));
|
||||
};
|
||||
return (
|
||||
<form noValidate autoComplete="off" onSubmit={submit}>
|
||||
<TextField
|
||||
required
|
||||
margin="dense"
|
||||
variant="outlined"
|
||||
label="username"
|
||||
autoFocus
|
||||
fullWidth
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
/>
|
||||
<TextField
|
||||
required
|
||||
type="password"
|
||||
margin="dense"
|
||||
variant="outlined"
|
||||
label="password"
|
||||
fullWidth
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
/>
|
||||
<FormControlLabel
|
||||
style={{float: 'right'}}
|
||||
control={<Checkbox checked={remember} onChange={(e) => setRemember(e.target.checked)} />}
|
||||
label="Remember Me"
|
||||
/>
|
||||
<Button
|
||||
type="submit"
|
||||
className={classes.button}
|
||||
fullWidth
|
||||
size="large"
|
||||
onClick={submit}
|
||||
variant="contained"
|
||||
color="primary">
|
||||
Login
|
||||
</Button>
|
||||
</form>
|
||||
);
|
||||
};
|
||||
52
ui/src/login/LoginPage.tsx
Normal file
52
ui/src/login/LoginPage.tsx
Normal file
@@ -0,0 +1,52 @@
|
||||
import * as React from 'react';
|
||||
import Typography from '@material-ui/core/Typography';
|
||||
import Grid from '@material-ui/core/Grid';
|
||||
import {LoginForm} from './LoginForm';
|
||||
import Link from '@material-ui/core/Link';
|
||||
import {DefaultPaper} from '../common/DefaultPaper';
|
||||
import * as gqlVersion from '../gql/version';
|
||||
import {useQuery} from '@apollo/react-hooks';
|
||||
import {Version} from '../gql/__generated__/Version';
|
||||
import makeStyles from '@material-ui/core/styles/makeStyles';
|
||||
|
||||
const useStyles = makeStyles(() => ({
|
||||
footerLink: {
|
||||
margin: '0 2px',
|
||||
},
|
||||
}));
|
||||
|
||||
export const LoginPage = () => {
|
||||
const classes = useStyles();
|
||||
const {data: {version = gqlVersion.VersionDefault.version} = gqlVersion.VersionDefault} = useQuery<Version>(
|
||||
gqlVersion.Version
|
||||
);
|
||||
return (
|
||||
<Grid container={true} direction="row" alignItems="center" justify="center" style={{height: '95%'}}>
|
||||
<Grid item>
|
||||
<DefaultPaper>
|
||||
<Typography variant="h1" component="h1" gutterBottom={true}>
|
||||
traggo
|
||||
</Typography>
|
||||
<LoginForm />
|
||||
</DefaultPaper>
|
||||
</Grid>
|
||||
<div style={{position: 'absolute', bottom: 10, display: 'flex'}}>
|
||||
<Typography variant="subtitle1" component="span" className={classes.footerLink}>
|
||||
<Link href="https://github.com/traggo/server">Source Code</Link>
|
||||
</Typography>
|
||||
<Typography variant="subtitle1" component="span" className={classes.footerLink}>
|
||||
|
|
||||
</Typography>
|
||||
<Typography variant="subtitle1" component="span" className={classes.footerLink}>
|
||||
<Link href="https://github.com/traggo/server/issues">Bug Tracker</Link>
|
||||
</Typography>
|
||||
<Typography variant="subtitle1" component="span" className={classes.footerLink}>
|
||||
|
|
||||
</Typography>
|
||||
<Typography variant="subtitle1" component="span" className={classes.footerLink}>
|
||||
{version.name}@{version.commit.slice(0, 8)}
|
||||
</Typography>
|
||||
</div>
|
||||
</Grid>
|
||||
);
|
||||
};
|
||||
16
ui/src/provider/ApolloProvider.tsx
Normal file
16
ui/src/provider/ApolloProvider.tsx
Normal file
@@ -0,0 +1,16 @@
|
||||
import ApolloClient from 'apollo-boost';
|
||||
import * as React from 'react';
|
||||
import {ApolloProvider as Provider} from 'react-apollo';
|
||||
import {ApolloProvider as ApolloProviderHooks} from '@apollo/react-hooks';
|
||||
|
||||
const client = new ApolloClient({
|
||||
uri: './graphql',
|
||||
});
|
||||
|
||||
export const ApolloProvider: React.FC = ({children}) => {
|
||||
return (
|
||||
<Provider client={client}>
|
||||
<ApolloProviderHooks client={client}>{children}</ApolloProviderHooks>
|
||||
</Provider>
|
||||
);
|
||||
};
|
||||
30
ui/src/provider/SnackbarProvider.tsx
Normal file
30
ui/src/provider/SnackbarProvider.tsx
Normal file
@@ -0,0 +1,30 @@
|
||||
import * as React from 'react';
|
||||
import {SnackbarProvider as Provider} from 'notistack';
|
||||
import {makeStyles} from '@material-ui/core/styles';
|
||||
|
||||
const useStyles = makeStyles(() => ({
|
||||
error: {
|
||||
background: '#E53935',
|
||||
color: '#fff',
|
||||
},
|
||||
warning: {
|
||||
background: '#d35400',
|
||||
color: '#fff',
|
||||
},
|
||||
info: {
|
||||
background: '#2980b9',
|
||||
color: '#fff',
|
||||
},
|
||||
}));
|
||||
|
||||
export const SnackbarProvider: React.FC = ({children}) => {
|
||||
const classes = useStyles();
|
||||
return (
|
||||
<Provider
|
||||
maxSnack={3}
|
||||
classes={{variantError: classes.error, variantWarning: classes.warning, variantInfo: classes.info}}
|
||||
autoHideDuration={3500}>
|
||||
{children}
|
||||
</Provider>
|
||||
);
|
||||
};
|
||||
141
ui/src/provider/ThemeProvider.tsx
Normal file
141
ui/src/provider/ThemeProvider.tsx
Normal file
@@ -0,0 +1,141 @@
|
||||
import * as React from 'react';
|
||||
import {MuiThemeProvider, Theme} from '@material-ui/core/styles';
|
||||
import createMuiTheme from '@material-ui/core/styles/createMuiTheme';
|
||||
import CssBaseline from '@material-ui/core/CssBaseline';
|
||||
import {useSettings} from '../gql/settings';
|
||||
import {Theme as SettingTheme} from '../gql/__generated__/globalTypes';
|
||||
|
||||
const themes: Record<SettingTheme, Theme> = {
|
||||
[SettingTheme.GruvboxDark]: createMuiTheme({
|
||||
overrides: {
|
||||
MuiLink: {
|
||||
root: {
|
||||
color: '#458588',
|
||||
},
|
||||
},
|
||||
MuiIconButton: {
|
||||
root: {
|
||||
color: 'inherit',
|
||||
},
|
||||
},
|
||||
MuiListItemIcon: {
|
||||
root: {
|
||||
color: 'inherit',
|
||||
},
|
||||
},
|
||||
MuiToolbar: {
|
||||
root: {
|
||||
background: '#a89984',
|
||||
},
|
||||
},
|
||||
},
|
||||
palette: {
|
||||
background: {
|
||||
default: '#282828',
|
||||
paper: '#32302f',
|
||||
},
|
||||
text: {
|
||||
primary: '#fbf1d4',
|
||||
},
|
||||
primary: {
|
||||
main: '#a89984',
|
||||
},
|
||||
secondary: {
|
||||
main: '#f44336',
|
||||
},
|
||||
type: 'dark',
|
||||
},
|
||||
}),
|
||||
[SettingTheme.GruvboxLight]: createMuiTheme({
|
||||
overrides: {
|
||||
MuiLink: {
|
||||
root: {
|
||||
color: '#458588',
|
||||
},
|
||||
},
|
||||
MuiIconButton: {
|
||||
root: {
|
||||
color: 'inherit',
|
||||
},
|
||||
},
|
||||
MuiListItemIcon: {
|
||||
root: {
|
||||
color: 'inherit',
|
||||
},
|
||||
},
|
||||
MuiToolbar: {
|
||||
root: {
|
||||
background: '#7c6f64',
|
||||
},
|
||||
},
|
||||
},
|
||||
palette: {
|
||||
background: {
|
||||
default: '#fbf1c7',
|
||||
paper: '#f9f5d7',
|
||||
},
|
||||
text: {
|
||||
primary: '#282828',
|
||||
},
|
||||
primary: {
|
||||
main: '#7c6f64',
|
||||
},
|
||||
secondary: {
|
||||
main: '#f44336',
|
||||
},
|
||||
type: 'light',
|
||||
},
|
||||
}),
|
||||
[SettingTheme.MaterialLight]: createMuiTheme({
|
||||
overrides: {
|
||||
MuiLink: {
|
||||
root: {
|
||||
color: '#2980b9',
|
||||
},
|
||||
},
|
||||
MuiIconButton: {
|
||||
root: {
|
||||
color: 'inherit',
|
||||
},
|
||||
},
|
||||
},
|
||||
palette: {
|
||||
background: {default: '#eeeeee'},
|
||||
primary: {
|
||||
main: '#455a64',
|
||||
},
|
||||
secondary: {
|
||||
main: '#f44336',
|
||||
},
|
||||
type: 'light',
|
||||
},
|
||||
}),
|
||||
[SettingTheme.MaterialDark]: createMuiTheme({
|
||||
overrides: {
|
||||
MuiLink: {
|
||||
root: {
|
||||
color: '#3498db',
|
||||
},
|
||||
},
|
||||
},
|
||||
palette: {
|
||||
primary: {
|
||||
main: '#455a64',
|
||||
},
|
||||
secondary: {
|
||||
main: '#f44336',
|
||||
},
|
||||
type: 'dark',
|
||||
},
|
||||
}),
|
||||
};
|
||||
|
||||
export const ThemeProvider: React.FC = ({children}) => {
|
||||
const {theme} = useSettings();
|
||||
return (
|
||||
<MuiThemeProvider theme={themes[theme] || themes[SettingTheme.GruvboxDark]}>
|
||||
<CssBaseline />
|
||||
{children}
|
||||
</MuiThemeProvider>
|
||||
);
|
||||
};
|
||||
82
ui/src/provider/UserSettingsProvider.tsx
Normal file
82
ui/src/provider/UserSettingsProvider.tsx
Normal file
@@ -0,0 +1,82 @@
|
||||
import * as React from 'react';
|
||||
import {useSettings} from '../gql/settings';
|
||||
import {CenteredSpinner} from '../common/CenteredSpinner';
|
||||
import moment, {LocaleSpecification} from 'moment';
|
||||
import {DateLocale, WeekDay} from '../gql/__generated__/globalTypes';
|
||||
import {expectNever} from '../utils/never';
|
||||
|
||||
const setLocale = (locale: DateLocale, spec: LocaleSpecification) => {
|
||||
switch (locale) {
|
||||
case DateLocale.American:
|
||||
moment.locale('en', spec);
|
||||
return;
|
||||
case DateLocale.American24h:
|
||||
moment.locale('en', {
|
||||
...spec,
|
||||
longDateFormat: {
|
||||
LTS: 'HH:mm:ss',
|
||||
LT: 'HH:mm',
|
||||
L: 'MM/DD/YYYY',
|
||||
LL: 'MMMM D, YYYY',
|
||||
LLL: 'MMMM D, YYYY HH:mm',
|
||||
LLLL: 'dddd, MMMM D, YYYY HH:mm',
|
||||
},
|
||||
});
|
||||
return;
|
||||
case DateLocale.Australian:
|
||||
moment.locale('en-au', spec);
|
||||
return;
|
||||
case DateLocale.British:
|
||||
moment.locale('en-gb', spec);
|
||||
return;
|
||||
case DateLocale.German:
|
||||
moment.locale('de', spec);
|
||||
return;
|
||||
default:
|
||||
expectNever(locale);
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
const weekDayToMoment = (s: WeekDay): number => {
|
||||
switch (s) {
|
||||
case WeekDay.Sunday:
|
||||
return 0;
|
||||
case WeekDay.Monday:
|
||||
return 1;
|
||||
case WeekDay.Tuesday:
|
||||
return 2;
|
||||
case WeekDay.Wednesday:
|
||||
return 3;
|
||||
case WeekDay.Thursday:
|
||||
return 4;
|
||||
case WeekDay.Friday:
|
||||
return 5;
|
||||
case WeekDay.Saturday:
|
||||
return 6;
|
||||
default:
|
||||
return expectNever(s);
|
||||
}
|
||||
};
|
||||
|
||||
export const BootUserSettings: React.FC = ({children}): React.ReactElement => {
|
||||
const {done, firstDayOfTheWeek, dateLocale} = useSettings();
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!done) {
|
||||
return;
|
||||
}
|
||||
setLocale(dateLocale, {
|
||||
week: {
|
||||
dow: weekDayToMoment(firstDayOfTheWeek),
|
||||
doy: moment.localeData(moment.locale()).firstDayOfYear(),
|
||||
},
|
||||
});
|
||||
}, [dateLocale, firstDayOfTheWeek, done]);
|
||||
|
||||
if (!done) {
|
||||
return <CenteredSpinner />;
|
||||
}
|
||||
|
||||
return <>{children}</>;
|
||||
};
|
||||
1
ui/src/react-app-env.d.ts
vendored
Normal file
1
ui/src/react-app-env.d.ts
vendored
Normal file
@@ -0,0 +1 @@
|
||||
/// <reference types="react-scripts" />
|
||||
153
ui/src/setting/SettingsPage.tsx
Normal file
153
ui/src/setting/SettingsPage.tsx
Normal file
@@ -0,0 +1,153 @@
|
||||
import * as React from 'react';
|
||||
import makeStyles from '@material-ui/core/styles/makeStyles';
|
||||
import {Paper} from '@material-ui/core';
|
||||
import {SetSettings as SetSettingsGQL, Settings as SettingsGQL, useSettings} from '../gql/settings';
|
||||
import {useMutation} from '@apollo/react-hooks';
|
||||
import {SetSettings, SetSettingsVariables} from '../gql/__generated__/SetSettings';
|
||||
import FormControl from '@material-ui/core/FormControl';
|
||||
import InputLabel from '@material-ui/core/InputLabel';
|
||||
import Select from '@material-ui/core/NativeSelect/NativeSelect';
|
||||
import {DateLocale, Theme, WeekDay, DateTimeInputStyle} from '../gql/__generated__/globalTypes';
|
||||
import {useSnackbar} from 'notistack';
|
||||
import {handleError} from '../utils/errors';
|
||||
|
||||
const useStyles = makeStyles((theme) => ({
|
||||
root: {
|
||||
...theme.mixins.gutters(),
|
||||
paddingTop: theme.spacing(1),
|
||||
paddingBottom: theme.spacing(3),
|
||||
maxWidth: 500,
|
||||
margin: '0 auto',
|
||||
},
|
||||
}));
|
||||
|
||||
export const SettingsPage: React.FC = () => {
|
||||
const classes = useStyles();
|
||||
const {done, ...settings} = useSettings();
|
||||
const {enqueueSnackbar} = useSnackbar();
|
||||
const [setSettings] = useMutation<SetSettings, SetSettingsVariables>(SetSettingsGQL, {
|
||||
refetchQueries: [{query: SettingsGQL}],
|
||||
});
|
||||
return (
|
||||
<Paper elevation={1} className={classes.root}>
|
||||
<FormControl margin={'normal'} fullWidth>
|
||||
<InputLabel>Date Locale</InputLabel>
|
||||
<Select
|
||||
fullWidth
|
||||
value={settings.dateLocale}
|
||||
onChange={(e) => {
|
||||
setSettings({
|
||||
variables: {
|
||||
settings: {
|
||||
...settings,
|
||||
dateLocale: e.target.value as DateLocale,
|
||||
},
|
||||
},
|
||||
})
|
||||
.then(() => {
|
||||
enqueueSnackbar('date locale changed', {
|
||||
variant: 'success',
|
||||
});
|
||||
enqueueSnackbar('a reload of the page is required for the new date locale to fully function', {
|
||||
variant: 'info',
|
||||
preventDuplicate: true,
|
||||
persist: true,
|
||||
});
|
||||
})
|
||||
.catch(handleError('set date locale', enqueueSnackbar));
|
||||
}}>
|
||||
{Object.values(DateLocale).map((type) => (
|
||||
<option key={type} value={type}>
|
||||
{type}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
</FormControl>
|
||||
<FormControl margin={'normal'} fullWidth>
|
||||
<InputLabel>Theme</InputLabel>
|
||||
<Select
|
||||
fullWidth
|
||||
value={settings.theme}
|
||||
onChange={(e) => {
|
||||
setSettings({variables: {settings: {...settings, theme: e.target.value as Theme}}})
|
||||
.then(() =>
|
||||
enqueueSnackbar('theme changed', {
|
||||
variant: 'success',
|
||||
})
|
||||
)
|
||||
.catch(handleError('set theme', enqueueSnackbar));
|
||||
}}>
|
||||
{Object.values(Theme).map((type) => (
|
||||
<option key={type} value={type}>
|
||||
{type}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
</FormControl>
|
||||
<FormControl margin={'normal'} fullWidth>
|
||||
<InputLabel>First day of the week</InputLabel>
|
||||
<Select
|
||||
fullWidth
|
||||
value={settings.firstDayOfTheWeek}
|
||||
onChange={(e) => {
|
||||
setSettings({
|
||||
variables: {
|
||||
settings: {
|
||||
...settings,
|
||||
firstDayOfTheWeek: e.target.value as WeekDay,
|
||||
},
|
||||
},
|
||||
})
|
||||
.then(() =>
|
||||
enqueueSnackbar('first day of the week changed', {
|
||||
variant: 'success',
|
||||
})
|
||||
)
|
||||
.catch(handleError('set first day of the week', enqueueSnackbar));
|
||||
}}>
|
||||
{[
|
||||
WeekDay.Sunday,
|
||||
WeekDay.Monday,
|
||||
WeekDay.Tuesday,
|
||||
WeekDay.Wednesday,
|
||||
WeekDay.Thursday,
|
||||
WeekDay.Friday,
|
||||
WeekDay.Saturday,
|
||||
].map((type) => (
|
||||
<option key={type} value={type}>
|
||||
{type}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
</FormControl>
|
||||
<FormControl margin={'normal'} fullWidth>
|
||||
<InputLabel>Datetime input style</InputLabel>
|
||||
<Select
|
||||
fullWidth
|
||||
value={settings.dateTimeInputStyle}
|
||||
onChange={(e) => {
|
||||
setSettings({
|
||||
variables: {
|
||||
settings: {
|
||||
...settings,
|
||||
dateTimeInputStyle: e.target.value as DateTimeInputStyle,
|
||||
},
|
||||
},
|
||||
})
|
||||
.then(() =>
|
||||
enqueueSnackbar('datetime input style changed', {
|
||||
variant: 'success',
|
||||
})
|
||||
)
|
||||
.catch(handleError('set datetime input style', enqueueSnackbar));
|
||||
}}>
|
||||
{[DateTimeInputStyle.Fancy, DateTimeInputStyle.Native].map((type) => (
|
||||
<option key={type} value={type}>
|
||||
{type}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
</FormControl>
|
||||
</Paper>
|
||||
);
|
||||
};
|
||||
81
ui/src/tag/AddTagDialog.tsx
Normal file
81
ui/src/tag/AddTagDialog.tsx
Normal file
@@ -0,0 +1,81 @@
|
||||
import * as React from 'react';
|
||||
import Button from '@material-ui/core/Button';
|
||||
import TextField from '@material-ui/core/TextField';
|
||||
import Dialog from '@material-ui/core/Dialog';
|
||||
import DialogActions from '@material-ui/core/DialogActions';
|
||||
import DialogContent from '@material-ui/core/DialogContent';
|
||||
import DialogContentText from '@material-ui/core/DialogContentText';
|
||||
import DialogTitle from '@material-ui/core/DialogTitle';
|
||||
import {SliderPicker} from 'react-color';
|
||||
import {InputLabel} from '@material-ui/core';
|
||||
import FormControl from '@material-ui/core/FormControl';
|
||||
import {MutationFetchResult} from 'react-apollo';
|
||||
import {AddTag, AddTagVariables} from '../gql/__generated__/AddTag';
|
||||
import * as gqlTags from '../gql/tags';
|
||||
import {TagSelectorEntry} from './tagSelectorEntry';
|
||||
import {useMutation} from '@apollo/react-hooks';
|
||||
import {useSnackbar} from 'notistack';
|
||||
import {handleError} from '../utils/errors';
|
||||
|
||||
interface AddTagDialogProps {
|
||||
initialName: string;
|
||||
open: boolean;
|
||||
close: () => void;
|
||||
onAdded?: (tag: TagSelectorEntry['tag']) => void;
|
||||
}
|
||||
|
||||
export const AddTagDialog: React.FC<AddTagDialogProps> = ({close, open, initialName, onAdded = () => {}}) => {
|
||||
const [name, setName] = React.useState(initialName);
|
||||
const [color, setColor] = React.useState('#e6b3b3');
|
||||
const {enqueueSnackbar} = useSnackbar();
|
||||
|
||||
const [addTag] = useMutation<AddTag, AddTagVariables>(gqlTags.AddTag, {refetchQueries: [{query: gqlTags.Tags}]});
|
||||
const submit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
addTag({variables: {name, color}})
|
||||
.then((result: MutationFetchResult<AddTag> | void) => {
|
||||
close();
|
||||
if (result && result.data && result.data.createTag) {
|
||||
onAdded(result.data.createTag);
|
||||
}
|
||||
})
|
||||
.catch(handleError('Add Tag', enqueueSnackbar));
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onClose={close} aria-labelledby="form-dialog-title" fullWidth>
|
||||
<form onSubmit={submit} noValidate autoComplete="off">
|
||||
<DialogTitle id="form-dialog-title">Create Tag</DialogTitle>
|
||||
<DialogContent>
|
||||
<DialogContentText />
|
||||
<TextField
|
||||
autoFocus
|
||||
margin="dense"
|
||||
id="name"
|
||||
label="Name"
|
||||
type="text"
|
||||
fullWidth
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
/>
|
||||
<FormControl fullWidth margin="dense">
|
||||
<InputLabel htmlFor="color-picker" shrink={true}>
|
||||
Color
|
||||
</InputLabel>
|
||||
<div id="color-picker" style={{marginTop: 25}}>
|
||||
<SliderPicker onChange={(c) => setColor(c.hex)} color={color} />
|
||||
</div>
|
||||
</FormControl>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={close} color="primary">
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" onClick={submit} color="primary">
|
||||
Create Tag
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</form>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
25
ui/src/tag/FormTagSelector.tsx
Normal file
25
ui/src/tag/FormTagSelector.tsx
Normal file
@@ -0,0 +1,25 @@
|
||||
import React from 'react';
|
||||
import FormControl from '@material-ui/core/FormControl';
|
||||
import Box from '@material-ui/core/Box';
|
||||
import InputLabel from '@material-ui/core/InputLabel';
|
||||
import {TagSelector, TagSelectorProps} from './TagSelector';
|
||||
|
||||
interface FormTagSelectorProps extends TagSelectorProps {
|
||||
label: string;
|
||||
required?: boolean;
|
||||
}
|
||||
|
||||
export const FormTagSelector = ({label, required = false, ...props}: FormTagSelectorProps) => {
|
||||
return (
|
||||
<Box mt={1}>
|
||||
<FormControl fullWidth required>
|
||||
<Box pt={2}>
|
||||
<InputLabel shrink> {label} </InputLabel>
|
||||
<Box className="MuiInput-underline">
|
||||
<TagSelector {...props} />
|
||||
</Box>
|
||||
</Box>
|
||||
</FormControl>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
161
ui/src/tag/TagPage.tsx
Normal file
161
ui/src/tag/TagPage.tsx
Normal file
@@ -0,0 +1,161 @@
|
||||
import * as React from 'react';
|
||||
import Paper from '@material-ui/core/Paper';
|
||||
import {makeStyles} from '@material-ui/core/styles';
|
||||
import {useMutation, useQuery} from '@apollo/react-hooks';
|
||||
import * as gqlTag from '../gql/tags';
|
||||
import * as gqlDashboard from '../gql/dashboard';
|
||||
import {CenteredSpinner} from '../common/CenteredSpinner';
|
||||
import Table from '@material-ui/core/Table';
|
||||
import TableBody from '@material-ui/core/TableBody';
|
||||
import TableCell from '@material-ui/core/TableCell';
|
||||
import TableHead from '@material-ui/core/TableHead';
|
||||
import TableRow from '@material-ui/core/TableRow';
|
||||
import DeleteIcon from '@material-ui/icons/Delete';
|
||||
import EditIcon from '@material-ui/icons/Edit';
|
||||
import DoneIcon from '@material-ui/icons/Done';
|
||||
import CloseIcon from '@material-ui/icons/Close';
|
||||
import IconButton from '@material-ui/core/IconButton';
|
||||
import {useSnackbar} from 'notistack';
|
||||
import {TextField} from '@material-ui/core';
|
||||
import Button from '@material-ui/core/Button';
|
||||
import {Tags} from '../gql/__generated__/Tags';
|
||||
import {RemoveTag, RemoveTagVariables} from '../gql/__generated__/RemoveTag';
|
||||
import {UpdateTag, UpdateTagVariables} from '../gql/__generated__/UpdateTag';
|
||||
import {AddTagDialog} from './AddTagDialog';
|
||||
import {SliderPicker} from 'react-color';
|
||||
import {TagChip} from '../common/TagChip';
|
||||
import {handleError} from '../utils/errors';
|
||||
import {ConfirmDialog} from '../common/ConfirmDialog';
|
||||
|
||||
const useStyles = makeStyles((theme) => ({
|
||||
root: {
|
||||
...theme.mixins.gutters(),
|
||||
paddingTop: theme.spacing(3),
|
||||
paddingBottom: theme.spacing(3),
|
||||
textAlign: 'center',
|
||||
maxWidth: 1200,
|
||||
margin: '0 auto',
|
||||
},
|
||||
}));
|
||||
|
||||
export const TagPage = () => {
|
||||
const classes = useStyles();
|
||||
const {data, loading} = useQuery<Tags>(gqlTag.Tags);
|
||||
const [removeTagConfirm, setRemoveTagConfirm] = React.useState('');
|
||||
const refetch = {refetchQueries: [{query: gqlTag.Tags}, {query: gqlDashboard.Dashboards}]};
|
||||
const {enqueueSnackbar} = useSnackbar();
|
||||
const [removeTag] = useMutation<RemoveTag, RemoveTagVariables>(gqlTag.RemoveTag, refetch);
|
||||
const [[editKey, editKeyNew, editColor], setEditing] = React.useState<[string, string, string]>(['', '', '']);
|
||||
const [addActive, setAddActive] = React.useState(false);
|
||||
const [updateTag] = useMutation<UpdateTag, UpdateTagVariables>(gqlTag.UpdateTag, refetch);
|
||||
if (loading || !data || !data.tags) {
|
||||
return <CenteredSpinner />;
|
||||
}
|
||||
const onClickDelete = () => {
|
||||
return removeTag({variables: {key: removeTagConfirm}})
|
||||
.then(() => enqueueSnackbar('tag deleted', {variant: 'success'}))
|
||||
.catch(handleError('Delete Tag', enqueueSnackbar));
|
||||
};
|
||||
|
||||
const tags = data.tags.map((tag) => {
|
||||
const onClickSubmit = () => {
|
||||
setEditing(['', '', '']);
|
||||
updateTag({
|
||||
variables: {
|
||||
key: editKey,
|
||||
newKey: editKeyNew,
|
||||
color: editColor,
|
||||
},
|
||||
})
|
||||
.then(() => enqueueSnackbar('tag edited', {variant: 'success'}))
|
||||
.catch(handleError('Edit Tag', enqueueSnackbar));
|
||||
};
|
||||
const isEdited = editKey === tag.key;
|
||||
return (
|
||||
<TableRow key={tag.key}>
|
||||
<TableCell>
|
||||
{isEdited ? (
|
||||
<TextField
|
||||
value={editKeyNew}
|
||||
onChange={(e) => setEditing([editKey, e.target.value, editColor])}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
onClickSubmit();
|
||||
}
|
||||
}}
|
||||
onSubmit={onClickSubmit}
|
||||
style={{minWidth: 128}}
|
||||
/>
|
||||
) : (
|
||||
tag.key
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell style={{minWidth: 128}}>
|
||||
{isEdited ? (
|
||||
<SliderPicker onChange={(c) => setEditing([editKey, editKeyNew, c.hex])} color={editColor} />
|
||||
) : (
|
||||
<TagChip label={tag.color} color={tag.color} />
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell align="right">{tag.usages}</TableCell>
|
||||
<TableCell align="right">
|
||||
{isEdited ? (
|
||||
<>
|
||||
<IconButton onClick={onClickSubmit} title="Save">
|
||||
<DoneIcon />
|
||||
</IconButton>
|
||||
<IconButton onClick={() => setEditing(['', '', ''])} title="Cancel">
|
||||
<CloseIcon />
|
||||
</IconButton>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<IconButton onClick={() => setEditing([tag.key, tag.key, tag.color])} title="Edit">
|
||||
<EditIcon />
|
||||
</IconButton>
|
||||
<IconButton onClick={() => setRemoveTagConfirm(tag.key)} title="Delete">
|
||||
<DeleteIcon />
|
||||
</IconButton>
|
||||
</>
|
||||
)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
});
|
||||
|
||||
return (
|
||||
<Paper elevation={1} square={true} className={classes.root}>
|
||||
<Button
|
||||
color={'primary'}
|
||||
variant={'outlined'}
|
||||
size="small"
|
||||
onClick={() => setAddActive(true)}
|
||||
fullWidth
|
||||
style={{marginBottom: 10}}>
|
||||
Create Tag
|
||||
</Button>
|
||||
<Table>
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableCell>Key</TableCell>
|
||||
<TableCell>Color</TableCell>
|
||||
<TableCell style={{width: 100}}>Usages</TableCell>
|
||||
<TableCell style={{width: 150}} />
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{addActive ? <AddTagDialog initialName={''} open={true} close={() => setAddActive(false)} /> : null}
|
||||
{tags}
|
||||
{removeTagConfirm ? (
|
||||
<ConfirmDialog
|
||||
title={`Delete Tag ${removeTagConfirm}`}
|
||||
fClose={() => setRemoveTagConfirm('')}
|
||||
fOnSubmit={onClickDelete}>
|
||||
<b>This operation cannot be undone.</b> Deleting the tag will remove all references in time spans.
|
||||
</ConfirmDialog>
|
||||
) : null}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</Paper>
|
||||
);
|
||||
};
|
||||
279
ui/src/tag/TagSelector.tsx
Normal file
279
ui/src/tag/TagSelector.tsx
Normal file
@@ -0,0 +1,279 @@
|
||||
import * as React from 'react';
|
||||
import {Tags} from '../gql/__generated__/Tags';
|
||||
import * as gqlTags from '../gql/tags';
|
||||
import {useQuery} from '@apollo/react-hooks';
|
||||
import {useError} from '../utils/errors';
|
||||
import Typography from '@material-ui/core/Typography';
|
||||
import Tooltip from '@material-ui/core/Tooltip';
|
||||
import MenuItem from '@material-ui/core/MenuItem';
|
||||
import {AddTagDialog} from './AddTagDialog';
|
||||
import {TagSelectorEntry, itemLabel, label, addValues} from './tagSelectorEntry';
|
||||
import {useSuggest} from './suggest';
|
||||
import Paper from '@material-ui/core/Paper';
|
||||
import ClickAwayListener from '@material-ui/core/ClickAwayListener';
|
||||
import Input from '@material-ui/core/Input';
|
||||
import {useStateAndDelegateWithDelayOnChange} from '../utils/hooks';
|
||||
import {TagChip} from '../common/TagChip';
|
||||
import {makeStyles, Theme} from '@material-ui/core/styles';
|
||||
|
||||
const useStyles = makeStyles((theme: Theme) => ({
|
||||
root: {
|
||||
width: '100%',
|
||||
},
|
||||
inputRoot: {display: 'flex', flexWrap: 'wrap', cursor: 'text', width: '100%'},
|
||||
inputInput: {height: 40, minWidth: 150, flexGrow: 1},
|
||||
paper: {
|
||||
position: 'absolute',
|
||||
zIndex: 1,
|
||||
marginTop: theme.spacing(1),
|
||||
},
|
||||
}));
|
||||
|
||||
export interface TagSelectorProps {
|
||||
onSelectedEntriesChanged: (entries: TagSelectorEntry[]) => void;
|
||||
selectedEntries: TagSelectorEntry[];
|
||||
dialogOpen?: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
onCtrlEnter?: () => void;
|
||||
createTags?: boolean;
|
||||
allowDuplicateKeys?: boolean;
|
||||
onlySelectKeys?: boolean;
|
||||
}
|
||||
|
||||
export const TagSelector: React.FC<TagSelectorProps> = ({
|
||||
selectedEntries,
|
||||
onSelectedEntriesChanged: setSelectedEntries,
|
||||
dialogOpen = () => {},
|
||||
onCtrlEnter,
|
||||
createTags = true,
|
||||
allowDuplicateKeys = false,
|
||||
onlySelectKeys = false,
|
||||
}) => {
|
||||
const classes = useStyles();
|
||||
const [tooltipErrorActive, tooltipError, showTooltipError] = useError(4000);
|
||||
const [open, setOpen] = React.useState(false);
|
||||
const [currentValue, setCurrentValueInternal] = React.useState('');
|
||||
const [highlightedIndex, setHighlightedIndex] = React.useState<number>(0);
|
||||
const [addDialogOpen, setAddDialogOpen] = useStateAndDelegateWithDelayOnChange<boolean>(false, dialogOpen);
|
||||
const input = React.useRef<null | HTMLDivElement>(null);
|
||||
const container = React.useRef<null | HTMLDivElement>(null);
|
||||
|
||||
const tagsResult = useQuery<Tags>(gqlTags.Tags);
|
||||
|
||||
const suggestions = useSuggest(tagsResult, currentValue, selectedEntries, onlySelectKeys, allowDuplicateKeys, createTags);
|
||||
|
||||
if (tagsResult.error || tagsResult.loading || !tagsResult.data || !tagsResult.data.tags) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const setCurrentValue = (newValue: string) => {
|
||||
if (currentValue.indexOf(' ') !== -1) {
|
||||
throw new Error('old value should never contain a space');
|
||||
}
|
||||
setHighlightedIndex(0);
|
||||
|
||||
if (newValue.indexOf(' ') !== -1) {
|
||||
const {errors, entries} = addValues(newValue, tagsResult, selectedEntries, onlySelectKeys, allowDuplicateKeys);
|
||||
|
||||
setSelectedEntries([...selectedEntries, ...entries]);
|
||||
|
||||
if (errors.length > 0) {
|
||||
if (errors.length === 1) {
|
||||
showTooltipError(errors[0].error);
|
||||
} else {
|
||||
showTooltipError('skipped tags because:\n' + errors.map((e) => e.error).join('\n'));
|
||||
}
|
||||
setCurrentValueInternal(errors[errors.length - 1].value);
|
||||
} else {
|
||||
setCurrentValueInternal('');
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
setCurrentValueInternal(newValue);
|
||||
};
|
||||
|
||||
const focusInput = () => {
|
||||
if (input.current) {
|
||||
input.current.focus();
|
||||
}
|
||||
};
|
||||
|
||||
const trySubmit = (entry: TagSelectorEntry) => {
|
||||
if (entry.tag.alreadyUsed) {
|
||||
return;
|
||||
}
|
||||
if (entry.tag.create) {
|
||||
setAddDialogOpen(true);
|
||||
return;
|
||||
}
|
||||
|
||||
focusInput();
|
||||
|
||||
if (!onlySelectKeys && !entry.value) {
|
||||
const newValue = entry.tag.key + ':';
|
||||
if (currentValue !== newValue) {
|
||||
setHighlightedIndex(0);
|
||||
setCurrentValue(newValue);
|
||||
} else {
|
||||
showTooltipError('enter a value after the colon');
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
setSelectedEntries([...selectedEntries, entry]);
|
||||
setCurrentValue('');
|
||||
return;
|
||||
};
|
||||
|
||||
const onTagClicked = (entry: TagSelectorEntry, edit: boolean) => {
|
||||
// Prevent overwriting text that's already being edited
|
||||
if (currentValue && edit) {
|
||||
showTooltipError('Input is not empty. Use ctrl+click to delete tag without editing.');
|
||||
return;
|
||||
}
|
||||
|
||||
setSelectedEntries(selectedEntries.filter((selected) => selected !== entry));
|
||||
|
||||
if (edit) {
|
||||
setCurrentValueInternal(itemLabel(entry, onlySelectKeys));
|
||||
setOpen(true);
|
||||
}
|
||||
|
||||
focusInput();
|
||||
};
|
||||
|
||||
const onKeyDown = (event: React.KeyboardEvent) => {
|
||||
if (!currentValue && selectedEntries.length && event.key === 'Backspace') {
|
||||
event.preventDefault();
|
||||
const last = selectedEntries[selectedEntries.length - 1];
|
||||
setSelectedEntries(selectedEntries.slice(0, selectedEntries.length - 1));
|
||||
setCurrentValue(event.ctrlKey ? '' : itemLabel(last, onlySelectKeys));
|
||||
}
|
||||
if (event.key === 'ArrowUp') {
|
||||
event.preventDefault();
|
||||
setHighlightedIndex(highlightedIndex === 0 ? suggestions.length - 1 : highlightedIndex - 1);
|
||||
}
|
||||
if (event.key === 'ArrowDown') {
|
||||
event.preventDefault();
|
||||
setHighlightedIndex(highlightedIndex === suggestions.length - 1 ? 0 : highlightedIndex + 1);
|
||||
}
|
||||
if (event.key === 'Enter' && highlightedIndex < suggestions.length) {
|
||||
event.preventDefault();
|
||||
if (!currentValue && event.ctrlKey && onCtrlEnter) {
|
||||
onCtrlEnter();
|
||||
} else {
|
||||
trySubmit(suggestions[highlightedIndex]);
|
||||
}
|
||||
}
|
||||
if (event.key === 'Escape' && input.current) {
|
||||
event.preventDefault();
|
||||
setOpen(false);
|
||||
}
|
||||
if (event.key === 'Tab') {
|
||||
setOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<ClickAwayListener onClickAway={() => setOpen(false)}>
|
||||
<div className={classes.root}>
|
||||
<Tooltip
|
||||
disableFocusListener
|
||||
disableHoverListener
|
||||
disableTouchListener
|
||||
open={tooltipErrorActive}
|
||||
PopperProps={{style: {zIndex: 100000}}}
|
||||
placement={'top'}
|
||||
title={
|
||||
<Typography color="inherit" style={{whiteSpace: 'pre-line'}}>
|
||||
{tooltipError}
|
||||
</Typography>
|
||||
}>
|
||||
<div ref={(ref) => (container.current = ref)} className={classes.inputRoot} onClick={focusInput}>
|
||||
{toChips(selectedEntries, onlySelectKeys, onTagClicked)}
|
||||
<Input
|
||||
margin="none"
|
||||
value={currentValue}
|
||||
inputRef={(ref) => (input.current = ref)}
|
||||
onFocus={() => setOpen(true)}
|
||||
onKeyDown={onKeyDown}
|
||||
disableUnderline={true}
|
||||
onChange={(e) => setCurrentValue(e.target.value)}
|
||||
placeholder="Enter Tags"
|
||||
className={classes.inputInput}
|
||||
/>
|
||||
</div>
|
||||
</Tooltip>
|
||||
|
||||
{open ? (
|
||||
<Paper
|
||||
className={classes.paper}
|
||||
style={{width: (container.current && container.current.clientWidth) || 300}}
|
||||
square>
|
||||
{suggestions.map((entry, index) => (
|
||||
<Item
|
||||
key={label(entry)}
|
||||
entry={entry}
|
||||
onClick={trySubmit}
|
||||
selected={index === highlightedIndex}
|
||||
onlySelectKeys={onlySelectKeys}
|
||||
/>
|
||||
))}
|
||||
</Paper>
|
||||
) : null}
|
||||
{addDialogOpen && (
|
||||
<AddTagDialog
|
||||
onAdded={(tag) => {
|
||||
const valuePart = currentValue.split(':')[1] || '';
|
||||
trySubmit({tag, value: valuePart});
|
||||
}}
|
||||
open={true}
|
||||
initialName={currentValue.split(':')[0]}
|
||||
close={() => {
|
||||
setTimeout(focusInput, 50);
|
||||
setAddDialogOpen(false);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</ClickAwayListener>
|
||||
);
|
||||
};
|
||||
|
||||
interface ItemProps {
|
||||
entry: TagSelectorEntry;
|
||||
selected: boolean;
|
||||
onlySelectKeys: boolean;
|
||||
onClick: (entry: TagSelectorEntry) => void;
|
||||
}
|
||||
|
||||
const Item: React.FC<ItemProps> = ({entry, selected, onlySelectKeys, onClick}) => {
|
||||
return (
|
||||
<MenuItem
|
||||
key={entry.tag.key}
|
||||
selected={selected}
|
||||
component="a"
|
||||
onClick={() => onClick(entry)}
|
||||
disabled={entry.tag.alreadyUsed}
|
||||
style={{
|
||||
fontWeight: selected ? 500 : 400,
|
||||
}}>
|
||||
{itemLabel(entry, onlySelectKeys)}
|
||||
</MenuItem>
|
||||
);
|
||||
};
|
||||
|
||||
const toChips = (
|
||||
entries: TagSelectorEntry[],
|
||||
onlySelectKeys: boolean,
|
||||
onClick: (entry: TagSelectorEntry, edit: boolean) => void
|
||||
) => {
|
||||
return entries.map((entry) => (
|
||||
<TagChip
|
||||
key={itemLabel(entry, onlySelectKeys)}
|
||||
label={itemLabel(entry, onlySelectKeys)}
|
||||
color={entry.tag.color}
|
||||
onClick={(e) => onClick(entry, !e.ctrlKey)}
|
||||
/>
|
||||
));
|
||||
};
|
||||
90
ui/src/tag/suggest.ts
Normal file
90
ui/src/tag/suggest.ts
Normal file
@@ -0,0 +1,90 @@
|
||||
import {Tags} from '../gql/__generated__/Tags';
|
||||
import * as gqlTags from '../gql/tags';
|
||||
import {useQuery} from '@apollo/react-hooks';
|
||||
import {SuggestTagValue, SuggestTagValueVariables} from '../gql/__generated__/SuggestTagValue';
|
||||
import {TagSelectorEntry, specialTag} from './tagSelectorEntry';
|
||||
import {QueryResult} from 'react-apollo';
|
||||
|
||||
export const useSuggest = (
|
||||
tagResult: QueryResult<Tags, {}>,
|
||||
inputValue: string,
|
||||
usedTags: TagSelectorEntry[],
|
||||
skipValue = false,
|
||||
allowDuplicateKeys = false,
|
||||
createTags = true
|
||||
): TagSelectorEntry[] => {
|
||||
const [tagKeySomeCase, tagValue] = inputValue.split(':');
|
||||
const tagKey = tagKeySomeCase.toLowerCase();
|
||||
|
||||
const exactMatch = ((tagResult.data && tagResult.data.tags) || []).find((tag) => tag.key === tagKey);
|
||||
|
||||
const valueResult = useQuery<SuggestTagValue, SuggestTagValueVariables>(gqlTags.SuggestTagValue, {
|
||||
variables: {tag: tagKey, query: tagValue},
|
||||
skip: exactMatch === undefined || skipValue,
|
||||
fetchPolicy: 'no-cache',
|
||||
});
|
||||
|
||||
const usedKeys = usedTags.map((t) => t.tag.key);
|
||||
|
||||
if (exactMatch && tagValue !== undefined && !skipValue && (allowDuplicateKeys || usedKeys.indexOf(exactMatch.key) === -1)) {
|
||||
const usedValues = usedTags.filter((t) => t.tag.key === tagKey).map((t) => t.value);
|
||||
return suggestTagValue(exactMatch, tagValue, valueResult, usedValues, createTags);
|
||||
} else {
|
||||
return suggestTag(exactMatch, tagResult, tagKey, usedKeys, allowDuplicateKeys, createTags);
|
||||
}
|
||||
};
|
||||
|
||||
const suggestTag = (
|
||||
exactMatch: TagSelectorEntry['tag'] | undefined,
|
||||
tagResult: QueryResult<Tags, {}>,
|
||||
tagKey: string,
|
||||
usedTags: string[],
|
||||
allowDuplicateKeys: boolean,
|
||||
createTags: boolean
|
||||
) => {
|
||||
if (!tagResult.data || tagResult.data.tags === null) {
|
||||
return [];
|
||||
}
|
||||
|
||||
let availableTags = (tagResult.data.tags || [])
|
||||
.filter((tag) => allowDuplicateKeys || usedTags.indexOf(tag.key) === -1)
|
||||
.filter((tag) => tag.key.indexOf(tagKey) === 0);
|
||||
|
||||
if (tagKey && !exactMatch && createTags) {
|
||||
availableTags = [specialTag(tagKey, 'new'), ...availableTags];
|
||||
}
|
||||
|
||||
if (usedTags.indexOf(tagKey) !== -1 && !allowDuplicateKeys) {
|
||||
availableTags = [specialTag(tagKey, 'used'), ...availableTags];
|
||||
}
|
||||
|
||||
return availableTags
|
||||
.sort((a, b) => b.usages - a.usages)
|
||||
.slice(0, 5)
|
||||
.map((tag) => ({tag, value: ''}));
|
||||
};
|
||||
|
||||
const suggestTagValue = (
|
||||
exactMatch: TagSelectorEntry['tag'],
|
||||
tagValue: string,
|
||||
valueResult: QueryResult<SuggestTagValue, SuggestTagValueVariables>,
|
||||
usedValues: string[],
|
||||
includeInputValueOnNoMatch: boolean
|
||||
): TagSelectorEntry[] => {
|
||||
let someValues = (valueResult.data && valueResult.data.values) || [];
|
||||
|
||||
if (includeInputValueOnNoMatch && someValues.indexOf(tagValue) === -1) {
|
||||
someValues = [tagValue, ...someValues];
|
||||
}
|
||||
|
||||
if (someValues.length === 0 && !includeInputValueOnNoMatch) {
|
||||
return [{tag: specialTag(exactMatch.key, 'no_values'), value: tagValue}];
|
||||
}
|
||||
|
||||
someValues = someValues.filter((val) => usedValues.indexOf(val) === -1);
|
||||
if (someValues.length === 0 && !includeInputValueOnNoMatch) {
|
||||
return [{tag: specialTag(exactMatch.key, 'all_values_used'), value: ''}];
|
||||
}
|
||||
|
||||
return someValues.map((val) => ({tag: exactMatch, value: val}));
|
||||
};
|
||||
141
ui/src/tag/tagSelectorEntry.ts
Normal file
141
ui/src/tag/tagSelectorEntry.ts
Normal file
@@ -0,0 +1,141 @@
|
||||
import {Tags, Tags_tags} from '../gql/__generated__/Tags';
|
||||
import {QueryResult} from 'react-apollo';
|
||||
|
||||
export interface TagInputError {
|
||||
error: string;
|
||||
value: string;
|
||||
}
|
||||
|
||||
type SpecialTagState = 'used' | 'new' | 'no_values' | 'all_values_used';
|
||||
export interface SpecialTag {
|
||||
create?: boolean;
|
||||
alreadyUsed?: boolean;
|
||||
noValues?: boolean;
|
||||
allValuesUsed?: boolean;
|
||||
}
|
||||
|
||||
export interface TagSelectorEntry {
|
||||
tag: Omit<Tags_tags, 'usages'> & SpecialTag;
|
||||
value: string;
|
||||
}
|
||||
|
||||
export interface InputTag {
|
||||
key: string;
|
||||
value: string;
|
||||
}
|
||||
|
||||
export const toInputTags = (entries: TagSelectorEntry[]): InputTag[] => {
|
||||
return entries.map((entry) => ({key: entry.tag.key, value: entry.value}));
|
||||
};
|
||||
|
||||
export const toTagSelectorEntry = (tags: Array<TagSelectorEntry['tag']>, entries: InputTag[]): TagSelectorEntry[] => {
|
||||
return entries.map(
|
||||
(timerTag): TagSelectorEntry => {
|
||||
const definition = tags.find((tag) => tag.key === timerTag.key) || specialTag(timerTag.key, 'new');
|
||||
return {
|
||||
tag: definition,
|
||||
value: timerTag.value,
|
||||
};
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
export const specialTag = (name: string, state: SpecialTagState): TagSelectorEntry['tag'] & {usages: 0} => {
|
||||
return {
|
||||
key: name,
|
||||
__typename: 'TagDefinition',
|
||||
color: 'gray',
|
||||
create: state === 'new',
|
||||
alreadyUsed: state === 'used',
|
||||
noValues: state === 'no_values',
|
||||
allValuesUsed: state === 'all_values_used',
|
||||
usages: 0,
|
||||
};
|
||||
};
|
||||
|
||||
const tryAdd = (tagsResult: QueryResult<Tags, {}>, entry: string, onlyKeys: boolean): TagSelectorEntry | TagInputError => {
|
||||
const [keySomeCase, value, ...other] = entry.split(':');
|
||||
const key = keySomeCase.toLowerCase();
|
||||
|
||||
if (other.length || !tagsResult.data || !tagsResult.data.tags) {
|
||||
return {error: `${entry} has too many colons`, value: entry};
|
||||
}
|
||||
|
||||
const foundTag = tagsResult.data.tags.find((tag) => tag.key === key);
|
||||
|
||||
if (foundTag === undefined) {
|
||||
return {error: `'${key}' does not exist`, value: entry};
|
||||
}
|
||||
|
||||
if (onlyKeys && value) {
|
||||
return {error: `'${key}' has a value, but this field doesn't allow them`, value: entry};
|
||||
}
|
||||
|
||||
if (!onlyKeys && !value) {
|
||||
return {error: `'${key}' requires a value`, value: entry};
|
||||
}
|
||||
|
||||
return {tag: foundTag, value: value || ''};
|
||||
};
|
||||
interface EntriesAndErrors {
|
||||
errors: TagInputError[];
|
||||
entries: TagSelectorEntry[];
|
||||
usedTags: string[];
|
||||
}
|
||||
|
||||
const groupAndCheckExistence = (onlyKeys: boolean, allowDuplicateKeys: boolean) => {
|
||||
return (a: EntriesAndErrors, entry: TagInputError | TagSelectorEntry) => {
|
||||
if ('tag' in entry) {
|
||||
if (allowDuplicateKeys || a.usedTags.indexOf(entry.tag.key) === -1) {
|
||||
a.entries = [...a.entries, entry];
|
||||
a.usedTags = [...a.usedTags, entry.tag.key];
|
||||
} else {
|
||||
a.errors = [...a.errors, {value: itemLabel(entry, onlyKeys), error: `'${entry.tag.key}' is already defined`}];
|
||||
}
|
||||
} else {
|
||||
a.errors = [...a.errors, entry];
|
||||
}
|
||||
return a;
|
||||
};
|
||||
};
|
||||
|
||||
export const addValues = (
|
||||
newValue: string,
|
||||
tagsResult: QueryResult<Tags, {}>,
|
||||
selectedEntries: TagSelectorEntry[],
|
||||
onlyKeys: boolean,
|
||||
allowDuplicateKeys: boolean
|
||||
): EntriesAndErrors => {
|
||||
return newValue
|
||||
.split(/\s+/)
|
||||
.filter((entry) => entry)
|
||||
.map((entry) => tryAdd(tagsResult, entry, onlyKeys))
|
||||
.reduce(groupAndCheckExistence(onlyKeys, allowDuplicateKeys), {
|
||||
errors: [],
|
||||
entries: [],
|
||||
usedTags: selectedEntries.map((entry) => entry.tag.key),
|
||||
});
|
||||
};
|
||||
|
||||
export const itemLabel = (tag: TagSelectorEntry, onlyShowKey = false) => {
|
||||
if (tag.tag.create) {
|
||||
return `Create tag '${tag.tag.key}'`;
|
||||
}
|
||||
if (tag.tag.alreadyUsed) {
|
||||
return `Tag '${tag.tag.key}' is already defined`;
|
||||
}
|
||||
if (tag.tag.noValues) {
|
||||
return `Unkown value '${tag.value}' of tag '${tag.tag.key}'`;
|
||||
}
|
||||
if (tag.tag.allValuesUsed) {
|
||||
return `All values of tag '${tag.tag.key}' are used`;
|
||||
}
|
||||
if (onlyShowKey) {
|
||||
return tag.tag.key;
|
||||
}
|
||||
return label(tag);
|
||||
};
|
||||
|
||||
export const label = (tag: TagSelectorEntry) => {
|
||||
return `${tag.tag.key}:${tag.value}`;
|
||||
};
|
||||
44
ui/src/timespan/ActiveTrackers.tsx
Normal file
44
ui/src/timespan/ActiveTrackers.tsx
Normal file
@@ -0,0 +1,44 @@
|
||||
import * as React from 'react';
|
||||
import {useQuery} from '@apollo/react-hooks';
|
||||
import * as gqlTimeSpan from '../gql/timeSpan';
|
||||
import * as gqlTag from '../gql/tags';
|
||||
import {Trackers} from '../gql/__generated__/Trackers';
|
||||
import {Tags} from '../gql/__generated__/Tags';
|
||||
import {TimeSpan} from './TimeSpan';
|
||||
import {toTimeSpanProps} from './timespanutils';
|
||||
import {Typography} from '@material-ui/core';
|
||||
|
||||
export const ActiveTrackers = () => {
|
||||
const trackersResult = useQuery<Trackers>(gqlTimeSpan.Trackers, {fetchPolicy: 'cache-and-network'});
|
||||
const tagsResult = useQuery<Tags>(gqlTag.Tags, {fetchPolicy: 'cache-and-network'});
|
||||
const values = React.useMemo(() => {
|
||||
if (
|
||||
trackersResult.error ||
|
||||
trackersResult.loading ||
|
||||
!trackersResult.data ||
|
||||
trackersResult.data.timers === null ||
|
||||
tagsResult.error ||
|
||||
tagsResult.loading ||
|
||||
!tagsResult.data ||
|
||||
tagsResult.data.tags === null
|
||||
) {
|
||||
return [];
|
||||
}
|
||||
return toTimeSpanProps(trackersResult.data.timers, tagsResult.data.tags);
|
||||
}, [tagsResult, trackersResult]);
|
||||
|
||||
if (!values.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Typography align="center" variant="h5" style={{marginTop: 10}}>
|
||||
Active Timers
|
||||
</Typography>
|
||||
{values.map((value) => {
|
||||
return <TimeSpan key={value.id} {...value} />;
|
||||
})}
|
||||
</>
|
||||
);
|
||||
};
|
||||
22
ui/src/timespan/DailyPage.tsx
Normal file
22
ui/src/timespan/DailyPage.tsx
Normal file
@@ -0,0 +1,22 @@
|
||||
import * as React from 'react';
|
||||
import {Tracker} from './Tracker';
|
||||
import {ActiveTrackers} from './ActiveTrackers';
|
||||
import {DoneTrackers} from './DoneTrackers';
|
||||
import {TagSelectorEntry} from '../tag/tagSelectorEntry';
|
||||
import {RefreshTimeSpans} from './RefreshTimespans';
|
||||
|
||||
export const DailyPage = () => {
|
||||
const [selectedEntries, setSelectedEntries] = React.useState<TagSelectorEntry[]>([]);
|
||||
return (
|
||||
<div style={{margin: '1px auto', maxWidth: 1000}}>
|
||||
<Tracker selectedEntries={selectedEntries} onSelectedEntriesChanged={setSelectedEntries} />
|
||||
<ActiveTrackers />
|
||||
<DoneTrackers
|
||||
addTagsToTracker={
|
||||
selectedEntries.length === 0 ? (entries) => setSelectedEntries(selectedEntries.concat(entries)) : undefined
|
||||
}
|
||||
/>
|
||||
<RefreshTimeSpans />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
149
ui/src/timespan/DoneTrackers.tsx
Normal file
149
ui/src/timespan/DoneTrackers.tsx
Normal file
@@ -0,0 +1,149 @@
|
||||
import * as React from 'react';
|
||||
import {useQuery} from '@apollo/react-hooks';
|
||||
import * as gqlTimeSpan from '../gql/timeSpan';
|
||||
import * as gqlTag from '../gql/tags';
|
||||
import {TimeSpan, TimeSpanProps} from './TimeSpan';
|
||||
import {Tags} from '../gql/__generated__/Tags';
|
||||
import useInterval from '@rooks/use-interval';
|
||||
import moment from 'moment';
|
||||
import {TimeSpans, TimeSpansVariables} from '../gql/__generated__/TimeSpans';
|
||||
import {Typography} from '@material-ui/core';
|
||||
import {GroupedTimeSpanProps, toGroupedTimeSpanProps} from './timespanutils';
|
||||
import {TagSelectorEntry} from '../tag/tagSelectorEntry';
|
||||
import ReactInfinite from 'react-infinite';
|
||||
import {isSameDate} from '../utils/time';
|
||||
|
||||
interface DoneTrackersProps {
|
||||
addTagsToTracker?: (entries: TagSelectorEntry[]) => void;
|
||||
}
|
||||
|
||||
export const DoneTrackers: React.FC<DoneTrackersProps> = ({addTagsToTracker}) => {
|
||||
const trackersResult = useQuery<TimeSpans, TimeSpansVariables>(gqlTimeSpan.TimeSpans, {
|
||||
variables: {cursor: {pageSize: 30}},
|
||||
});
|
||||
const loading = React.useRef(false);
|
||||
const tagsResult = useQuery<Tags>(gqlTag.Tags);
|
||||
const [infiniteLoading, setInfiniteLoading] = React.useState(false);
|
||||
const [currentDate, setCurrentDate] = React.useState(moment());
|
||||
const [heights, setHeights] = React.useState<Record<string, number>>({});
|
||||
useInterval(
|
||||
() => {
|
||||
if (!isSameDate(currentDate, moment())) {
|
||||
setCurrentDate(moment());
|
||||
}
|
||||
},
|
||||
1000,
|
||||
true
|
||||
);
|
||||
|
||||
const fetchMore = () => {
|
||||
if (!trackersResult || !trackersResult.data || trackersResult.loading || loading.current) {
|
||||
return;
|
||||
}
|
||||
loading.current = true;
|
||||
const {offset, pageSize, startId} = trackersResult.data.timeSpans.cursor;
|
||||
trackersResult
|
||||
.fetchMore({
|
||||
variables: {
|
||||
cursor: {
|
||||
startId,
|
||||
offset,
|
||||
pageSize,
|
||||
},
|
||||
},
|
||||
updateQuery: (prev, {fetchMoreResult}): TimeSpans => {
|
||||
if (!fetchMoreResult) {
|
||||
return prev;
|
||||
}
|
||||
|
||||
return {
|
||||
timeSpans: {
|
||||
__typename: 'PagedTimeSpans',
|
||||
timeSpans: [...prev.timeSpans.timeSpans, ...fetchMoreResult.timeSpans.timeSpans],
|
||||
cursor: fetchMoreResult.timeSpans.cursor,
|
||||
},
|
||||
};
|
||||
},
|
||||
})
|
||||
.then(() => {
|
||||
loading.current = false;
|
||||
return setInfiniteLoading(false);
|
||||
})
|
||||
.catch(() => {
|
||||
loading.current = false;
|
||||
return setInfiniteLoading(false);
|
||||
});
|
||||
};
|
||||
|
||||
const values: GroupedTimeSpanProps = React.useMemo(() => {
|
||||
if (
|
||||
trackersResult.error ||
|
||||
trackersResult.loading ||
|
||||
!trackersResult.data ||
|
||||
trackersResult.data.timeSpans === null ||
|
||||
tagsResult.error ||
|
||||
tagsResult.loading ||
|
||||
!tagsResult.data ||
|
||||
tagsResult.data.tags === null
|
||||
) {
|
||||
return [];
|
||||
}
|
||||
return toGroupedTimeSpanProps(trackersResult.data.timeSpans.timeSpans, tagsResult.data.tags, currentDate);
|
||||
}, [trackersResult, tagsResult, currentDate]);
|
||||
|
||||
return (
|
||||
<div style={{marginTop: 10}}>
|
||||
<ReactInfinite
|
||||
key={1}
|
||||
useWindowAsScrollContainer
|
||||
preloadBatchSize={window.innerHeight}
|
||||
onInfiniteLoad={fetchMore}
|
||||
isInfiniteLoading={infiniteLoading}
|
||||
infiniteLoadBeginEdgeOffset={2000}
|
||||
loadingSpinnerDelegate={
|
||||
<Typography align={'center'} variant={'h5'}>
|
||||
.. loading time spans ..
|
||||
</Typography>
|
||||
}
|
||||
elementHeight={values.map((m) => heights[m.key] || 500)}>
|
||||
{values.map(({key, timeSpans}) => {
|
||||
return (
|
||||
<DatedTimeSpans
|
||||
key={key}
|
||||
name={key}
|
||||
timeSpans={timeSpans}
|
||||
addTagsToTracker={addTagsToTracker}
|
||||
setHeight={setHeights}
|
||||
height={heights[key] || 500}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</ReactInfinite>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const DatedTimeSpans: React.FC<{
|
||||
name: string;
|
||||
setHeight: (cb: (height: Record<string, number>) => Record<string, number>) => void;
|
||||
height: number;
|
||||
timeSpans: TimeSpanProps[];
|
||||
} & DoneTrackersProps> = ({name, timeSpans, addTagsToTracker, setHeight, height}) => {
|
||||
const ref = React.useRef<HTMLDivElement | null>();
|
||||
React.useEffect(() => {
|
||||
const currentHeight = ref.current && ref.current.getBoundingClientRect().height;
|
||||
if (currentHeight != null && currentHeight !== height) {
|
||||
setHeight((old) => ({...old, [name]: currentHeight}));
|
||||
}
|
||||
}, [ref, name, setHeight, height]);
|
||||
return (
|
||||
<div key={name} ref={(r) => (ref.current = r)}>
|
||||
<Typography key={name} align="center" variant={'h5'}>
|
||||
{name}
|
||||
</Typography>
|
||||
{timeSpans.map((timeSpanProps) => (
|
||||
<TimeSpan key={timeSpanProps.id} {...timeSpanProps} addTagsToTracker={addTagsToTracker} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
61
ui/src/timespan/RefreshTimespans.tsx
Normal file
61
ui/src/timespan/RefreshTimespans.tsx
Normal file
@@ -0,0 +1,61 @@
|
||||
import * as React from 'react';
|
||||
import {useQuery} from '@apollo/react-hooks';
|
||||
import {TimeSpans, TimeSpansVariables} from '../gql/__generated__/TimeSpans';
|
||||
import * as gqlTimeSpan from '../gql/timeSpan';
|
||||
import {useSnackbar} from 'notistack';
|
||||
import {isSameDate} from '../utils/time';
|
||||
import moment from 'moment';
|
||||
import {Fab, Zoom} from '@material-ui/core';
|
||||
import RefreshIcon from '@material-ui/icons/Refresh';
|
||||
|
||||
export const RefreshTimeSpans: React.FC = () => {
|
||||
const {refetch, data} = useQuery<TimeSpans, TimeSpansVariables>(gqlTimeSpan.TimeSpans, {
|
||||
variables: {cursor: {pageSize: 30}},
|
||||
});
|
||||
const {enqueueSnackbar, closeSnackbar} = useSnackbar();
|
||||
const hasMovedEntries =
|
||||
data &&
|
||||
data.timeSpans &&
|
||||
data.timeSpans.timeSpans.some(({start, oldStart}) => !isSameDate(moment(start), oldStart ? moment(oldStart) : undefined));
|
||||
React.useEffect(() => {
|
||||
if (hasMovedEntries) {
|
||||
const id = enqueueSnackbar('Some messages where moved, use the refresh button to reorder the timespans', {
|
||||
variant: 'info',
|
||||
persist: true,
|
||||
preventDuplicate: true,
|
||||
});
|
||||
return () => {
|
||||
if (id) {
|
||||
closeSnackbar(id);
|
||||
}
|
||||
};
|
||||
}
|
||||
return () => {};
|
||||
}, [hasMovedEntries, enqueueSnackbar, closeSnackbar]);
|
||||
|
||||
if (!hasMovedEntries) {
|
||||
return <></>;
|
||||
}
|
||||
|
||||
return (
|
||||
<Zoom
|
||||
in={true}
|
||||
timeout={50}
|
||||
style={{
|
||||
transitionDelay: `5ms`,
|
||||
}}
|
||||
unmountOnExit>
|
||||
<Fab
|
||||
aria-label={'refresh'}
|
||||
onClick={() => {
|
||||
refetch({cursor: {pageSize: 30}}).then(() => {
|
||||
enqueueSnackbar('Refreshed messages', {variant: 'success'});
|
||||
});
|
||||
}}
|
||||
style={{position: 'fixed', bottom: '30px', right: '30px', zIndex: 100000}}
|
||||
color="primary">
|
||||
<RefreshIcon />
|
||||
</Fab>
|
||||
</Zoom>
|
||||
);
|
||||
};
|
||||
363
ui/src/timespan/TimeSpan.tsx
Normal file
363
ui/src/timespan/TimeSpan.tsx
Normal file
@@ -0,0 +1,363 @@
|
||||
import * as React from 'react';
|
||||
import {TagSelectorEntry, toInputTags} from '../tag/tagSelectorEntry';
|
||||
import {TagSelector} from '../tag/TagSelector';
|
||||
import moment from 'moment';
|
||||
import Paper from '@material-ui/core/Paper';
|
||||
import {DateTimeSelector} from '../common/DateTimeSelector';
|
||||
import {Button, TextField, Typography, makeStyles} from '@material-ui/core';
|
||||
import {inUserTz} from './timeutils';
|
||||
import {useMutation} from '@apollo/react-hooks';
|
||||
import {StopTimer, StopTimerVariables} from '../gql/__generated__/StopTimer';
|
||||
import * as gqlTimeSpan from '../gql/timeSpan';
|
||||
import {UpdateTimeSpan, UpdateTimeSpanVariables} from '../gql/__generated__/UpdateTimeSpan';
|
||||
import IconButton from '@material-ui/core/IconButton';
|
||||
import {MoreVert} from '@material-ui/icons';
|
||||
import Menu from '@material-ui/core/Menu';
|
||||
import MenuItem from '@material-ui/core/MenuItem';
|
||||
import {RemoveTimeSpan, RemoveTimeSpanVariables} from '../gql/__generated__/RemoveTimeSpan';
|
||||
import {useStateAndDelegateWithDelayOnChange} from '../utils/hooks';
|
||||
import {TimeSpans} from '../gql/__generated__/TimeSpans';
|
||||
import {isSameDate} from '../utils/time';
|
||||
import {Trackers} from '../gql/__generated__/Trackers';
|
||||
import {addTimeSpanToCache, removeFromTrackersCache} from '../gql/utils';
|
||||
import {StartTimer, StartTimerVariables} from '../gql/__generated__/StartTimer';
|
||||
import {RelativeTime, RelativeToNow} from '../common/RelativeTime';
|
||||
|
||||
interface Range {
|
||||
from: moment.Moment;
|
||||
to?: moment.Moment;
|
||||
}
|
||||
|
||||
export interface TimeSpanProps {
|
||||
id: number;
|
||||
range: Range & {oldFrom?: moment.Moment};
|
||||
initialTags: TagSelectorEntry[];
|
||||
note: string;
|
||||
dateSelectorOpen?: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
rangeChange?: (r: Range) => void;
|
||||
deleted?: () => void;
|
||||
stopped?: () => void;
|
||||
continued?: () => void;
|
||||
addTagsToTracker?: (tags: TagSelectorEntry[]) => void;
|
||||
elevation?: number;
|
||||
}
|
||||
|
||||
const useStyles = makeStyles(() => ({
|
||||
innerTimespan: {
|
||||
position: 'relative',
|
||||
width: '100%',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
textAlign: 'center',
|
||||
'@media (max-width: 750px)': {
|
||||
flexDirection: 'column',
|
||||
},
|
||||
},
|
||||
tagInput: {
|
||||
width: '100%',
|
||||
flex: '1',
|
||||
marginRight: 10,
|
||||
'@media (max-width: 750px)': {
|
||||
display: 'flex',
|
||||
width: 'calc(100% - 48px)',
|
||||
marginRight: '48px',
|
||||
},
|
||||
},
|
||||
timeSelection: {
|
||||
display: 'inline-flex',
|
||||
'@media (max-width: 750px)': {
|
||||
justifyContent: 'space-evenly',
|
||||
width: '100%',
|
||||
flexWrap: 'wrap',
|
||||
},
|
||||
},
|
||||
showMoreButton: {
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
'@media (max-width: 750px)': {
|
||||
position: 'absolute',
|
||||
top: '0',
|
||||
right: '0',
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
export const TimeSpan: React.FC<TimeSpanProps> = React.memo(
|
||||
({
|
||||
range: {from, to, oldFrom},
|
||||
id,
|
||||
initialTags,
|
||||
note: initialNote,
|
||||
dateSelectorOpen = () => {},
|
||||
rangeChange = () => {},
|
||||
deleted = () => {},
|
||||
stopped = () => {},
|
||||
continued = () => {},
|
||||
elevation = 1,
|
||||
addTagsToTracker,
|
||||
}) => {
|
||||
const styles = useStyles();
|
||||
const [showNotes, toggleShowingNotes] = React.useState(initialNote !== '');
|
||||
const note = React.useRef<{value: string; handle?: number}>({value: initialNote});
|
||||
|
||||
const [selectedEntries, setSelectedEntries] = React.useState<TagSelectorEntry[]>(initialTags);
|
||||
const [openMenu, setOpenMenu] = useStateAndDelegateWithDelayOnChange<null | HTMLElement>(null, (o) =>
|
||||
dateSelectorOpen(!!o)
|
||||
);
|
||||
const [stopTimer] = useMutation<StopTimer, StopTimerVariables>(gqlTimeSpan.StopTimer, {
|
||||
update: (cache, {data}) => {
|
||||
if (!data || !data.stopTimeSpan) {
|
||||
return;
|
||||
}
|
||||
removeFromTrackersCache(cache, data);
|
||||
addTimeSpanToCache(cache, data.stopTimeSpan);
|
||||
},
|
||||
});
|
||||
const [startTimer] = useMutation<StartTimer, StartTimerVariables>(gqlTimeSpan.StartTimer, {
|
||||
refetchQueries: [{query: gqlTimeSpan.Trackers}],
|
||||
});
|
||||
const [updateTimeSpan] = useMutation<UpdateTimeSpan, UpdateTimeSpanVariables>(gqlTimeSpan.UpdateTimeSpan);
|
||||
const noteAwareUpdateTimeSpan = ({variables}: {variables: Omit<UpdateTimeSpanVariables, 'note'>}) => {
|
||||
clearTimeout(note.current.handle);
|
||||
return updateTimeSpan({variables: {...variables, note: note.current.value}});
|
||||
};
|
||||
const [removeTimeSpan] = useMutation<RemoveTimeSpan, RemoveTimeSpanVariables>(gqlTimeSpan.RemoveTimeSpan, {
|
||||
update: (cache, {data}) => {
|
||||
let oldData: TimeSpans | null = null;
|
||||
try {
|
||||
oldData = cache.readQuery<TimeSpans>({query: gqlTimeSpan.TimeSpans});
|
||||
} catch (e) {}
|
||||
|
||||
const oldTrackers = cache.readQuery<Trackers>({query: gqlTimeSpan.Trackers});
|
||||
if (!data || !data.removeTimeSpan) {
|
||||
return;
|
||||
}
|
||||
const removedId = data.removeTimeSpan.id;
|
||||
if (oldTrackers) {
|
||||
cache.writeQuery<Trackers>({
|
||||
query: gqlTimeSpan.Trackers,
|
||||
data: {
|
||||
timers: (oldTrackers.timers || []).filter((tracker) => tracker.id !== removedId),
|
||||
},
|
||||
});
|
||||
}
|
||||
if (oldData) {
|
||||
cache.writeQuery<TimeSpans>({
|
||||
query: gqlTimeSpan.TimeSpans,
|
||||
data: {
|
||||
timeSpans: {
|
||||
__typename: 'PagedTimeSpans',
|
||||
timeSpans: oldData.timeSpans.timeSpans.filter((ts) => ts.id !== removedId),
|
||||
cursor: oldData.timeSpans.cursor,
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
const updateNote = (newValue: string) => {
|
||||
window.clearTimeout(note.current.handle);
|
||||
const handle = window.setTimeout(
|
||||
() =>
|
||||
updateTimeSpan({
|
||||
variables: {
|
||||
oldStart: oldFrom,
|
||||
id,
|
||||
start: inUserTz(from).format(),
|
||||
end: to && inUserTz(to).format(),
|
||||
tags: toInputTags(selectedEntries),
|
||||
note: newValue,
|
||||
},
|
||||
}),
|
||||
200
|
||||
);
|
||||
note.current = {handle, value: newValue};
|
||||
};
|
||||
|
||||
const wasMoved = !isSameDate(from, oldFrom);
|
||||
const showDate = to !== undefined && (!isSameDate(from, to) || wasMoved);
|
||||
return (
|
||||
<Paper
|
||||
elevation={elevation}
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
padding: '10px',
|
||||
margin: '10px 0',
|
||||
opacity: wasMoved ? 0.5 : 1,
|
||||
width: '100%',
|
||||
}}>
|
||||
<div className={styles.innerTimespan}>
|
||||
<div className={styles.tagInput}>
|
||||
<TagSelector
|
||||
dialogOpen={dateSelectorOpen}
|
||||
selectedEntries={selectedEntries}
|
||||
onSelectedEntriesChanged={(entries) => {
|
||||
setSelectedEntries(entries);
|
||||
noteAwareUpdateTimeSpan({
|
||||
variables: {
|
||||
oldStart: oldFrom,
|
||||
id,
|
||||
start: inUserTz(from).format(),
|
||||
end: to && inUserTz(to).format(),
|
||||
tags: toInputTags(entries),
|
||||
},
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className={styles.timeSelection}>
|
||||
<div style={{alignItems: 'center', display: 'flex', justifyContent: 'space-evenly', flexWrap: 'wrap'}}>
|
||||
<DateTimeSelector
|
||||
popoverOpen={dateSelectorOpen}
|
||||
selectedDate={from}
|
||||
onSelectDate={(newFrom) => {
|
||||
if (!newFrom.isValid()) {
|
||||
return;
|
||||
}
|
||||
newFrom.set({second: 0});
|
||||
if (to && moment(newFrom).isAfter(to)) {
|
||||
const newTo = moment(newFrom).add(15, 'minute');
|
||||
noteAwareUpdateTimeSpan({
|
||||
variables: {
|
||||
oldStart: oldFrom,
|
||||
id,
|
||||
start: inUserTz(newFrom).format(),
|
||||
end: inUserTz(newTo).format(),
|
||||
tags: toInputTags(selectedEntries),
|
||||
},
|
||||
}).then(() => rangeChange({from: newFrom, to: newTo}));
|
||||
} else {
|
||||
noteAwareUpdateTimeSpan({
|
||||
variables: {
|
||||
id,
|
||||
oldStart: oldFrom,
|
||||
start: inUserTz(newFrom).format(),
|
||||
end: to && inUserTz(to).format(),
|
||||
tags: toInputTags(selectedEntries),
|
||||
},
|
||||
}).then(() => rangeChange({from: newFrom, to}));
|
||||
}
|
||||
}}
|
||||
showDate={showDate}
|
||||
label="start"
|
||||
/>
|
||||
{to !== undefined ? (
|
||||
<DateTimeSelector
|
||||
popoverOpen={dateSelectorOpen}
|
||||
selectedDate={to}
|
||||
onSelectDate={(newTo) => {
|
||||
if (!newTo.isValid()) {
|
||||
return;
|
||||
}
|
||||
newTo.set({second: 0});
|
||||
if (moment(newTo).isBefore(from)) {
|
||||
const newFrom = moment(newTo).subtract(15, 'minute');
|
||||
noteAwareUpdateTimeSpan({
|
||||
variables: {
|
||||
id,
|
||||
oldStart: oldFrom,
|
||||
start: inUserTz(newFrom).format(),
|
||||
end: inUserTz(newTo).format(),
|
||||
tags: toInputTags(selectedEntries),
|
||||
},
|
||||
}).then(() => rangeChange({from: newFrom, to: newTo}));
|
||||
} else {
|
||||
noteAwareUpdateTimeSpan({
|
||||
variables: {
|
||||
id,
|
||||
oldStart: oldFrom,
|
||||
start: inUserTz(from).format(),
|
||||
end: inUserTz(newTo).format(),
|
||||
tags: toInputTags(selectedEntries),
|
||||
},
|
||||
}).then(() => rangeChange({from, to: newTo}));
|
||||
}
|
||||
}}
|
||||
showDate={showDate}
|
||||
label="end"
|
||||
/>
|
||||
) : (
|
||||
<Button
|
||||
onClick={() => {
|
||||
stopTimer({variables: {id, end: inUserTz(moment()).format()}}).then(stopped);
|
||||
}}>
|
||||
Stop
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div style={{alignItems: 'center', display: 'flex'}}>
|
||||
<Typography
|
||||
variant="subtitle1"
|
||||
style={{minWidth: '70px'}}
|
||||
title="The amount of time between from and to">
|
||||
{to ? <RelativeTime from={from} to={to} /> : <RelativeToNow from={from} />}
|
||||
</Typography>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<IconButton
|
||||
className={styles.showMoreButton}
|
||||
onClick={(e: React.MouseEvent<HTMLElement>) => setOpenMenu(e.currentTarget)}>
|
||||
<MoreVert />
|
||||
</IconButton>
|
||||
|
||||
<Menu aria-haspopup="true" anchorEl={openMenu} open={openMenu !== null} onClose={() => setOpenMenu(null)}>
|
||||
{to ? (
|
||||
<MenuItem
|
||||
onClick={() => {
|
||||
setOpenMenu(null);
|
||||
startTimer({
|
||||
variables: {
|
||||
start: inUserTz(moment()).format(),
|
||||
tags: toInputTags(selectedEntries),
|
||||
note: note.current.value,
|
||||
},
|
||||
}).then(() => continued());
|
||||
}}>
|
||||
Continue
|
||||
</MenuItem>
|
||||
) : null}
|
||||
{addTagsToTracker ? (
|
||||
<MenuItem
|
||||
onClick={() => {
|
||||
setOpenMenu(null);
|
||||
addTagsToTracker(selectedEntries);
|
||||
}}>
|
||||
Copy tags
|
||||
</MenuItem>
|
||||
) : null}
|
||||
<MenuItem
|
||||
onClick={() => {
|
||||
setOpenMenu(null);
|
||||
toggleShowingNotes(!showNotes);
|
||||
}}>
|
||||
Show Notes
|
||||
</MenuItem>
|
||||
<MenuItem
|
||||
onClick={() => {
|
||||
setOpenMenu(null);
|
||||
removeTimeSpan({variables: {id}}).then(() => deleted());
|
||||
}}>
|
||||
Delete
|
||||
</MenuItem>
|
||||
</Menu>
|
||||
</div>
|
||||
{showNotes ? (
|
||||
<div>
|
||||
<TextField
|
||||
label="Note"
|
||||
fullWidth
|
||||
multiline
|
||||
defaultValue={initialNote}
|
||||
onChange={(e) => updateNote(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
</Paper>
|
||||
);
|
||||
}
|
||||
);
|
||||
157
ui/src/timespan/Tracker.tsx
Normal file
157
ui/src/timespan/Tracker.tsx
Normal file
@@ -0,0 +1,157 @@
|
||||
import * as React from 'react';
|
||||
import {TagSelectorEntry} from '../tag/tagSelectorEntry';
|
||||
import {TagSelector} from '../tag/TagSelector';
|
||||
import moment from 'moment-timezone';
|
||||
import {Button} from '@material-ui/core';
|
||||
import {MoreVert} from '@material-ui/icons';
|
||||
import IconButton from '@material-ui/core/IconButton';
|
||||
import Paper from '@material-ui/core/Paper';
|
||||
import Menu from '@material-ui/core/Menu';
|
||||
import MenuItem from '@material-ui/core/MenuItem';
|
||||
import ClickAwayListener from '@material-ui/core/ClickAwayListener';
|
||||
import {DateTimeSelector} from '../common/DateTimeSelector';
|
||||
import {useMutation} from '@apollo/react-hooks';
|
||||
import * as gqlTimeSpan from '../gql/timeSpan';
|
||||
import {StartTimer, StartTimerVariables} from '../gql/__generated__/StartTimer';
|
||||
import {InputTimeSpanTag} from '../gql/__generated__/globalTypes';
|
||||
import {AddTimeSpan, AddTimeSpanVariables} from '../gql/__generated__/AddTimeSpan';
|
||||
import {useSnackbar} from 'notistack';
|
||||
import {inUserTz} from './timeutils';
|
||||
import {addTimeSpanToCache} from '../gql/utils';
|
||||
|
||||
enum Type {
|
||||
Tracker,
|
||||
Manual,
|
||||
}
|
||||
|
||||
export const calcShowDate = (from: moment.Moment, to: moment.Moment) => {
|
||||
const fromString = from.format('YYYYMMDD');
|
||||
return fromString !== to.format('YYYYMMDD') || moment().format('YYYYMMDD') !== fromString;
|
||||
};
|
||||
|
||||
interface TrackerProps {
|
||||
onSelectedEntriesChanged: (entries: TagSelectorEntry[]) => void;
|
||||
selectedEntries: TagSelectorEntry[];
|
||||
}
|
||||
|
||||
export const Tracker: React.FC<TrackerProps> = ({selectedEntries, onSelectedEntriesChanged: setSelectedEntries}) => {
|
||||
const [openMenu, setOpenMenu] = React.useState<null | HTMLElement>(null);
|
||||
const [type, setType] = React.useState<Type>(Type.Tracker);
|
||||
const [from, setFrom] = React.useState<moment.Moment>(moment().subtract(15, 'minute'));
|
||||
const [to, setTo] = React.useState<moment.Moment>(moment());
|
||||
const [showDate, setShowDate] = React.useState(false);
|
||||
const [startTimer] = useMutation<StartTimer, StartTimerVariables>(gqlTimeSpan.StartTimer, {
|
||||
refetchQueries: [{query: gqlTimeSpan.Trackers}],
|
||||
});
|
||||
const [addTimeSpan] = useMutation<AddTimeSpan, AddTimeSpanVariables>(gqlTimeSpan.AddTimeSpan, {
|
||||
update: (cache, {data}) => {
|
||||
if (!data || !data.createTimeSpan) {
|
||||
return;
|
||||
}
|
||||
addTimeSpanToCache(cache, data.createTimeSpan);
|
||||
},
|
||||
});
|
||||
const {enqueueSnackbar} = useSnackbar();
|
||||
|
||||
React.useEffect(() => {
|
||||
const handle = window.setInterval(() => {
|
||||
setShowDate(calcShowDate(from, to));
|
||||
}, 10000);
|
||||
return () => clearInterval(handle);
|
||||
}, [showDate, from, to]);
|
||||
|
||||
const submit = () => {
|
||||
const tags = selectedEntries.map(
|
||||
(entry: TagSelectorEntry): InputTimeSpanTag => ({key: entry.tag.key, value: entry.value})
|
||||
);
|
||||
if (type === Type.Tracker) {
|
||||
startTimer({variables: {start: inUserTz(moment()).format(), tags, note: ''}}).then(() => {
|
||||
setSelectedEntries([]);
|
||||
enqueueSnackbar('tracker started', {variant: 'success'});
|
||||
});
|
||||
} else {
|
||||
addTimeSpan({variables: {start: inUserTz(from).format(), end: inUserTz(to).format(), tags, note: ''}}).then(() => {
|
||||
setSelectedEntries([]);
|
||||
enqueueSnackbar('time span added', {variant: 'success'});
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<ClickAwayListener onClickAway={() => setOpenMenu(null)}>
|
||||
<Paper style={{display: 'flex', alignItems: 'center', padding: '10px'}}>
|
||||
<div style={{flex: '1', marginRight: 10}}>
|
||||
<TagSelector
|
||||
selectedEntries={selectedEntries}
|
||||
onSelectedEntriesChanged={setSelectedEntries}
|
||||
onCtrlEnter={submit}
|
||||
/>
|
||||
</div>
|
||||
{type === Type.Manual ? (
|
||||
<div>
|
||||
<DateTimeSelector
|
||||
selectedDate={from}
|
||||
onSelectDate={(newFrom) => {
|
||||
if (!newFrom.isValid()) {
|
||||
return;
|
||||
}
|
||||
setFrom(newFrom);
|
||||
if (moment(newFrom).isAfter(to)) {
|
||||
const newTo = moment(newFrom).add(15, 'minute');
|
||||
setTo(newTo);
|
||||
setShowDate(calcShowDate(newFrom, newTo));
|
||||
} else {
|
||||
setShowDate(calcShowDate(newFrom, to));
|
||||
}
|
||||
}}
|
||||
showDate={showDate}
|
||||
label="start"
|
||||
/>
|
||||
<DateTimeSelector
|
||||
selectedDate={to}
|
||||
onSelectDate={(newTo) => {
|
||||
if (!newTo.isValid()) {
|
||||
return;
|
||||
}
|
||||
setTo(newTo);
|
||||
if (moment(newTo).isBefore(from)) {
|
||||
const newFrom = moment(newTo).subtract(15, 'minute');
|
||||
setFrom(newFrom);
|
||||
setShowDate(calcShowDate(newFrom, newTo));
|
||||
} else {
|
||||
setShowDate(calcShowDate(from, newTo));
|
||||
}
|
||||
}}
|
||||
showDate={showDate}
|
||||
label="end"
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
<Button variant="text" style={{height: 50}} onClick={submit}>
|
||||
{type === Type.Manual ? 'add' : 'start'}
|
||||
</Button>
|
||||
<IconButton onClick={(e: React.MouseEvent<HTMLElement>) => setOpenMenu(e.currentTarget)}>
|
||||
<MoreVert />
|
||||
</IconButton>
|
||||
<Menu aria-haspopup="true" anchorEl={openMenu} open={openMenu !== null}>
|
||||
<MenuItem
|
||||
selected={type === Type.Tracker}
|
||||
onClick={() => {
|
||||
setOpenMenu(null);
|
||||
setType(Type.Tracker);
|
||||
}}>
|
||||
Tracker
|
||||
</MenuItem>
|
||||
<MenuItem
|
||||
selected={type === Type.Manual}
|
||||
onClick={() => {
|
||||
setOpenMenu(null);
|
||||
setType(Type.Manual);
|
||||
}}>
|
||||
Manual
|
||||
</MenuItem>
|
||||
</Menu>
|
||||
</Paper>
|
||||
</ClickAwayListener>
|
||||
);
|
||||
};
|
||||
380
ui/src/timespan/calendar/CalendarPage.tsx
Normal file
380
ui/src/timespan/calendar/CalendarPage.tsx
Normal file
@@ -0,0 +1,380 @@
|
||||
import * as React from 'react';
|
||||
import {Paper, useTheme} from '@material-ui/core';
|
||||
import moment from 'moment';
|
||||
import {useApolloClient, useMutation, useQuery} from '@apollo/react-hooks';
|
||||
import {TimeSpans_timeSpans_timeSpans} from '../../gql/__generated__/TimeSpans';
|
||||
import * as gqlTimeSpan from '../../gql/timeSpan';
|
||||
import {Trackers} from '../../gql/__generated__/Trackers';
|
||||
import {Tags} from '../../gql/__generated__/Tags';
|
||||
import * as gqlTag from '../../gql/tags';
|
||||
import FullCalendar from '@fullcalendar/react';
|
||||
import {calculateColor, ColorMode} from '../colorutils';
|
||||
import '@fullcalendar/core/main.css';
|
||||
import '@fullcalendar/daygrid/main.css';
|
||||
import '@fullcalendar/timegrid/main.css';
|
||||
import dayGridPlugin from '@fullcalendar/daygrid';
|
||||
import timeGridPlugin from '@fullcalendar/timegrid';
|
||||
import momentPlugin from '@fullcalendar/moment';
|
||||
import interactionPlugin from '@fullcalendar/interaction';
|
||||
import {OptionsInput} from '@fullcalendar/core';
|
||||
import {UpdateTimeSpan, UpdateTimeSpanVariables} from '../../gql/__generated__/UpdateTimeSpan';
|
||||
import Popper from '@material-ui/core/Popper';
|
||||
import ClickAwayListener from '@material-ui/core/ClickAwayListener';
|
||||
import {TimeSpan} from '../TimeSpan';
|
||||
import {toTagSelectorEntry} from '../../tag/tagSelectorEntry';
|
||||
import {AddTimeSpan, AddTimeSpanVariables} from '../../gql/__generated__/AddTimeSpan';
|
||||
import {FullCalendarStyling} from './FullCalendarStyling';
|
||||
import useInterval from '@rooks/use-interval';
|
||||
import {EventApi} from '@fullcalendar/core/api/EventApi';
|
||||
import {StopTimer, StopTimerVariables} from '../../gql/__generated__/StopTimer';
|
||||
import {
|
||||
addTimeSpanInRangeToCache,
|
||||
addTimeSpanToCache,
|
||||
removeFromTimeSpanInRangeCache,
|
||||
removeFromTrackersCache,
|
||||
} from '../../gql/utils';
|
||||
import {StartTimer, StartTimerVariables} from '../../gql/__generated__/StartTimer';
|
||||
import {timeRunningCalendar} from '../timeutils';
|
||||
import {stripTypename} from '../../utils/strip';
|
||||
import {TimeSpansInRange, TimeSpansInRangeVariables} from '../../gql/__generated__/TimeSpansInRange';
|
||||
import {ExtendedEventSourceInput} from '@fullcalendar/core/structs/event-source';
|
||||
|
||||
const toMoment = (date: Date): moment.Moment => {
|
||||
return moment(date).tz('utc');
|
||||
};
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
// tslint:disable-next-line:no-any
|
||||
__TRAGGO_CALENDAR: any;
|
||||
}
|
||||
}
|
||||
|
||||
const StartTimerId = '-1';
|
||||
|
||||
export const CalendarPage: React.FC = () => {
|
||||
const apollo = useApolloClient();
|
||||
const theme = useTheme();
|
||||
const timeSpansResult = useQuery<TimeSpansInRange, TimeSpansInRangeVariables>(gqlTimeSpan.TimeSpansInRange, {
|
||||
variables: {
|
||||
start: moment()
|
||||
.startOf('week')
|
||||
.format(),
|
||||
end: moment()
|
||||
.endOf('week')
|
||||
.format(),
|
||||
},
|
||||
fetchPolicy: 'cache-and-network',
|
||||
});
|
||||
const trackersResult = useQuery<Trackers>(gqlTimeSpan.Trackers, {fetchPolicy: 'cache-and-network'});
|
||||
const tagsResult = useQuery<Tags>(gqlTag.Tags);
|
||||
const [startTimer] = useMutation<StartTimer, StartTimerVariables>(gqlTimeSpan.StartTimer, {
|
||||
refetchQueries: [{query: gqlTimeSpan.Trackers}],
|
||||
});
|
||||
const [updateTimeSpanMutation] = useMutation<UpdateTimeSpan, UpdateTimeSpanVariables>(gqlTimeSpan.UpdateTimeSpan);
|
||||
const [currentDate, setCurrentDate] = React.useState(moment());
|
||||
const [stopTimer] = useMutation<StopTimer, StopTimerVariables>(gqlTimeSpan.StopTimer, {
|
||||
update: (cache, {data}) => {
|
||||
if (!data || !data.stopTimeSpan) {
|
||||
return;
|
||||
}
|
||||
removeFromTrackersCache(cache, data);
|
||||
addTimeSpanInRangeToCache(cache, data.stopTimeSpan, timeSpansResult.variables);
|
||||
},
|
||||
});
|
||||
useInterval(
|
||||
() => {
|
||||
setCurrentDate(moment());
|
||||
},
|
||||
60000,
|
||||
true
|
||||
);
|
||||
React.useEffect(() => {
|
||||
window.__TRAGGO_CALENDAR = {};
|
||||
return () => (window.__TRAGGO_CALENDAR = undefined);
|
||||
});
|
||||
const [ignore, setIgnore] = React.useState<boolean>(false);
|
||||
const [selected, setSelected] = React.useState<{selected: HTMLElement | null; data: TimeSpans_timeSpans_timeSpans | null}>({
|
||||
selected: null,
|
||||
data: null,
|
||||
});
|
||||
const [addTimeSpan] = useMutation<AddTimeSpan, AddTimeSpanVariables>(gqlTimeSpan.AddTimeSpan, {
|
||||
update: (cache, {data}) => {
|
||||
if (!data || !data.createTimeSpan) {
|
||||
return;
|
||||
}
|
||||
addTimeSpanInRangeToCache(cache, data.createTimeSpan, timeSpansResult.variables);
|
||||
addTimeSpanToCache(cache, data.createTimeSpan);
|
||||
},
|
||||
});
|
||||
|
||||
const values: ExtendedEventSourceInput[] = (() => {
|
||||
if (
|
||||
timeSpansResult.error ||
|
||||
timeSpansResult.loading ||
|
||||
!timeSpansResult.data ||
|
||||
timeSpansResult.data.timeSpans === null ||
|
||||
trackersResult.error ||
|
||||
trackersResult.loading ||
|
||||
!trackersResult.data ||
|
||||
trackersResult.data.timers === null ||
|
||||
tagsResult.error ||
|
||||
tagsResult.loading ||
|
||||
!tagsResult.data ||
|
||||
tagsResult.data.tags === null
|
||||
) {
|
||||
return [];
|
||||
}
|
||||
return timeSpansResult.data.timeSpans.timeSpans
|
||||
.concat(trackersResult.data.timers)
|
||||
.sort((a, b) => a.start.toString().localeCompare(b.start.toString()))
|
||||
.map((ts) => {
|
||||
const colorKey = ts
|
||||
.tags!.map((t) => t.key + ':' + t.value)
|
||||
.sort((a, b) => a.localeCompare(b))
|
||||
.join(' ');
|
||||
const color = calculateColor(colorKey, ColorMode.Bold, theme.palette.type);
|
||||
const borderColor = calculateColor(colorKey, ColorMode.None, theme.palette.type);
|
||||
return {
|
||||
start: moment(ts.start).toDate(),
|
||||
end: moment(ts.end || currentDate).toDate(),
|
||||
hasEnd: !!ts.end,
|
||||
editable: !!ts.end,
|
||||
backgroundColor: color,
|
||||
startEditable: true,
|
||||
id: ts.id,
|
||||
tags: ts.tags!.map(({value, key}) => ({key, value})),
|
||||
title: ts.tags!.map((t) => t.key + ':' + t.value).join(' '),
|
||||
extendedProps: {ts},
|
||||
textColor: theme.palette.getContrastText(color),
|
||||
borderColor,
|
||||
};
|
||||
});
|
||||
})();
|
||||
|
||||
const onDrop: OptionsInput['eventDrop'] = (data) => {
|
||||
updateTimeSpanMutation({
|
||||
variables: {
|
||||
oldStart: moment(data.oldEvent.start!).format(),
|
||||
start: moment(data.event.start!).format(),
|
||||
end: moment(data.event.end!).format(),
|
||||
id: parseInt(data.event.id, 10),
|
||||
tags: stripTypename(data.event.extendedProps.ts.tags),
|
||||
note: data.event.extendedProps.ts.note,
|
||||
},
|
||||
});
|
||||
};
|
||||
const onResize: OptionsInput['eventResize'] = (data) => {
|
||||
updateTimeSpanMutation({
|
||||
variables: {
|
||||
oldStart: moment(data.prevEvent.start!).format(),
|
||||
start: moment(data.event.start!).format(),
|
||||
end: moment(data.event.end!).format(),
|
||||
id: parseInt(data.event.id, 10),
|
||||
tags: stripTypename(data.event.extendedProps.ts.tags),
|
||||
note: data.event.extendedProps.ts.note,
|
||||
},
|
||||
});
|
||||
};
|
||||
const onSelect: OptionsInput['select'] = (data) => {
|
||||
addTimeSpan({
|
||||
variables: {
|
||||
start: moment(data.start).format(),
|
||||
end: moment(data.end).format(),
|
||||
tags: [],
|
||||
note: '',
|
||||
},
|
||||
});
|
||||
};
|
||||
const onClick: OptionsInput['eventClick'] = (data) => {
|
||||
data.jsEvent.preventDefault();
|
||||
if (data.event.id === StartTimerId) {
|
||||
startTimer({variables: {start: moment().format(), tags: [], note: ''}}).then(() => {
|
||||
setCurrentDate(moment());
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// tslint:disable-next-line:no-any
|
||||
setSelected({data: data.event.extendedProps.ts, selected: data.jsEvent.target as any});
|
||||
};
|
||||
if (trackersResult.data && !(trackersResult.data.timers || []).length) {
|
||||
const startTimerEvent: ExtendedEventSourceInput = {
|
||||
start: currentDate.toDate(),
|
||||
end: moment(currentDate)
|
||||
.add(15, 'minute')
|
||||
.toDate(),
|
||||
className: '__start',
|
||||
editable: false,
|
||||
id: StartTimerId,
|
||||
};
|
||||
values.push(startTimerEvent);
|
||||
}
|
||||
|
||||
return (
|
||||
<Paper style={{padding: 10, bottom: 10, top: 80, position: 'absolute'}} color="red">
|
||||
<FullCalendarStyling>
|
||||
<FullCalendar
|
||||
defaultView="timeGridWeek"
|
||||
rerenderDelay={30}
|
||||
datesRender={(x) => {
|
||||
const range = {start: moment(x.view.currentStart), end: moment(x.view.currentEnd)};
|
||||
if (
|
||||
!moment(timeSpansResult.variables.start).isSame(range.start) ||
|
||||
!moment(timeSpansResult.variables.end).isSame(range.end)
|
||||
) {
|
||||
timeSpansResult.refetch(range);
|
||||
}
|
||||
}}
|
||||
views={{
|
||||
timeGrid5Day: {
|
||||
type: 'timeGrid',
|
||||
duration: {days: 7},
|
||||
buttonText: '5 day',
|
||||
hiddenDays: [0, 6],
|
||||
},
|
||||
}}
|
||||
editable={true}
|
||||
events={values}
|
||||
allDaySlot={false}
|
||||
selectable={true}
|
||||
selectMirror={true}
|
||||
handleWindowResize={true}
|
||||
height={'parent'}
|
||||
selectMinDistance={20}
|
||||
now={currentDate.toDate()}
|
||||
defaultTimedEventDuration={{minute: 15}}
|
||||
eventRender={(e) => {
|
||||
const content = e.el.getElementsByClassName('fc-content').item(0);
|
||||
if (content) {
|
||||
content.innerHTML = getElementContent(e.event, () => {
|
||||
stopTimer({
|
||||
variables: {id: e.event.extendedProps.ts.id, end: moment().format()},
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
e.el.setAttribute('data-has-end', '' + (!e.event.extendedProps.ts || !!e.event.extendedProps.ts.end));
|
||||
}}
|
||||
slotLabelInterval={{minute: 60}}
|
||||
slotDuration={{minute: 15}}
|
||||
scrollTime={{hour: 6, minute: 30}}
|
||||
select={onSelect}
|
||||
firstDay={moment.localeData().firstDayOfWeek()}
|
||||
eventResize={onResize}
|
||||
eventClick={onClick}
|
||||
eventDrop={onDrop}
|
||||
slotLabelFormat={(s) => toMoment(s.start.marker).format('LT')}
|
||||
columnHeaderFormat={(s) => toMoment(s.start.marker).format('DD ddd')}
|
||||
nowIndicator={true}
|
||||
plugins={[dayGridPlugin, timeGridPlugin, interactionPlugin, momentPlugin]}
|
||||
header={{
|
||||
center: 'title',
|
||||
left: 'prev,next today',
|
||||
right: 'timeGridWeek,timeGrid5Day,timeGridDay',
|
||||
}}
|
||||
/>
|
||||
</FullCalendarStyling>
|
||||
{!!selected.selected && (
|
||||
<Popper open={true} anchorEl={selected.selected} style={{zIndex: 1200, maxWidth: 700}}>
|
||||
<ClickAwayListener
|
||||
onClickAway={() => {
|
||||
if (ignore) {
|
||||
return;
|
||||
}
|
||||
setSelected({selected: null, data: null});
|
||||
}}>
|
||||
<div>
|
||||
<TimeSpan
|
||||
elevation={10}
|
||||
id={selected.data!.id}
|
||||
key={selected.data!.id}
|
||||
rangeChange={(range) => {
|
||||
setSelected({
|
||||
...selected,
|
||||
data: {...selected.data!, start: range.from.format(), end: range.to && range.to.format()},
|
||||
});
|
||||
}}
|
||||
deleted={() => {
|
||||
removeFromTimeSpanInRangeCache(apollo.cache, selected.data!.id, timeSpansResult.variables);
|
||||
setSelected({selected: null, data: null});
|
||||
}}
|
||||
continued={() => setCurrentDate(moment())}
|
||||
range={{
|
||||
from: moment(selected.data!.start),
|
||||
to: selected.data!.end ? moment(selected.data!.end) : undefined,
|
||||
}}
|
||||
initialTags={toTagSelectorEntry(
|
||||
tagsResult.data!.tags!,
|
||||
selected.data!.tags!.map((tag) => ({key: tag.key, value: tag.value}))
|
||||
)}
|
||||
note={selected.data!.note}
|
||||
dateSelectorOpen={setIgnore}
|
||||
stopped={() => {
|
||||
setSelected({
|
||||
...selected,
|
||||
data: {...selected.data!, end: currentDate.toDate()},
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</ClickAwayListener>
|
||||
</Popper>
|
||||
)}
|
||||
</Paper>
|
||||
);
|
||||
};
|
||||
|
||||
const getElementContent = (event: EventApi, stop: () => void): string => {
|
||||
if (!event.start || !event.end) {
|
||||
return '';
|
||||
}
|
||||
|
||||
if (event.id === StartTimerId) {
|
||||
return 'START';
|
||||
}
|
||||
|
||||
const start = moment(event.start);
|
||||
const end = moment(event.end);
|
||||
const diff = end.diff(start, 'minute');
|
||||
|
||||
const lines = Math.floor(diff / 15);
|
||||
const hasEnd = !event.extendedProps.ts || event.extendedProps.ts.end;
|
||||
|
||||
let stopButton = '';
|
||||
if (!hasEnd) {
|
||||
const id = event.extendedProps.ts.id;
|
||||
if (!window.__TRAGGO_CALENDAR) {
|
||||
window.__TRAGGO_CALENDAR = {};
|
||||
}
|
||||
window.__TRAGGO_CALENDAR[id] = (e: Event) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
stop();
|
||||
return false;
|
||||
};
|
||||
stopButton = `<div class="stop"><a onClick="return window.__TRAGGO_CALENDAR[${id}](event)">STOP ${timeRunningCalendar(
|
||||
start,
|
||||
end
|
||||
)}</a></div>`;
|
||||
}
|
||||
const clamp = (amount: number) =>
|
||||
`<span class="ellipsis" title="${event.title}" style="-webkit-line-clamp: ${amount}">${event.title}</span>`;
|
||||
|
||||
const running = hasEnd ? `<span style="float: right">${timeRunningCalendar(start, end)}</span>` : '';
|
||||
const date = `${start.format('LT')} - ${hasEnd ? end.format('LT') : 'now'} ${running}`;
|
||||
if (lines < 2) {
|
||||
return event.title
|
||||
? `<span class="ellipsis-single" title="${event.title}">${event.title}</span>${stopButton}`
|
||||
: `${date}${stopButton}`;
|
||||
}
|
||||
if (lines === 2) {
|
||||
if (hasEnd) {
|
||||
return `${date}<span class="ellipsis-single" title="${event.title}">${event.title}</span>${stopButton}`;
|
||||
} else {
|
||||
return `${clamp(2)}${stopButton}`;
|
||||
}
|
||||
}
|
||||
|
||||
return `${date}<br/>${clamp(lines - 1)}${stopButton}`;
|
||||
};
|
||||
236
ui/src/timespan/calendar/FullCalendarStyling.tsx
Normal file
236
ui/src/timespan/calendar/FullCalendarStyling.tsx
Normal file
@@ -0,0 +1,236 @@
|
||||
import * as React from 'react';
|
||||
import {makeStyles} from '@material-ui/core';
|
||||
|
||||
const useStyle = makeStyles((theme) => {
|
||||
return {
|
||||
root: {
|
||||
height: '100%',
|
||||
width: '100%',
|
||||
// '& .fc-time-grid-event': {
|
||||
// opacity: 0,
|
||||
// border: 'none',
|
||||
// },
|
||||
'& .fc-head': {
|
||||
fontFamily: 'monospace',
|
||||
},
|
||||
'& .fc-toolbar': {
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
},
|
||||
'& .fc-left, .fc-center, .fc-right': {
|
||||
display: 'inline-block',
|
||||
verticalAlign: 'middle',
|
||||
textAlign: 'center',
|
||||
// width: '33.33%',
|
||||
boxSizing: 'border-box',
|
||||
// padding: '0 10px',
|
||||
},
|
||||
'& .fc-center h2': {
|
||||
margin: 0,
|
||||
fontSize: '1.5rem',
|
||||
},
|
||||
'@media (max-width: 600px)': {
|
||||
'& .fc-toolbar': {
|
||||
display: 'inherit',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
},
|
||||
'& .fc-left, .fc-center, .fc-right': {
|
||||
display: 'block',
|
||||
width: '100%',
|
||||
},
|
||||
'& .fc-left, .fc-right': {
|
||||
textAlign: 'center',
|
||||
},
|
||||
},
|
||||
'& .fc-time-grid-event.fc-v-event.fc-event': {
|
||||
borderRadius: 1,
|
||||
border: 'none',
|
||||
padding: 5,
|
||||
opacity: 0.65,
|
||||
left: '5% !important',
|
||||
right: '5% !important',
|
||||
},
|
||||
'& .fc .fc-time-grid-event': {
|
||||
minHeight: 1,
|
||||
},
|
||||
'& .fc-event': {
|
||||
fontSize: 'inherit !important',
|
||||
borderRadius: 1,
|
||||
},
|
||||
'& .fc td, .fc th': {
|
||||
borderStyle: 'solid !important',
|
||||
borderWidth: '0px !important',
|
||||
padding: '0 !important',
|
||||
verticalAlign: 'top !important',
|
||||
},
|
||||
'& .fc tr:nth-child(4n)': {
|
||||
borderStyle: 'solid !important',
|
||||
borderColor: theme.palette.type === 'dark' ? 'rgba(255,255,255,.2)' : 'rgba(0,0,0,.2)',
|
||||
borderWidth: '0 0 2px 0 !important',
|
||||
padding: '0 !important',
|
||||
verticalAlign: 'top !important',
|
||||
},
|
||||
'& .fc tbody tr:first-child': {
|
||||
borderStyle: 'solid !important',
|
||||
borderColor: theme.palette.type === 'dark' ? 'rgba(255,255,255,.2)' : 'rgba(0,0,0,.2)',
|
||||
borderWidth: '2px 0 0 0 !important',
|
||||
padding: '0 !important',
|
||||
verticalAlign: 'top !important',
|
||||
},
|
||||
'& .fc td': {
|
||||
borderStyle: 'solid !important',
|
||||
borderColor: theme.palette.type === 'dark' ? 'rgba(255,255,255,.1)' : 'rgba(0,0,0,.1)',
|
||||
borderWidth: '0 1px 0 0 !important',
|
||||
padding: '0 !important',
|
||||
verticalAlign: 'top !important',
|
||||
},
|
||||
'& .fc tr:nth-child(4n+2)': {
|
||||
borderStyle: 'solid !important',
|
||||
borderColor: theme.palette.type === 'dark' ? 'rgba(255,255,255,.1)' : 'rgba(0,0,0,.1)',
|
||||
borderWidth: '0 0 1px 0 !important',
|
||||
padding: '0 !important',
|
||||
verticalAlign: 'top !important',
|
||||
},
|
||||
'& .fc-event .fc-bg': {
|
||||
zIndex: '1 !important',
|
||||
background: 'inherit !important',
|
||||
opacity: '.25 !important',
|
||||
},
|
||||
'& .fc-time-grid-event .fc-time': {
|
||||
fontWeight: 'normal !important',
|
||||
},
|
||||
'& .fc-ltr .fc-h-event.fc-not-end, .fc-rtl .fc-h-event.fc-not-start': {
|
||||
opacity: '.65 !important',
|
||||
marginLeft: '12px !important',
|
||||
padding: '5px !important',
|
||||
},
|
||||
'& .fc-day-grid-event.fc-h-event.fc-event.fc-not-start.fc-end': {
|
||||
opacity: '.65 !important',
|
||||
marginLeft: '12px !important',
|
||||
padding: '5px !important',
|
||||
},
|
||||
'& .fc-today': {
|
||||
background: theme.palette.type === 'dark' ? 'rgba(199,199,199,.1) !important' : 'rgba(0,0,0,.1) !important',
|
||||
},
|
||||
'& .fc-button': {
|
||||
display: 'inline-block',
|
||||
position: 'relative',
|
||||
cursor: 'pointer',
|
||||
minHeight: '36px',
|
||||
minWidth: '88px',
|
||||
lineHeight: '36px',
|
||||
verticalAlign: 'middle',
|
||||
alignItems: 'center',
|
||||
textAlign: 'center',
|
||||
borderRadius: '2px',
|
||||
boxSizing: 'border-box',
|
||||
userSelect: 'none',
|
||||
outline: 'none',
|
||||
border: '0',
|
||||
padding: '0 6px',
|
||||
margin: '6px 8px',
|
||||
letterSpacing: '.01em',
|
||||
background: 'transparent',
|
||||
color: 'currentColor',
|
||||
whiteSpace: 'nowrap',
|
||||
textTransform: 'uppercase',
|
||||
fontWeight: '500',
|
||||
fontSize: '14px',
|
||||
fontStyle: 'inherit',
|
||||
fontVariant: 'inherit',
|
||||
fontFamily: 'inherit',
|
||||
textDecoration: 'none',
|
||||
overflow: 'hidden',
|
||||
borderColor: theme.palette.primary.main,
|
||||
borderWidth: 1,
|
||||
borderStyle: 'solid',
|
||||
transition: 'box-shadow .4s cubic-bezier(.25,.8,.25,1),background-color .4s cubic-bezier(.25,.8,.25,1)',
|
||||
},
|
||||
'& .fc-button-active': {
|
||||
backgroundColor: theme.palette.primary.main + '!important',
|
||||
},
|
||||
'& .fc-button:active': {
|
||||
backgroundColor: theme.palette.primary.main + '!important',
|
||||
},
|
||||
'& .fc td.fc-axis.fc-time': {
|
||||
position: 'absolute',
|
||||
marginTop: -10,
|
||||
background: theme.palette.background.paper,
|
||||
zIndex: 1000,
|
||||
},
|
||||
'& .fc td.fc-axis.fc-time span': {
|
||||
paddingRight: 10,
|
||||
},
|
||||
'& .fc .fc-time-grid-event-inset': {
|
||||
boxShadow: theme.shadows[5],
|
||||
},
|
||||
'& .fc .fc-mirror': {
|
||||
opacity: 0.5,
|
||||
},
|
||||
'& .fc .fc-time-grid-event:hover': {
|
||||
minHeight: 17,
|
||||
zIndex: '2!important',
|
||||
},
|
||||
'& .fc .fc-time-grid-event[data-has-end="false"]': {
|
||||
minHeight: 40,
|
||||
},
|
||||
'& .fc .fc-content': {
|
||||
height: '100%',
|
||||
},
|
||||
'& .fc .fc-content .stop': {
|
||||
background: theme.palette.type === 'dark' ? 'rgba(255,255,255,.2) !important' : 'rgba(0,0,0,.1) !important',
|
||||
borderRadius: '6px 0 0 0',
|
||||
fontSize: '10px',
|
||||
width: '50%',
|
||||
padding: '2px 0 1px 0',
|
||||
textAlign: 'center',
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
position: 'absolute',
|
||||
display: 'inline-block',
|
||||
},
|
||||
'& .fc .fc-content .stop:hover': {
|
||||
background: theme.palette.type === 'dark' ? 'rgba(255,255,255,.3) !important' : 'rgba(0,0,0,.2) !important',
|
||||
},
|
||||
'& .fc .fc-content .stop a': {
|
||||
width: '100%',
|
||||
display: 'block',
|
||||
},
|
||||
'& .fc .fc-now-indicator': {
|
||||
zIndex: 5,
|
||||
},
|
||||
'& .fc .ellipsis': {
|
||||
display: '-webkit-box',
|
||||
boxOrient: 'vertical',
|
||||
overflow: 'hidden',
|
||||
},
|
||||
'& .fc .ellipsis-single': {
|
||||
textOverflow: 'ellipsis',
|
||||
display: 'inline-block',
|
||||
width: '100%',
|
||||
whiteSpace: 'nowrap',
|
||||
overflow: 'hidden',
|
||||
},
|
||||
'& .fc .__start': {
|
||||
background: theme.palette.primary.main,
|
||||
color: theme.palette.primary.contrastText,
|
||||
border: 0,
|
||||
borderRadius: '0 0 10px 10px',
|
||||
textAlign: 'center',
|
||||
boxShadow: theme.shadows[1],
|
||||
cursor: 'pointer',
|
||||
},
|
||||
'& .fc .__start:hover': {
|
||||
background: theme.palette.primary.light,
|
||||
},
|
||||
// tslint:disable-next-line:no-any important breaks it
|
||||
} as any,
|
||||
};
|
||||
});
|
||||
|
||||
export const FullCalendarStyling: React.FC = ({children}) => {
|
||||
const classes = useStyle();
|
||||
return <div className={classes.root}>{children}</div>;
|
||||
};
|
||||
28
ui/src/timespan/colorutils.ts
Normal file
28
ui/src/timespan/colorutils.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
// @ts-ignore
|
||||
import colorHash from 'color-hash';
|
||||
|
||||
const dark = new colorHash({saturation: 0.35, lightness: 0.35});
|
||||
const darkNone = new colorHash({saturation: 0.35, lightness: 0.5});
|
||||
|
||||
const light = new colorHash({saturation: 0.5, lightness: 0.6});
|
||||
const lightNone = new colorHash({saturation: 0.5, lightness: 0.4});
|
||||
|
||||
export enum ColorMode {
|
||||
Bold,
|
||||
None,
|
||||
}
|
||||
|
||||
const mapping: Record<ColorMode, Record<'dark' | 'light', (s: string) => string>> = {
|
||||
[ColorMode.Bold]: {
|
||||
dark: (s) => dark.hex(s),
|
||||
light: (s) => light.hex(s),
|
||||
},
|
||||
[ColorMode.None]: {
|
||||
dark: (s) => darkNone.hex(s),
|
||||
light: (s) => lightNone.hex(s),
|
||||
},
|
||||
};
|
||||
|
||||
export const calculateColor = (s: string, mode: ColorMode, theme: 'light' | 'dark') => {
|
||||
return mapping[mode][theme](s);
|
||||
};
|
||||
69
ui/src/timespan/timespanutils.ts
Normal file
69
ui/src/timespan/timespanutils.ts
Normal file
@@ -0,0 +1,69 @@
|
||||
import {Trackers_timers} from '../gql/__generated__/Trackers';
|
||||
import {Tags_tags} from '../gql/__generated__/Tags';
|
||||
import {toTagSelectorEntry} from '../tag/tagSelectorEntry';
|
||||
import moment from 'moment';
|
||||
import {TimeSpanProps} from './TimeSpan';
|
||||
import {TimeSpans_timeSpans_timeSpans} from '../gql/__generated__/TimeSpans';
|
||||
|
||||
export const toTimeSpanProps = (timers: Trackers_timers[], tags: Tags_tags[]): TimeSpanProps[] => {
|
||||
return [...timers].map((timer) => {
|
||||
const tagEntries = toTagSelectorEntry(tags, timer.tags || []);
|
||||
const range: TimeSpanProps['range'] = {from: moment.parseZone(timer.start)};
|
||||
if (timer.end) {
|
||||
range.to = moment.parseZone(timer.end);
|
||||
}
|
||||
return {
|
||||
id: timer.id,
|
||||
range: {
|
||||
...range,
|
||||
oldFrom: timer.oldStart ? moment(timer.oldStart) : undefined,
|
||||
},
|
||||
initialTags: tagEntries,
|
||||
note: timer.note,
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
type GroupedByIndex = Record<string, TimeSpans_timeSpans_timeSpans[]>;
|
||||
const group = (startOfTomorrow: moment.Moment, startOfToday: moment.Moment, startOfYesterday: moment.Moment) => (
|
||||
a: GroupedByIndex,
|
||||
current: TimeSpans_timeSpans_timeSpans
|
||||
): GroupedByIndex => {
|
||||
const startTime = moment(current.oldStart || current.start);
|
||||
let date = `${startTime.format('dddd')}, ${startTime.format('LL')}`;
|
||||
if (startTime.isBetween(startOfToday, startOfTomorrow)) {
|
||||
date = `${date} (today)`;
|
||||
} else if (startTime.isBetween(startOfYesterday, startOfToday)) {
|
||||
date = `${date} (yesterday)`;
|
||||
}
|
||||
a[date] = [...(a[date] || []), current];
|
||||
return a;
|
||||
};
|
||||
|
||||
export type GroupedTimeSpanProps = Array<{key: string; timeSpans: TimeSpanProps[]}>;
|
||||
|
||||
export const toGroupedTimeSpanProps = (
|
||||
timeSpans: TimeSpans_timeSpans_timeSpans[],
|
||||
tags: Tags_tags[],
|
||||
now: moment.Moment
|
||||
): GroupedTimeSpanProps => {
|
||||
const datesWithTimeSpans: GroupedByIndex = timeSpans.reduce(
|
||||
group(
|
||||
moment(now)
|
||||
.add(1, 'day')
|
||||
.startOf('day'),
|
||||
moment(now).startOf('day'),
|
||||
moment(now)
|
||||
.subtract(1, 'day')
|
||||
.startOf('day')
|
||||
),
|
||||
{}
|
||||
);
|
||||
return Object.keys(datesWithTimeSpans).map((key) => {
|
||||
const groupedTimeSpans = datesWithTimeSpans[key];
|
||||
return {
|
||||
key,
|
||||
timeSpans: toTimeSpanProps(groupedTimeSpans, tags),
|
||||
};
|
||||
});
|
||||
};
|
||||
24
ui/src/timespan/timeutils.ts
Normal file
24
ui/src/timespan/timeutils.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import moment from 'moment-timezone';
|
||||
import prettyMs from 'pretty-ms';
|
||||
|
||||
export const timeRunning = (date: moment.Moment, now: moment.Moment) => {
|
||||
const d = inUserTz(now).unix() - inUserTz(date).unix();
|
||||
return prettyMs(d * 1000, {unitCount: 2}).substring(1);
|
||||
};
|
||||
|
||||
export const timeRunningCalendar = (date: moment.Moment, now: moment.Moment) => {
|
||||
const d = inUserTz(now).unix() - inUserTz(date).unix();
|
||||
|
||||
if (d < 60) {
|
||||
return '<1m';
|
||||
}
|
||||
|
||||
return prettyMs(d * 1000, {unitCount: d < 60 * 24 ? 1 : 2}).substring(1);
|
||||
};
|
||||
|
||||
export const uglyConvertToLocalTime = (m: moment.Moment): moment.Moment => {
|
||||
const withoutTimeZone: string = m.format('YYYY-MM-DDTHH:mm:ss');
|
||||
return moment(withoutTimeZone);
|
||||
};
|
||||
|
||||
export const inUserTz = (m: moment.Moment): moment.Moment => m.tz(moment.tz.guess());
|
||||
83
ui/src/user/AddUserDialog.tsx
Normal file
83
ui/src/user/AddUserDialog.tsx
Normal file
@@ -0,0 +1,83 @@
|
||||
import * as React from 'react';
|
||||
import Button from '@material-ui/core/Button';
|
||||
import TextField from '@material-ui/core/TextField';
|
||||
import Dialog from '@material-ui/core/Dialog';
|
||||
import DialogActions from '@material-ui/core/DialogActions';
|
||||
import DialogContent from '@material-ui/core/DialogContent';
|
||||
import DialogTitle from '@material-ui/core/DialogTitle';
|
||||
import * as gqlUser from '../gql/user';
|
||||
import {useMutation} from '@apollo/react-hooks';
|
||||
import {useSnackbar} from 'notistack';
|
||||
import {handleError} from '../utils/errors';
|
||||
import {Checkbox} from '@material-ui/core';
|
||||
import {CreateUser, CreateUserVariables} from '../gql/__generated__/CreateUser';
|
||||
import FormControlLabel from '@material-ui/core/FormControlLabel';
|
||||
|
||||
interface AddTagDialogProps {
|
||||
open: boolean;
|
||||
close: () => void;
|
||||
}
|
||||
|
||||
export const AddUserDialog: React.FC<AddTagDialogProps> = ({close, open}) => {
|
||||
const [name, setName] = React.useState('');
|
||||
const [pass, setPass] = React.useState('');
|
||||
const [admin, setAdmin] = React.useState(false);
|
||||
const {enqueueSnackbar} = useSnackbar();
|
||||
|
||||
const [addUser] = useMutation<CreateUser, CreateUserVariables>(gqlUser.CreateUser, {
|
||||
refetchQueries: [{query: gqlUser.Users}],
|
||||
});
|
||||
const submit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
addUser({variables: {name, pass, admin}})
|
||||
.then(() => {
|
||||
enqueueSnackbar('User created', {variant: 'success'});
|
||||
close();
|
||||
})
|
||||
.catch(handleError('Create User', enqueueSnackbar));
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onClose={close} aria-labelledby="form-dialog-title" fullWidth>
|
||||
<form onSubmit={submit} noValidate autoComplete="off">
|
||||
<DialogTitle id="form-dialog-title">Create User</DialogTitle>
|
||||
<DialogContent>
|
||||
<TextField
|
||||
autoFocus
|
||||
margin="dense"
|
||||
id="name"
|
||||
label="Name"
|
||||
type="text"
|
||||
fullWidth
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
/>
|
||||
<TextField
|
||||
margin="dense"
|
||||
label="Password"
|
||||
type="password"
|
||||
fullWidth
|
||||
value={pass}
|
||||
onChange={(e) => setPass(e.target.value)}
|
||||
/>
|
||||
<FormControlLabel
|
||||
control={
|
||||
<Checkbox checked={admin} onChange={(e) => setAdmin(e.target.checked)}>
|
||||
Admin
|
||||
</Checkbox>
|
||||
}
|
||||
label="Admin"
|
||||
/>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={close} color="primary">
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" onClick={submit} color="primary">
|
||||
Create User
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</form>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
177
ui/src/user/UsersPage.tsx
Normal file
177
ui/src/user/UsersPage.tsx
Normal file
@@ -0,0 +1,177 @@
|
||||
import * as React from 'react';
|
||||
import Paper from '@material-ui/core/Paper';
|
||||
import {useMutation, useQuery} from '@apollo/react-hooks';
|
||||
import * as gqlUser from '../gql/user';
|
||||
import {CenteredSpinner} from '../common/CenteredSpinner';
|
||||
import Table from '@material-ui/core/Table';
|
||||
import TableBody from '@material-ui/core/TableBody';
|
||||
import TableCell from '@material-ui/core/TableCell';
|
||||
import TableHead from '@material-ui/core/TableHead';
|
||||
import TableRow from '@material-ui/core/TableRow';
|
||||
import DeleteIcon from '@material-ui/icons/Delete';
|
||||
import EditIcon from '@material-ui/icons/Edit';
|
||||
import DoneIcon from '@material-ui/icons/Done';
|
||||
import CloseIcon from '@material-ui/icons/Close';
|
||||
import IconButton from '@material-ui/core/IconButton';
|
||||
import {useSnackbar} from 'notistack';
|
||||
import {TextField} from '@material-ui/core';
|
||||
import {Users} from '../gql/__generated__/Users';
|
||||
import {RemoveUser, RemoveUserVariables} from '../gql/__generated__/RemoveUser';
|
||||
import {UpdateUser, UpdateUserVariables} from '../gql/__generated__/UpdateUser';
|
||||
import Checkbox from '@material-ui/core/Checkbox';
|
||||
import Button from '@material-ui/core/Button';
|
||||
import {AddUserDialog} from './AddUserDialog';
|
||||
import makeStyles from '@material-ui/core/styles/makeStyles';
|
||||
import {ConfirmDialog} from '../common/ConfirmDialog';
|
||||
|
||||
const useStyles = makeStyles((theme) => ({
|
||||
root: {
|
||||
...theme.mixins.gutters(),
|
||||
paddingTop: theme.spacing(3),
|
||||
paddingBottom: theme.spacing(3),
|
||||
textAlign: 'center',
|
||||
maxWidth: 800,
|
||||
margin: '0 auto',
|
||||
},
|
||||
}));
|
||||
|
||||
const NoEdit = [-1, '', '', false] as const;
|
||||
|
||||
export const UsersPage = () => {
|
||||
const classes = useStyles();
|
||||
const {data, loading} = useQuery<Users>(gqlUser.Users);
|
||||
const [addUser, setAddUser] = React.useState(false);
|
||||
const refetch = {refetchQueries: [{query: gqlUser.Users}, {query: gqlUser.CurrentUser}]};
|
||||
const {enqueueSnackbar} = useSnackbar();
|
||||
const [removeUserConfirmation, setRemoveUserConfirmation] = React.useState<false | [number, string]>(false);
|
||||
const [removeUser] = useMutation<RemoveUser, RemoveUserVariables>(gqlUser.RemoveUser, refetch);
|
||||
const [[editId, editName, editPass, editAdmin], setEditing] = React.useState<Readonly<[number, string, string, boolean]>>(
|
||||
NoEdit
|
||||
);
|
||||
const [updateUser] = useMutation<UpdateUser, UpdateUserVariables>(gqlUser.UpdateUser, refetch);
|
||||
if (loading || !data || !data.currentUser || !data.users) {
|
||||
return <CenteredSpinner />;
|
||||
}
|
||||
const onClickDelete = () => {
|
||||
if (removeUserConfirmation) {
|
||||
removeUser({variables: {id: removeUserConfirmation[0]}}).then(() =>
|
||||
enqueueSnackbar('user deleted', {variant: 'success'})
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const users = data.users.map((user) => {
|
||||
const onClickSubmit = () => {
|
||||
setEditing(NoEdit);
|
||||
updateUser({
|
||||
variables: {
|
||||
id: editId,
|
||||
name: editName,
|
||||
admin: editAdmin,
|
||||
pass: editPass || undefined,
|
||||
},
|
||||
}).then(() => enqueueSnackbar('user edited', {variant: 'success'}));
|
||||
};
|
||||
const isCurrent = user.id === data.currentUser!.id;
|
||||
const isEdited = editId === user.id;
|
||||
return (
|
||||
<TableRow selected={isCurrent} key={user.id}>
|
||||
<TableCell>{user.id}</TableCell>
|
||||
<TableCell>
|
||||
{isEdited ? (
|
||||
<TextField
|
||||
value={editName}
|
||||
onChange={(e) => setEditing([editId, e.target.value, '', editAdmin])}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
onClickSubmit();
|
||||
}
|
||||
}}
|
||||
onSubmit={onClickSubmit}
|
||||
/>
|
||||
) : (
|
||||
user.name + (isCurrent ? ' (current)' : '')
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{isEdited ? (
|
||||
<TextField
|
||||
value={editPass}
|
||||
placeholder={'use old password'}
|
||||
type="password"
|
||||
onChange={(e) => setEditing([editId, editName, e.target.value, editAdmin])}
|
||||
/>
|
||||
) : (
|
||||
'********'
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{isEdited ? (
|
||||
<Checkbox checked={editAdmin} onChange={(e) => setEditing([editId, editName, '', e.target.checked])} />
|
||||
) : user.admin ? (
|
||||
'Yes'
|
||||
) : (
|
||||
'No'
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell align="right">
|
||||
{isEdited ? (
|
||||
<>
|
||||
<IconButton onClick={onClickSubmit} title="Save">
|
||||
<DoneIcon />
|
||||
</IconButton>
|
||||
<IconButton onClick={() => setEditing(NoEdit)} title="Cancel">
|
||||
<CloseIcon />
|
||||
</IconButton>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<IconButton onClick={() => setEditing([user.id, user.name, '', user.admin])} title="Edit">
|
||||
<EditIcon />
|
||||
</IconButton>
|
||||
<IconButton onClick={() => setRemoveUserConfirmation([user.id, user.name])} title="Delete">
|
||||
<DeleteIcon />
|
||||
</IconButton>
|
||||
</>
|
||||
)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
});
|
||||
|
||||
return (
|
||||
<Paper elevation={1} square={true} className={classes.root}>
|
||||
<Button
|
||||
color={'primary'}
|
||||
variant={'outlined'}
|
||||
size="small"
|
||||
onClick={() => setAddUser(true)}
|
||||
fullWidth
|
||||
style={{marginBottom: 10}}>
|
||||
Create User
|
||||
</Button>
|
||||
{addUser && <AddUserDialog open={true} close={() => setAddUser(false)} />}
|
||||
<Table>
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableCell>ID</TableCell>
|
||||
<TableCell>Name</TableCell>
|
||||
<TableCell>Password</TableCell>
|
||||
<TableCell>Admin</TableCell>
|
||||
<TableCell style={{width: 150}} />
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>{users}</TableBody>
|
||||
</Table>
|
||||
{removeUserConfirmation ? (
|
||||
<ConfirmDialog
|
||||
title={`Are you sure you want to delete the user '${removeUserConfirmation[1]}'?`}
|
||||
fClose={() => setRemoveUserConfirmation(false)}
|
||||
fOnSubmit={onClickDelete}>
|
||||
<b>This operation cannot be undone.</b> Deleting the user includes deleting dashboards, time spans, tags and
|
||||
devices of that user.
|
||||
</ConfirmDialog>
|
||||
) : null}
|
||||
</Paper>
|
||||
);
|
||||
};
|
||||
35
ui/src/utils/errors.tsx
Normal file
35
ui/src/utils/errors.tsx
Normal file
@@ -0,0 +1,35 @@
|
||||
import * as React from 'react';
|
||||
import {withSnackbarProps} from 'notistack';
|
||||
import {ApolloError} from 'apollo-boost';
|
||||
|
||||
export const handleError = (prefix: string, enqueue: withSnackbarProps['enqueueSnackbar']): ((error: ApolloError) => void) => {
|
||||
return (error) => {
|
||||
error.graphQLErrors.forEach((gqlError) => {
|
||||
enqueue(`${prefix}: ${gqlError.message}`, {variant: 'warning'});
|
||||
});
|
||||
};
|
||||
};
|
||||
|
||||
export const useError = (timeout = 1000): [boolean, string, (s: string) => void] => {
|
||||
const [error, setError] = React.useState('');
|
||||
const [active, setActive] = React.useState(false);
|
||||
|
||||
React.useLayoutEffect(() => {
|
||||
if (!active) {
|
||||
return;
|
||||
}
|
||||
const handle = setTimeout(() => {
|
||||
setActive(false);
|
||||
}, timeout);
|
||||
return () => clearTimeout(handle);
|
||||
}, [active, timeout]);
|
||||
|
||||
return [
|
||||
active,
|
||||
error,
|
||||
(message: string) => {
|
||||
setError(message);
|
||||
setActive(true);
|
||||
},
|
||||
];
|
||||
};
|
||||
20
ui/src/utils/hooks.ts
Normal file
20
ui/src/utils/hooks.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import * as React from 'react';
|
||||
import useTimeout from '@rooks/use-timeout';
|
||||
|
||||
export const useStateAndDelegateWithDelayOnChange: <T>(
|
||||
initialState: T,
|
||||
delegate: React.Dispatch<React.SetStateAction<T>>,
|
||||
delay?: number
|
||||
) => [T, React.Dispatch<React.SetStateAction<T>>] = (initialState, delegate, delay = 50) => {
|
||||
const [value, setValue] = React.useState(initialState);
|
||||
const {start} = useTimeout(() => delegate(value), delay);
|
||||
return [
|
||||
value,
|
||||
(newValue) => {
|
||||
if (value !== newValue) {
|
||||
start();
|
||||
}
|
||||
setValue(newValue);
|
||||
},
|
||||
];
|
||||
};
|
||||
3
ui/src/utils/never.ts
Normal file
3
ui/src/utils/never.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export const expectNever = (value: never): never => {
|
||||
throw new Error('expected never but was ' + value);
|
||||
};
|
||||
16
ui/src/utils/range.ts
Normal file
16
ui/src/utils/range.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
export interface Range {
|
||||
from: string;
|
||||
to: string;
|
||||
}
|
||||
|
||||
export const findRange = (selection: {range: Range | null; rangeId: number | null}, ranges: Record<number, Range>): Range => {
|
||||
if (selection.rangeId !== null) {
|
||||
return exclusiveRange(ranges[selection.rangeId]);
|
||||
}
|
||||
if (selection.range === null) {
|
||||
throw new Error('expected rangeId or range to be non null');
|
||||
}
|
||||
return exclusiveRange(selection.range);
|
||||
};
|
||||
|
||||
export const exclusiveRange = (range: Range) => ({from: range.from, to: range.to});
|
||||
22
ui/src/utils/strip.ts
Normal file
22
ui/src/utils/strip.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
export const stripTypename = <T>(value: T): T => {
|
||||
if (value === null || value === undefined) {
|
||||
return value;
|
||||
}
|
||||
|
||||
if (Array.isArray(value)) {
|
||||
// tslint:disable-next-line:no-any
|
||||
return (value as any).map((x: any) => stripTypename(x));
|
||||
}
|
||||
|
||||
if (typeof value !== 'object') {
|
||||
return value;
|
||||
}
|
||||
|
||||
Object.values(value).forEach(stripTypename);
|
||||
if ('__typename' in value) {
|
||||
// @ts-ignore
|
||||
delete value.__typename;
|
||||
}
|
||||
|
||||
return value;
|
||||
};
|
||||
42
ui/src/utils/time.test.ts
Normal file
42
ui/src/utils/time.test.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
import {isValidDate, parseRelativeTime} from './time';
|
||||
import moment from 'moment';
|
||||
|
||||
moment.updateLocale('en', {
|
||||
week: {
|
||||
dow: 1, // monday
|
||||
doy: moment.localeData('en').firstDayOfYear(),
|
||||
},
|
||||
});
|
||||
|
||||
it('should test for valid date', () => {
|
||||
expect(isValidDate('2017-05-05')).toBe(false);
|
||||
expect(isValidDate('2017-05-05T15:23')).toBe(false);
|
||||
expect(isValidDate('2017-05-05 15:23')).toBe(true);
|
||||
});
|
||||
|
||||
// 2018-10-15 Monday
|
||||
// 2018-10-22 Monday
|
||||
|
||||
// 2019-10-07 Monday
|
||||
// 2019-10-14 Monday
|
||||
// 2019-10-21 Monday
|
||||
|
||||
it('should parse', () => {
|
||||
expectSuccess(parseRelativeTime('now-1d', 'startOf', moment('2019-10-20T15:55:00'))).toEqual('2019-10-19 15:55:00');
|
||||
expectSuccess(parseRelativeTime('now-120s', 'startOf', moment('2019-10-20T15:55:15'))).toEqual('2019-10-20 15:53:15');
|
||||
expectSuccess(parseRelativeTime('now-1d-1h', 'startOf', moment('2019-10-20T15:55:00'))).toEqual('2019-10-19 14:55:00');
|
||||
expectSuccess(parseRelativeTime('now/w', 'startOf', moment('2019-10-20T15:55:15'))).toEqual('2019-10-14 00:00:00');
|
||||
expectSuccess(parseRelativeTime('now/w', 'endOf', moment('2019-10-20T15:55:15'))).toEqual('2019-10-20 23:59:59');
|
||||
expectSuccess(parseRelativeTime('now-1w/w', 'startOf', moment('2019-10-20T15:55:15'))).toEqual('2019-10-07 00:00:00');
|
||||
expectSuccess(parseRelativeTime('now-1y+1w/w', 'startOf', moment('2019-10-20T15:55:15'))).toEqual('2018-10-22 00:00:00');
|
||||
expectSuccess(parseRelativeTime('now/d+5h', 'startOf', moment('2019-10-20T15:55:00'))).toEqual('2019-10-20 05:00:00');
|
||||
expectSuccess(parseRelativeTime('now/y', 'startOf', moment('2019-10-20T15:55:15'))).toEqual('2019-01-01 00:00:00');
|
||||
});
|
||||
|
||||
const expectSuccess = (value: ReturnType<typeof parseRelativeTime>) => {
|
||||
if (value.success) {
|
||||
return expect(value.value.format('YYYY-MM-DD HH:mm:ss'));
|
||||
}
|
||||
expect(value.error).toEqual('no error');
|
||||
return expect('');
|
||||
};
|
||||
142
ui/src/utils/time.ts
Normal file
142
ui/src/utils/time.ts
Normal file
@@ -0,0 +1,142 @@
|
||||
import moment from 'moment';
|
||||
|
||||
interface Success {
|
||||
success: true;
|
||||
value: moment.Moment;
|
||||
}
|
||||
|
||||
interface Failure {
|
||||
success: false;
|
||||
error: string;
|
||||
}
|
||||
|
||||
enum Type {
|
||||
Operation = 'operation',
|
||||
Unit = 'unit',
|
||||
Value = 'value',
|
||||
}
|
||||
|
||||
enum Operation {
|
||||
Divide = '/',
|
||||
Add = '+',
|
||||
Substract = '-',
|
||||
}
|
||||
|
||||
enum Unit {
|
||||
Year = 'y',
|
||||
Month = 'M',
|
||||
Week = 'w',
|
||||
Day = 'd',
|
||||
Hour = 'h',
|
||||
Minute = 'm',
|
||||
Second = 's',
|
||||
}
|
||||
|
||||
export const parseRelativeTime = (value: string, divide: 'endOf' | 'startOf', nowDate = moment()): Success | Failure => {
|
||||
if (isValidDate(value)) {
|
||||
return success(asDate(value));
|
||||
}
|
||||
|
||||
if (value.substr(0, 3) === 'now') {
|
||||
let time = nowDate;
|
||||
let currentIndex = 'now'.length;
|
||||
|
||||
let expectNext = Type.Operation;
|
||||
let lastOperation = Operation.Add;
|
||||
let lastNumber = -1;
|
||||
while (currentIndex < value.length) {
|
||||
const currentChar = value.charAt(currentIndex);
|
||||
switch (expectNext) {
|
||||
case Type.Operation:
|
||||
if (!isOperation(currentChar)) {
|
||||
return failure('Expected one of / + - at index ' + currentIndex + ' but was ' + currentChar);
|
||||
}
|
||||
lastOperation = currentChar;
|
||||
expectNext = currentChar === Operation.Divide ? Type.Unit : Type.Value;
|
||||
currentIndex++;
|
||||
break;
|
||||
case Type.Value:
|
||||
if (isNaN(parseInt(currentChar, 10))) {
|
||||
return failure('Expected number at index ' + currentIndex + ' but was ' + currentChar);
|
||||
}
|
||||
let valueIndex = currentIndex;
|
||||
while (!isNaN(parseInt(value.charAt(valueIndex + 1), 10)) && valueIndex + 1 < value.length) {
|
||||
valueIndex++;
|
||||
}
|
||||
lastNumber = parseInt(value.substr(currentIndex, valueIndex), 10);
|
||||
|
||||
expectNext = Type.Unit;
|
||||
currentIndex = valueIndex + 1;
|
||||
break;
|
||||
case Type.Unit:
|
||||
if (!isUnit(currentChar)) {
|
||||
return failure(
|
||||
'Expected unit (' + Object.values(Unit) + ') at index ' + currentIndex + ' but was ' + currentChar
|
||||
);
|
||||
}
|
||||
|
||||
// tslint:disable-next-line:no-nested-switch
|
||||
switch (lastOperation) {
|
||||
case Operation.Divide:
|
||||
time = time[divide](currentChar);
|
||||
expectNext = Type.Operation;
|
||||
break;
|
||||
case Operation.Add:
|
||||
time = time.add(lastNumber, currentChar);
|
||||
expectNext = Type.Operation;
|
||||
break;
|
||||
case Operation.Substract:
|
||||
time = time.subtract(lastNumber, currentChar);
|
||||
expectNext = Type.Operation;
|
||||
break;
|
||||
default:
|
||||
throw new Error('oops');
|
||||
}
|
||||
|
||||
currentIndex++;
|
||||
break;
|
||||
default:
|
||||
throw new Error('oopsie');
|
||||
}
|
||||
}
|
||||
if (expectNext === Type.Unit) {
|
||||
return failure('Expected unit at the end but got nothing');
|
||||
}
|
||||
if (expectNext === Type.Value) {
|
||||
return failure('Expected number at the end but got nothing');
|
||||
}
|
||||
return success(time);
|
||||
}
|
||||
|
||||
if (value.indexOf('now') !== -1) {
|
||||
return failure("'now' must be at the start");
|
||||
}
|
||||
|
||||
return failure("Expected valid date or 'now' at index 0");
|
||||
};
|
||||
|
||||
export const success = (value: moment.Moment): Success => {
|
||||
return {success: true, value};
|
||||
};
|
||||
export const failure = (error: string): Failure => {
|
||||
return {success: false, error};
|
||||
};
|
||||
|
||||
const isOperation = (char: string): char is Operation => {
|
||||
return Object.values(Operation).indexOf(char as Operation) !== -1;
|
||||
};
|
||||
const isUnit = (char: string): char is Unit => {
|
||||
return Object.values(Unit).indexOf(char as Unit) !== -1;
|
||||
};
|
||||
|
||||
export const isValidDate = (value: string, format?: string) => {
|
||||
return asDate(value, format).isValid();
|
||||
};
|
||||
|
||||
export const asDate = (value: string, format = 'YYYY-MM-DD HH:mm') => {
|
||||
return moment(value, format, true);
|
||||
};
|
||||
export const isSameDate = (from: moment.Moment, to?: moment.Moment): boolean => {
|
||||
const fromString = from.format('YYYYMMDD');
|
||||
return to === undefined || fromString === to.format('YYYYMMDD');
|
||||
};
|
||||
37
ui/tsconfig.json
Normal file
37
ui/tsconfig.json
Normal file
@@ -0,0 +1,37 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "es5",
|
||||
"lib": [
|
||||
"dom",
|
||||
"dom.iterable",
|
||||
"esnext"
|
||||
],
|
||||
"allowJs": false,
|
||||
"skipLibCheck": true,
|
||||
"esModuleInterop": true,
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"strict": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"module": "esnext",
|
||||
"moduleResolution": "node",
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"noEmit": true,
|
||||
"allowUnreachableCode": false,
|
||||
"allowUnusedLabels": false,
|
||||
"alwaysStrict": true,
|
||||
"noImplicitAny": true,
|
||||
"noImplicitReturns": true,
|
||||
"noImplicitThis": true,
|
||||
"suppressImplicitAnyIndexErrors": false,
|
||||
"strictPropertyInitialization": true,
|
||||
"strictNullChecks": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"strictFunctionTypes": true,
|
||||
"jsx": "preserve"
|
||||
},
|
||||
"include": [
|
||||
"src"
|
||||
]
|
||||
}
|
||||
120
ui/tslint.json
Normal file
120
ui/tslint.json
Normal file
@@ -0,0 +1,120 @@
|
||||
{
|
||||
"extends": "tslint-sonarts",
|
||||
"rules": {
|
||||
"array-type": [true, "array-simple"],
|
||||
"arrow-return-shorthand": true,
|
||||
"ban": [true,
|
||||
{"name": "Array", "message": "tsstyle#array-constructor"}
|
||||
],
|
||||
"ban-types": [true,
|
||||
["Object", "Use {} instead."],
|
||||
["String", "Use 'string' instead."],
|
||||
["Number", "Use 'number' instead."],
|
||||
["Boolean", "Use 'boolean' instead."],
|
||||
["Function", "Use (..) => .. instead."]
|
||||
],
|
||||
"class-name": true,
|
||||
"curly": [true],
|
||||
"forin": true,
|
||||
"object-literal-shorthand": true,
|
||||
"label-position": true,
|
||||
"member-access": [true],
|
||||
"new-parens": true,
|
||||
"no-angle-bracket-type-assertion": true,
|
||||
"no-any": true,
|
||||
"no-arg": true,
|
||||
"no-conditional-assignment": true,
|
||||
"no-construct": true,
|
||||
"no-shadowed-variable": true,
|
||||
"prefer-object-spread": true,
|
||||
"no-debugger": true,
|
||||
"no-default-export": true,
|
||||
"no-duplicate-variable": true,
|
||||
"no-inferrable-types": true,
|
||||
"no-namespace": [true, "allow-declarations"],
|
||||
"cyclomatic-complexity": [true, 20],
|
||||
"no-duplicate-imports": true,
|
||||
"no-reference": true,
|
||||
"no-string-throw": true,
|
||||
"no-unused-expression": true,
|
||||
"no-var-keyword": true,
|
||||
"object-literal-shorthand": true,
|
||||
"only-arrow-functions": [true, "allow-declarations", "allow-named-functions"],
|
||||
"prefer-const": true,
|
||||
"radix": true,
|
||||
"semicolon": [true, "always", "ignore-bound-class-methods"],
|
||||
"switch-default": true,
|
||||
"triple-equals": [true, "allow-null-check"],
|
||||
"use-isnan": true,
|
||||
"variable-name": [
|
||||
true,
|
||||
"check-format",
|
||||
"ban-keywords",
|
||||
"allow-pascal-case",
|
||||
"allow-leading-underscore",
|
||||
"allow-trailing-underscore"
|
||||
],
|
||||
"cognitive-complexity": [true, 20],
|
||||
"mccabe-complexity": [true, 20],
|
||||
"no-duplicate-string": false,
|
||||
"no-nested-incdec": false,
|
||||
"no-all-duplicated-branches": true,
|
||||
"consecutive-overloads": true,
|
||||
"max-union-size": false,
|
||||
"no-accessor-field-mismatch": true,
|
||||
"no-array-delete": true,
|
||||
"no-big-function": false,
|
||||
"no-case-with-or": true,
|
||||
"no-collection-size-mischeck": true,
|
||||
"no-commented-code": true,
|
||||
"no-dead-store": true,
|
||||
"no-duplicate-in-composite": true,
|
||||
"no-duplicated-branches": true,
|
||||
"no-element-overwrite": true,
|
||||
"no-empty-destructuring": true,
|
||||
"no-empty-nested-blocks": true,
|
||||
"no-extra-semicolon": true,
|
||||
"no-gratuitous-expressions": true,
|
||||
"no-hardcoded-credentials": true,
|
||||
"no-identical-conditions": true,
|
||||
"no-identical-expressions": true,
|
||||
"no-identical-functions": true,
|
||||
"no-ignored-initial-value": true,
|
||||
"no-ignored-return": true,
|
||||
"no-in-misuse": true,
|
||||
"no-inconsistent-return": true,
|
||||
"no-misleading-array-reverse": true,
|
||||
"no-misspelled-operator": true,
|
||||
"no-multiline-string-literals": true,
|
||||
"no-nested-template-literals": true,
|
||||
"no-redundant-boolean": true,
|
||||
"no-redundant-jump": true,
|
||||
"no-redundant-parentheses": true,
|
||||
"no-return-type-any": true,
|
||||
"no-same-line-conditional": true,
|
||||
"no-self-assignment": true,
|
||||
"no-small-switch": true,
|
||||
"no-statements-same-line": true,
|
||||
"no-unconditional-jump": true,
|
||||
"no-undefined-argument": true,
|
||||
"no-unenclosed-multiline-block": true,
|
||||
"no-unthrown-error": true,
|
||||
"no-unused-array": true,
|
||||
"no-use-of-empty-return-value": true,
|
||||
"no-useless-cast": true,
|
||||
"no-useless-increment": true,
|
||||
"no-useless-intersection": true,
|
||||
"no-variable-usage-before-declaration": true,
|
||||
"parameters-max-number": true,
|
||||
"prefer-default-last": true,
|
||||
"prefer-immediate-return": true,
|
||||
"prefer-promise-shorthand": true,
|
||||
"use-primitive-type": true,
|
||||
"use-type-alias": true
|
||||
},
|
||||
"linterOptions": {
|
||||
"exclude": [
|
||||
"node_modules/**/*.ts"
|
||||
]
|
||||
}
|
||||
}
|
||||
13130
ui/yarn.lock
Normal file
13130
ui/yarn.lock
Normal file
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user