diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml
index 89faa2f6..e6ee1bcf 100644
--- a/.github/workflows/build.yml
+++ b/.github/workflows/build.yml
@@ -16,8 +16,6 @@ jobs:
- run: make install
- run: make generate
- run: make build-js
- env:
- NODE_OPTIONS: --openssl-legacy-provider
- run: make lint
- run: make test
- if: startsWith(github.ref, 'refs/tags/v')
diff --git a/docker/Dockerfile.dev b/docker/Dockerfile.dev
index 3061f659..045140b9 100644
--- a/docker/Dockerfile.dev
+++ b/docker/Dockerfile.dev
@@ -1,6 +1,8 @@
FROM golang:1.18.1 as builder
-RUN apt-get update && apt-get install --yes nodejs npm && npm install --global yarn
+RUN apt-get update && apt-get install --yes curl && \
+ curl -fsSL https://deb.nodesource.com/setup_20.x | bash - && \
+ apt-get install --yes nodejs && npm install --global yarn
WORKDIR /app
COPY . .
diff --git a/ui/.prettierrc b/ui/.prettierrc
index 029bca26..e3c1506e 100644
--- a/ui/.prettierrc
+++ b/ui/.prettierrc
@@ -6,6 +6,6 @@
"singleQuote": true,
"trailingComma": "es5",
"bracketSpacing": false,
- "jsxBracketSameLine": true,
+ "bracketSameLine": true,
"arrowParens": "always"
}
\ No newline at end of file
diff --git a/ui/codegen.yml b/ui/codegen.yml
new file mode 100644
index 00000000..a40933f5
--- /dev/null
+++ b/ui/codegen.yml
@@ -0,0 +1,10 @@
+schema: ../schema.graphql
+documents: 'src/gql/*.ts'
+generates:
+ src/gql/__generated__/index.ts:
+ plugins:
+ - typescript
+ - typescript-operations
+ config:
+ skipTypename: false
+ enumsAsTypes: false
diff --git a/ui/eslint.config.mjs b/ui/eslint.config.mjs
new file mode 100644
index 00000000..03e19e94
--- /dev/null
+++ b/ui/eslint.config.mjs
@@ -0,0 +1,90 @@
+import js from '@eslint/js';
+import tseslint from 'typescript-eslint';
+import react from 'eslint-plugin-react';
+import reactHooks from 'eslint-plugin-react-hooks';
+import sonarjs from 'eslint-plugin-sonarjs';
+import prettierConfig from 'eslint-config-prettier';
+import globals from 'globals';
+
+export default tseslint.config(
+ {
+ ignores: ['build/**', 'src/gql/__generated__/**', 'node_modules/**'],
+ },
+ js.configs.recommended,
+ ...tseslint.configs.recommended,
+ react.configs.flat.recommended,
+ sonarjs.configs.recommended,
+ {
+ // Only the two long-stable hook rules for now; eslint-plugin-react-hooks'
+ // newer React-Compiler-oriented checks (immutability, refs, ...) are part
+ // of a deliberate, separate ruleset expansion + code fixups, not bundled here.
+ plugins: {
+ 'react-hooks': reactHooks,
+ },
+ rules: {
+ 'react-hooks/rules-of-hooks': 'error',
+ 'react-hooks/exhaustive-deps': 'warn',
+ },
+ },
+ prettierConfig,
+ {
+ languageOptions: {
+ globals: {
+ ...globals.browser,
+ ...globals.node,
+ },
+ },
+ settings: {
+ react: {
+ version: '18.3',
+ },
+ },
+ rules: {
+ 'react/prop-types': 'off',
+ 'react/react-in-jsx-scope': 'off',
+ 'react/display-name': 'off',
+ '@typescript-eslint/no-unused-vars': ['error', {argsIgnorePattern: '^_', ignoreRestSiblings: true}],
+ 'no-empty': ['error', {allowEmptyCatch: true}],
+ '@typescript-eslint/no-empty-object-type': 'off',
+
+ // Mapped from the old tslint.json + tslint-sonarts ruleset (see the tslint.json
+ // that existed at this path before the tslint->ESLint migration, in git history).
+ '@typescript-eslint/no-explicit-any': 'error',
+ '@typescript-eslint/explicit-member-accessibility': 'error',
+ '@typescript-eslint/naming-convention': [
+ 'error',
+ {
+ selector: 'variableLike',
+ format: ['camelCase', 'PascalCase', 'UPPER_CASE'],
+ leadingUnderscore: 'allow',
+ trailingUnderscore: 'allow',
+ },
+ ],
+ eqeqeq: ['error', 'always', {null: 'ignore'}],
+ 'no-var': 'error',
+ curly: 'error',
+ 'guard-for-in': 'error',
+ 'default-case': 'error',
+ 'no-shadow': 'off',
+ '@typescript-eslint/no-shadow': 'error',
+ 'object-shorthand': 'error',
+ 'no-duplicate-imports': 'error',
+ radix: 'error',
+ 'use-isnan': 'error',
+ 'no-debugger': 'error',
+ 'no-cond-assign': 'error',
+ '@typescript-eslint/no-inferrable-types': 'error',
+ '@typescript-eslint/no-namespace': ['error', {allowDeclarations: true}],
+ 'no-redeclare': 'off',
+ '@typescript-eslint/no-redeclare': 'error',
+ 'no-throw-literal': 'error',
+ 'no-unused-expressions': 'off',
+ '@typescript-eslint/no-unused-expressions': 'error',
+ complexity: ['error', 20],
+
+ // sonarjs's recommended preset is tuned for large codebases and disables a few
+ // rules by default (e.g. cognitive-complexity) that tslint-sonarts had enabled.
+ 'sonarjs/cognitive-complexity': ['error', 20],
+ },
+ }
+);
diff --git a/ui/public/index.html b/ui/index.html
similarity index 50%
rename from ui/public/index.html
rename to ui/index.html
index 0432ead9..cdd064dc 100644
--- a/ui/public/index.html
+++ b/ui/index.html
@@ -1,14 +1,14 @@
-
+
-
-
-
-
-
-
-
+
+
+
+
+
+
+
@@ -17,5 +17,6 @@
+
diff --git a/ui/package.json b/ui/package.json
index 0a17cde8..b4b983c5 100644
--- a/ui/package.json
+++ b/ui/package.json
@@ -3,80 +3,78 @@
"version": "0.1.0",
"private": true,
"homepage": ".",
+ "resolutions": {
+ "@types/react": "^18.3.31",
+ "@types/react-dom": "^18.3.7"
+ },
"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",
+ "@apollo/client": "^3.14.1",
+ "@emotion/react": "^11.14.0",
+ "@emotion/styled": "^11.14.1",
+ "@fullcalendar/core": "^6.1.21",
+ "@fullcalendar/daygrid": "^6.1.21",
+ "@fullcalendar/interaction": "^6.1.21",
+ "@fullcalendar/moment": "^6.1.21",
+ "@fullcalendar/react": "^6.1.21",
+ "@fullcalendar/timegrid": "^6.1.21",
+ "@mui/icons-material": "^5.18.0",
+ "@mui/material": "^5.18.0",
+ "@mui/styles": "^5.18.0",
+ "@mui/system": "^5.18.0",
+ "@mui/x-date-pickers": "^7.29.4",
+ "color-hash": "^2.0.2",
+ "events": "^3.3.0",
"get-best-contrast-color": "^0.2.2",
- "graphql": "^14.5.8",
- "lodash.clonedeep": "^4.5.0",
+ "graphql": "^16.14.2",
"moment": "^2.30.1",
"moment-timezone": "^0.5.45",
- "notistack": "^0.6.1",
+ "notistack": "^3.0.2",
"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",
+ "react": "^18.3.1",
+ "react-colorful": "^5.6.1",
+ "react-dom": "^18.3.1",
+ "react-grid-layout": "^1.5.3",
+ "react-router-dom": "^6.30.4",
"recharts": "^1.8.5",
"typeface-roboto": "^0.0.54",
- "typescript": "^3.7.2"
+ "typescript": "^5.9.3"
},
"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/color-hash": "^2.0.0",
+ "@types/node": "^22.0.0",
"@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/react": "^18.3.31",
+ "@types/react-dom": "^18.3.7",
+ "@types/react-grid-layout": "^1.3.6",
"@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"
+ "@vitejs/plugin-react": "^4.7.0",
+ "@eslint/js": "^10.0.1",
+ "@graphql-codegen/cli": "^5.0.7",
+ "@graphql-codegen/typescript": "^5.0.10",
+ "@graphql-codegen/typescript-operations": "^5.0.10",
+ "eslint": "^10.7.0",
+ "eslint-config-prettier": "^10.1.8",
+ "eslint-plugin-react": "^7.37.5",
+ "eslint-plugin-react-hooks": "^7.1.1",
+ "eslint-plugin-sonarjs": "^4.2.0",
+ "globals": "^17.7.0",
+ "jsdom": "^29.1.1",
+ "prettier": "^3.9.5",
+ "rimraf": "^6.1.3",
+ "typescript-eslint": "^8.64.0",
+ "vite": "^6.4.3",
+ "vitest": "^3.2.4"
},
"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",
+ "lint": "eslint . --fix",
+ "lint:check": "eslint .",
+ "generate": "graphql-codegen --config codegen.yml",
"format": "prettier \"**/*.{html,ts,tsx,css}\" --write",
"format:check": "prettier \"**/*.{html,ts,tsx,css}\" --list-different",
- "start": "react-scripts start",
- "build": "react-scripts build",
+ "start": "vite",
+ "build": "vite 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"
+ "test": "vitest run"
+ }
}
diff --git a/ui/serve.go b/ui/serve.go
index b6462f2c..15fdf96f 100644
--- a/ui/serve.go
+++ b/ui/serve.go
@@ -20,11 +20,9 @@ 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) {
+ r.Handle("/assets/{resource}", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Cache-Control", "public, max-age=31536000, immutable")
fileServer.ServeHTTP(w, r)
}))
diff --git a/ui/src/Root.tsx b/ui/src/Root.tsx
index de6f9841..ce8b8881 100644
--- a/ui/src/Root.tsx
+++ b/ui/src/Root.tsx
@@ -6,8 +6,8 @@ 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 {LocalizationProvider} from '@mui/x-date-pickers';
+import {AdapterMoment} from '@mui/x-date-pickers/AdapterMoment';
import {Router} from './Router';
import {HashRouter} from 'react-router-dom';
import {BootUserSettings} from './provider/UserSettingsProvider';
@@ -17,13 +17,13 @@ export const Root = () => {
-
+
-
+
diff --git a/ui/src/Router.tsx b/ui/src/Router.tsx
index ba97f627..aa5e637f 100644
--- a/ui/src/Router.tsx
+++ b/ui/src/Router.tsx
@@ -1,16 +1,15 @@
import * as React from 'react';
-import {useQuery} from '@apollo/react-hooks';
-import {CurrentUser} from './gql/__generated__/CurrentUser';
+import {useQuery, ApolloError} from '@apollo/client';
+import {CurrentUserQuery} from './gql/__generated__';
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 {Typography} from '@mui/material';
+import Button from '@mui/material/Button';
+import Grid from '@mui/material/Grid';
import {DefaultPaper} from './common/DefaultPaper';
import {Page} from './common/Page';
-import {Redirect, Route, Switch} from 'react-router';
+import {Navigate, Route, Routes} from 'react-router-dom';
import {DailyPage} from './timespan/DailyPage';
import {DevicesPage} from './devices/DevicesPage';
import {UsersPage} from './user/UsersPage';
@@ -21,39 +20,48 @@ import {SettingsPage} from './setting/SettingsPage';
import {CalendarPage} from './timespan/calendar/CalendarPage';
export const Router = () => {
- const {loading, error, data, refetch} = useQuery(gqlUser.CurrentUser);
+ const {loading, error, data, refetch} = useQuery(gqlUser.CurrentUser);
if (loading) {
return ;
}
if (error) {
- return ;
+ return ;
}
const loggedIn = data && data.user;
const admin = data && data.user && data.user.admin;
return (
-
- {loggedIn ? : }
- {loggedIn ? null : }
-
-
-
-
-
-
-
-
-
- {admin ? : null}
- } />
-
-
+
+ : } />
+
+
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+ {admin ? } /> : null}
+ } />
+
+
+ ) : (
+
+ )
+ }
+ />
+
);
};
-const Error: React.FC<{error: ApolloError; refetch: () => void}> = ({error, refetch}) => {
+const ErrorDisplay: React.FC<{error: ApolloError; refetch: () => void}> = ({error, refetch}) => {
return (
-
+
diff --git a/ui/src/common/Center.tsx b/ui/src/common/Center.tsx
index a45585e7..0bdb28de 100644
--- a/ui/src/common/Center.tsx
+++ b/ui/src/common/Center.tsx
@@ -1,5 +1,5 @@
import * as React from 'react';
-export const Center: React.FC = ({children}) => {
+export const Center: React.FC = ({children}) => {
return {children}
;
};
diff --git a/ui/src/common/CenteredSpinner.tsx b/ui/src/common/CenteredSpinner.tsx
index 6a875373..34773146 100644
--- a/ui/src/common/CenteredSpinner.tsx
+++ b/ui/src/common/CenteredSpinner.tsx
@@ -1,9 +1,9 @@
-import CircularProgress from '@material-ui/core/CircularProgress';
-import Grid from '@material-ui/core/Grid';
+import CircularProgress from '@mui/material/CircularProgress';
+import Grid from '@mui/material/Grid';
import * as React from 'react';
export const CenteredSpinner = () => (
-
+
diff --git a/ui/src/common/ConfirmDialog.tsx b/ui/src/common/ConfirmDialog.tsx
index f0cc0667..fdbbebdc 100644
--- a/ui/src/common/ConfirmDialog.tsx
+++ b/ui/src/common/ConfirmDialog.tsx
@@ -1,9 +1,9 @@
-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 Button from '@mui/material/Button';
+import Dialog from '@mui/material/Dialog';
+import DialogActions from '@mui/material/DialogActions';
+import DialogContent from '@mui/material/DialogContent';
+import DialogContentText from '@mui/material/DialogContentText';
+import DialogTitle from '@mui/material/DialogTitle';
import React from 'react';
interface Props {
@@ -12,7 +12,7 @@ interface Props {
fOnSubmit: () => void;
}
-export const ConfirmDialog: React.FC = ({children, title, fClose, fOnSubmit}) => {
+export const ConfirmDialog: React.FC> = ({children, title, fClose, fOnSubmit}) => {
const submitAndClose = () => {
fOnSubmit();
fClose();
diff --git a/ui/src/common/DateTimeSelector.tsx b/ui/src/common/DateTimeSelector.tsx
index 689039e2..f6b3ffa1 100644
--- a/ui/src/common/DateTimeSelector.tsx
+++ b/ui/src/common/DateTimeSelector.tsx
@@ -1,9 +1,9 @@
import * as React from 'react';
-import {KeyboardDateTimePicker} from '@material-ui/pickers';
-import * as moment from 'moment';
+import {DesktopDateTimePicker} from '@mui/x-date-pickers/DesktopDateTimePicker';
+import moment from 'moment';
import {uglyConvertToLocalTime} from '../timespan/timeutils';
import {useSettings} from '../gql/settings';
-import {DateTimeInputStyle} from '../gql/__generated__/globalTypes';
+import {DateTimeInputStyle} from '../gql/__generated__';
interface DateTimeSelectorProps {
selectedDate: moment.Moment;
@@ -16,6 +16,21 @@ interface DateTimeSelectorProps {
export const DateTimeSelector: React.FC = React.memo(
({selectedDate, onSelectDate, showDate, label, popoverOpen = () => {}}) => {
const {done, dateTimeInputStyle} = useSettings();
+ const [open, setOpen] = React.useState(false);
+ // The field fires onChange on every section edit (hour, minute, meridiem individually),
+ // not just once a full value has been typed - if we forwarded those straight to
+ // onSelectDate, a half-typed intermediate value could get persisted/trigger the parent's
+ // before/after-the-other-field correction. So onChange only updates this local draft (for
+ // responsive typing), and onSelectDate only fires once editing is actually done: on blur,
+ // or on accepting a value from the popup calendar/clock.
+ const [draft, setDraft] = React.useState(() => uglyConvertToLocalTime(selectedDate));
+ const isEditing = React.useRef(false);
+
+ React.useEffect(() => {
+ if (!isEditing.current) {
+ setDraft(uglyConvertToLocalTime(selectedDate));
+ }
+ }, [selectedDate]);
if (!done) {
return ...;
@@ -27,59 +42,85 @@ export const DateTimeSelector: React.FC = React.memo(
type="datetime-local"
value={selectedDate.format(selectedDate.format('YYYY-MM-DDTHH:mm'))}
onChange={(e) => {
- onSelectDate(moment.default(e.target.value));
+ onSelectDate(moment(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;
+ const commit = (date: moment.Moment) => {
+ if (!showDate && !open) {
+ date = date.clone().set({
+ date: selectedDate.date(),
+ month: selectedDate.month(),
+ year: selectedDate.year(),
+ });
+ }
+ if (uglyConvertToLocalTime(selectedDate).isSame(date)) {
+ return;
+ }
+ onSelectDate(date);
+ };
+
return (
- {
- popoverOpen(true);
- setOpen(true);
- },
- onExited: () => {
- popoverOpen(false);
- setOpen(false);
+ sx={{
+ width: (showDate ? 185 : 105) + (ampm ? 20 : 0),
+ // The calendar-icon adornment centers itself across the whole labeled field
+ // (label row + value row), while the value text sits lower - offset by the
+ // 12px marginTop MUI puts on the input to make room for the shrunk label
+ // above it. Match that offset so the icon lines up with the value/duration
+ // text instead of floating above it.
+ '& .MuiInputAdornment-root': {marginTop: '12px'},
+ }}
+ onOpen={() => {
+ popoverOpen(true);
+ setOpen(true);
+ isEditing.current = true;
+ }}
+ onClose={() => {
+ popoverOpen(false);
+ setOpen(false);
+ }}
+ slotProps={{
+ textField: {
+ title: selectedDate.format(),
+ variant: 'standard',
+ margin: 'none',
+ InputProps: {disableUnderline: true},
+ onFocus: () => {
+ isEditing.current = true;
+ },
+ onBlur: () => {
+ isEditing.current = false;
+ if (draft.isValid()) {
+ commit(draft);
+ }
+ },
},
}}
- margin="none"
- value={uglyConvertToLocalTime(selectedDate).format()}
- onChange={(date: moment.Moment) => {
+ value={draft}
+ onChange={(date: moment.Moment | null) => {
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;
+ setDraft(date);
+ }}
+ onAccept={(date: moment.Moment | null) => {
+ isEditing.current = false;
+ if (date && date.isValid()) {
+ commit(date);
}
-
- onSelectDate(date);
}}
ampm={ampm}
format={format}
label={label}
- openTo={showDate ? 'date' : 'hours'}
+ openTo={showDate ? 'day' : 'hours'}
/>
);
}
diff --git a/ui/src/common/DefaultPaper.tsx b/ui/src/common/DefaultPaper.tsx
index bd27a117..cfd31f24 100644
--- a/ui/src/common/DefaultPaper.tsx
+++ b/ui/src/common/DefaultPaper.tsx
@@ -1,10 +1,15 @@
-import Paper from '@material-ui/core/Paper';
-import {makeStyles} from '@material-ui/core/styles';
+import Paper from '@mui/material/Paper';
+import makeStyles from '@mui/styles/makeStyles';
import * as React from 'react';
const useStyles = makeStyles((theme) => ({
root: {
- ...theme.mixins.gutters(),
+ paddingLeft: theme.spacing(2),
+ paddingRight: theme.spacing(2),
+ [theme.breakpoints.up('sm')]: {
+ paddingLeft: theme.spacing(3),
+ paddingRight: theme.spacing(3),
+ },
paddingTop: theme.spacing(4),
paddingBottom: theme.spacing(3),
textAlign: 'center',
@@ -12,7 +17,7 @@ const useStyles = makeStyles((theme) => ({
borderTop: `5px solid ${theme.palette.primary.main}`,
},
}));
-export const DefaultPaper: React.FC = ({children}) => {
+export const DefaultPaper: React.FC = ({children}) => {
const classes = useStyles();
return (
diff --git a/ui/src/common/Fade.tsx b/ui/src/common/Fade.tsx
index 33c31687..ba7ebfa2 100644
--- a/ui/src/common/Fade.tsx
+++ b/ui/src/common/Fade.tsx
@@ -8,7 +8,11 @@ const defaultStyle = {
opacity: 0,
};
-export const Fade: React.FC<{fullyVisible: boolean; opacity?: number}> = ({fullyVisible, children, opacity = 0.4}) => {
+export const Fade: React.FC> = ({
+ fullyVisible,
+ children,
+ opacity = 0.4,
+}) => {
const transitionStyles: Record = {
entering: {opacity: 1},
entered: {opacity: 1},
diff --git a/ui/src/common/Page.tsx b/ui/src/common/Page.tsx
index 69111e32..4231cacf 100644
--- a/ui/src/common/Page.tsx
+++ b/ui/src/common/Page.tsx
@@ -1,41 +1,38 @@
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 AppBar from '@mui/material/AppBar';
+import CssBaseline from '@mui/material/CssBaseline';
+import Divider from '@mui/material/Divider';
+import Drawer from '@mui/material/Drawer';
+import Hidden from '@mui/material/Hidden';
+import IconButton from '@mui/material/IconButton';
+import UsersIcon from '@mui/icons-material/SupervisorAccount';
+import List from '@mui/material/List';
+import ListItem from '@mui/material/ListItem';
+import ListItemIcon from '@mui/material/ListItemIcon';
+import ListItemText from '@mui/material/ListItemText';
+import DashboardIcon from '@mui/icons-material/Dashboard';
+import MenuIcon from '@mui/icons-material/Menu';
+import SettingsIcon from '@mui/icons-material/Settings';
+import DevicesIcon from '@mui/icons-material/DevicesOther';
+import LabelIcon from '@mui/icons-material/Label';
+import DashboardManageIcon from '@mui/icons-material/ListAlt';
+import TimeLineIcon from '@mui/icons-material/Timeline';
+import CalendarIcon from '@mui/icons-material/CalendarToday';
+import Toolbar from '@mui/material/Toolbar';
+import Typography from '@mui/material/Typography';
+import {ListSubheader, Menu} from '@mui/material';
+import HrefLink from '@mui/material/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 Button from '@mui/material/Button';
+import AccountCircle from '@mui/icons-material/AccountCircle';
+import {Link, matchPath, useLocation} from 'react-router-dom';
+import MenuItem from '@mui/material/MenuItem';
+import {useMutation, useQuery} from '@apollo/client';
+import {LogoutMutation, VersionQuery, CurrentUserQuery, DashboardsQuery} from '../gql/__generated__';
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';
+import {Dashboard} from '../gql/types';
+import makeStyles from '@mui/styles/makeStyles';
const drawerWidth = 240;
@@ -95,27 +92,55 @@ const useStyles = makeStyles((theme) => ({
},
}));
-// tslint:disable-next-line:no-any
+// MUI's `component` prop isn't polymorphism-typed against an arbitrary forwardRef component.
+// eslint-disable-next-line @typescript-eslint/no-explicit-any
const routerLink = (to: string): any => {
- return React.forwardRef((props, ref) => );
+ return React.forwardRef((props, ref) => );
};
-export const Page: React.FC = ({children}) => {
- const classes = useStyles();
- const {data} = useQuery(gqlUser.CurrentUser);
+const staticPageTitles: Array<{path: string; title: string}> = [
+ {path: '/timesheet/list', title: 'Timesheet / List'},
+ {path: '/timesheet/calendar', title: 'Timesheet / Calendar'},
+ {path: '/user/settings', title: 'User / Settings'},
+ {path: '/user/devices', title: 'User / Devices'},
+ {path: '/user/tags', title: 'User / Tags'},
+ {path: '/admin/users', title: 'Admin / Users'},
+ {path: '/dashboards', title: 'Dashboards / Manage'},
+];
+
+const getPageTitle = (pathname: string, dashboards: Dashboard[]): string => {
+ for (const {path, title} of staticPageTitles) {
+ if (matchPath(path, pathname)) {
+ return title;
+ }
+ }
+ const dashboardMatch = matchPath('/dashboard/:id/*', pathname);
+ if (dashboardMatch && dashboardMatch.params.id) {
+ const db = dashboards.find((dashboard) => dashboard.id === parseInt(dashboardMatch.params.id!, 10));
+ return 'Dashboards / ' + (db ? db.name : '...');
+ }
+ return '';
+};
+
+export const Page: React.FC = ({children}) => {
+ const classes = useStyles({});
+ const {data} = useQuery(gqlUser.CurrentUser);
const [mobileOpen, setMobileOpen] = React.useState(false);
const [userMenuOpen, setUserMenuOpen] = React.useState(null);
- const [logout] = useMutation(gqlUser.Logout, {refetchQueries: [{query: gqlUser.CurrentUser}]});
- const {data: {version = gqlVersion.VersionDefault.version} = gqlVersion.VersionDefault} = useQuery(
+ const [logout] = useMutation(gqlUser.Logout, {refetchQueries: [{query: gqlUser.CurrentUser}]});
+ const {data: {version = gqlVersion.VersionDefault.version} = gqlVersion.VersionDefault} = useQuery(
gqlVersion.Version
);
- const dashboardsQuery = useQuery(gqlDashboard.Dashboards);
+ const dashboardsQuery = useQuery(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 location = useLocation();
+ const pageTitle = getPageTitle(location.pathname, dashboards);
+
const drawer = (
@@ -214,41 +239,12 @@ export const Page: React.FC = ({children}) => {
color="inherit"
aria-label="Open drawer"
onClick={() => setMobileOpen(!mobileOpen)}
- className={classes.menuButton}>
+ className={classes.menuButton}
+ size="large">
-
-
- Timesheet / List
-
-
- Timesheet / Calendar
-
-
- User / Settings
-
-
- User / Devices
-
-
- User / Tags
-
-
- Admin / Users
-
-
- Dashboards / Manage
-
-
- {(props: RouteChildrenProps<{id: string}>) => {
- const db = dashboards.find(
- (dashboard) => dashboard.id === parseInt(props.match!.params.id, 10)
- );
- return 'Dashboards / ' + (db ? db.name : '...');
- }}
-
-
+ {pageTitle}
@@ -288,7 +284,7 @@ export const Page: React.FC = ({children}) => {
{drawer}
-
+
=
const {start, stop} = useTimeout(() => setErrVisible(true), 200);
const parsed = parseRelativeTime(value, type);
+ let helperText: React.ReactNode;
+ if (small) {
+ helperText = undefined;
+ } else if (errVisible) {
+ helperText = (
+
+ {error}
+
+ );
+ } else {
+ helperText = {!parsed.success ? '...' : parsed.value.format('llll')};
+ }
return (
=
setValue(newValue, result.success);
}}
error={error !== ''}
- helperText={
- small ? (
- undefined
- ) : errVisible ? (
-
- {error}
-
- ) : (
- {!parsed.success ? '...' : parsed.value.format('llll')}
- )
- }
+ helperText={helperText}
label={label}
/>
);
diff --git a/ui/src/common/RelativeTime.tsx b/ui/src/common/RelativeTime.tsx
index c59dcdcf..b4044eba 100644
--- a/ui/src/common/RelativeTime.tsx
+++ b/ui/src/common/RelativeTime.tsx
@@ -1,7 +1,7 @@
import moment from 'moment';
import * as React from 'react';
import {timeRunning} from '../timespan/timeutils';
-import useInterval from '@rooks/use-interval';
+import {useInterval} from '../utils/hooks';
export const RelativeToNow: React.FC<{from: moment.Moment}> = ({from}) => {
const [now, setNow] = React.useState(moment());
diff --git a/ui/src/common/TagChip.tsx b/ui/src/common/TagChip.tsx
index 66f12392..5aca78ff 100644
--- a/ui/src/common/TagChip.tsx
+++ b/ui/src/common/TagChip.tsx
@@ -1,8 +1,10 @@
-import Chip from '@material-ui/core/Chip';
+import Chip from '@mui/material/Chip';
import * as React from 'react';
-// @ts-ignore
+// @ts-expect-error no type declarations for this package
import bestContrast from 'get-best-contrast-color';
-import {makeStyles, Theme} from '@material-ui/core';
+import {Theme} from '@mui/material';
+
+import makeStyles from '@mui/styles/makeStyles';
const useStyles = makeStyles((theme: Theme) => ({
chip: {
diff --git a/ui/src/dashboard/AddDashboardDialog.tsx b/ui/src/dashboard/AddDashboardDialog.tsx
index 33926a04..8d567d7d 100644
--- a/ui/src/dashboard/AddDashboardDialog.tsx
+++ b/ui/src/dashboard/AddDashboardDialog.tsx
@@ -1,15 +1,15 @@
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 Button from '@mui/material/Button';
+import TextField from '@mui/material/TextField';
+import Dialog from '@mui/material/Dialog';
+import DialogActions from '@mui/material/DialogActions';
+import DialogContent from '@mui/material/DialogContent';
+import DialogTitle from '@mui/material/DialogTitle';
+import {useMutation} from '@apollo/client';
import {useSnackbar} from 'notistack';
import {handleError} from '../utils/errors';
import * as gqlDashboard from '../gql/dashboard';
-import {CreateDashboard, CreateDashboardVariables} from '../gql/__generated__/CreateDashboard';
+import {CreateDashboardMutation, CreateDashboardMutationVariables} from '../gql/__generated__';
interface AddTagDialogProps {
open: boolean;
@@ -20,7 +20,7 @@ export const AddDashboardDialog: React.FC = ({close, open}) =
const [name, setName] = React.useState('');
const {enqueueSnackbar} = useSnackbar();
- const [addUser] = useMutation(gqlDashboard.CreateDashboard, {
+ const [addUser] = useMutation(gqlDashboard.CreateDashboard, {
refetchQueries: [{query: gqlDashboard.Dashboards}],
});
const submit = (e: React.FormEvent) => {
diff --git a/ui/src/dashboard/DashboardPage.tsx b/ui/src/dashboard/DashboardPage.tsx
index cc623dda..30d35508 100644
--- a/ui/src/dashboard/DashboardPage.tsx
+++ b/ui/src/dashboard/DashboardPage.tsx
@@ -1,22 +1,27 @@
import * as React from 'react';
-import {useMutation, useQuery} from '@apollo/react-hooks';
+import {useMutation, useQuery} from '@apollo/client';
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 {
+ DashboardsQuery,
+ UpdatePosMutation,
+ UpdatePosMutationVariables,
+ EntryType,
+ StatsInterval,
+ RemoveDashboardEntryMutation,
+ RemoveDashboardEntryMutationVariables,
+} from '../gql/__generated__';
+import {DashboardItem} from '../gql/types';
import {DashboardEntry} from './Entry/DashboardEntry';
-import Button from '@material-ui/core/Button';
-import clone from 'lodash.clonedeep';
+import Button from '@mui/material/Button';
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 {Paper} from '@mui/material';
import {Center} from '../common/Center';
-import {RemoveDashboardEntry, RemoveDashboardEntryVariables} from '../gql/__generated__/RemoveDashboardEntry';
-import {RouteChildrenProps} from 'react-router';
+import {useNavigate, useParams} from 'react-router-dom';
import {useSnackbar} from 'notistack';
import {DateRanges} from './DateRanges';
import {Range} from '../utils/range';
@@ -34,7 +39,7 @@ const cols: Record = {
const WidthAwareReactGrid = WidthProvider(ReactGrid);
const EditId = -1;
-const newEntry = (): Dashboards_dashboards_items => {
+const newEntry = (): DashboardItem => {
return {
__typename: 'DashboardEntry',
title: '',
@@ -78,9 +83,9 @@ const newEntry = (): Dashboards_dashboards_items => {
};
};
-type RouterProps = RouteChildrenProps<{id?: string}>;
-
-export const DashboardPage: React.FC = ({match, history}) => {
+export const DashboardPage: React.FC = () => {
+ const {id} = useParams<{id: string}>();
+ const navigate = useNavigate();
const [addRef, setAddRef] = React.useState(null);
const endRef = React.useRef(null);
const [changeMode, setChangeMode] = React.useState(false);
@@ -88,13 +93,13 @@ export const DashboardPage: React.FC = ({match, history}) => {
const [preview, setPreview] = React.useState(false);
const [diagramRanges, setDiagramRanges] = React.useState>({});
const [ranges, setRanges] = useStateAndDelegateWithDelayOnChange>({}, setDiagramRanges, 2000);
- const [addEntry, setAddEntry] = React.useState(null);
- const [[editElement, editEntry], setEdit] = React.useState<[null] | [HTMLElement, Dashboards_dashboards_items]>([null]);
- const {loading, data, error} = useQuery(gqlDashboard.Dashboards);
- const [updatePos] = useMutation(gqlDashboard.UpdatePos, {
+ const [addEntry, setAddEntry] = React.useState(null);
+ const [[editElement, editEntry], setEdit] = React.useState<[null] | [HTMLElement, DashboardItem]>([null]);
+ const {loading, data, error} = useQuery(gqlDashboard.Dashboards);
+ const [updatePos] = useMutation(gqlDashboard.UpdatePos, {
refetchQueries: [{query: gqlDashboard.Dashboards}],
});
- const [removeDashboardEntry] = useMutation(
+ const [removeDashboardEntry] = useMutation(
gqlDashboard.RemoveDashboardEntry,
{
refetchQueries: [{query: gqlDashboard.Dashboards}],
@@ -110,15 +115,15 @@ export const DashboardPage: React.FC = ({match, history}) => {
const dashboards = data.dashboards || [];
- if (!match || !match.params.id) {
+ if (!id) {
enqueueSnackbar('id parameter is missing in url', {variant: 'warning'});
- history.push('/dashboards');
+ navigate('/dashboards');
return <>>;
}
- const dashboard = dashboards.find((db) => '' + db.id === match.params.id);
+ const dashboard = dashboards.find((db) => '' + db.id === id);
if (!dashboard) {
enqueueSnackbar('dashboard does not exist', {variant: 'warning'});
- history.push('/dashboards');
+ navigate('/dashboards');
return <>>;
}
@@ -222,7 +227,7 @@ export const DashboardPage: React.FC = ({match, history}) => {
{changeMode ? (
setEdit([elm, clone(entry)])}
+ doEdit={(elm) => setEdit([elm, structuredClone(entry)])}
doDelete={() => removeDashboardEntry({variables: {id: entry.id}})}
/>
@@ -257,7 +262,7 @@ export const DashboardPage: React.FC = ({match, history}) => {
entry={addEntry}
anchorEl={addRef}
onChange={(e) => {
- return setAddEntry(clone(e));
+ return setAddEntry(structuredClone(e));
}}
finish={() => {
setAddEntry(null);
diff --git a/ui/src/dashboard/DashboardsPage.tsx b/ui/src/dashboard/DashboardsPage.tsx
index 0911bd18..63fed861 100644
--- a/ui/src/dashboard/DashboardsPage.tsx
+++ b/ui/src/dashboard/DashboardsPage.tsx
@@ -1,31 +1,40 @@
import * as React from 'react';
-import Paper from '@material-ui/core/Paper';
-import {useMutation, useQuery} from '@apollo/react-hooks';
+import Paper from '@mui/material/Paper';
+import {useMutation, useQuery} from '@apollo/client';
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 Table from '@mui/material/Table';
+import TableBody from '@mui/material/TableBody';
+import TableCell from '@mui/material/TableCell';
+import TableHead from '@mui/material/TableHead';
+import TableRow from '@mui/material/TableRow';
+import DeleteIcon from '@mui/icons-material/Delete';
+import EditIcon from '@mui/icons-material/Edit';
+import DoneIcon from '@mui/icons-material/Done';
+import CloseIcon from '@mui/icons-material/Close';
+import IconButton from '@mui/material/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 {TextField} from '@mui/material';
+import Button from '@mui/material/Button';
+import {
+ DashboardsQuery,
+ RemoveDashboardMutation,
+ RemoveDashboardMutationVariables,
+ UpdateDashboardMutation,
+ UpdateDashboardMutationVariables,
+} from '../gql/__generated__';
import {AddDashboardDialog} from './AddDashboardDialog';
-import makeStyles from '@material-ui/core/styles/makeStyles';
+import makeStyles from '@mui/styles/makeStyles';
import {ConfirmDialog} from '../common/ConfirmDialog';
const useStyles = makeStyles((theme) => ({
root: {
- ...theme.mixins.gutters(),
+ paddingLeft: theme.spacing(2),
+ paddingRight: theme.spacing(2),
+ [theme.breakpoints.up('sm')]: {
+ paddingLeft: theme.spacing(3),
+ paddingRight: theme.spacing(3),
+ },
paddingTop: theme.spacing(3),
paddingBottom: theme.spacing(3),
textAlign: 'center',
@@ -38,14 +47,20 @@ const NoEdit = [-1, ''] as const;
export const DashboardsPage = () => {
const classes = useStyles();
- const {loading, data} = useQuery(gqlDashboard.Dashboards);
+ const {loading, data} = useQuery(gqlDashboard.Dashboards);
const [addDashboard, setAddDashboard] = React.useState(false);
const [removeDashboardConfirm, setRemoveDashboardConfirm] = React.useState(false);
const refetch = {refetchQueries: [{query: gqlDashboard.Dashboards}]};
const {enqueueSnackbar} = useSnackbar();
- const [removeDashboard] = useMutation(gqlDashboard.RemoveDashboard, refetch);
+ const [removeDashboard] = useMutation(
+ gqlDashboard.RemoveDashboard,
+ refetch
+ );
const [[editId, editName], setEditing] = React.useState>(NoEdit);
- const [updateDashboard] = useMutation(gqlDashboard.UpdateDashboard, refetch);
+ const [updateDashboard] = useMutation(
+ gqlDashboard.UpdateDashboard,
+ refetch
+ );
if (loading || !data || !data.dashboards) {
return ;
}
@@ -90,19 +105,22 @@ export const DashboardsPage = () => {
{isEdited ? (
<>
-
+
- setEditing(NoEdit)} title="Cancel">
+ setEditing(NoEdit)} title="Cancel" size="large">
>
) : (
<>
- setEditing([dashboard.id, dashboard.name])} title="Edit">
+ setEditing([dashboard.id, dashboard.name])} title="Edit" size="large">
- setRemoveDashboardConfirm([dashboard.id, dashboard.name])} title="Delete">
+ setRemoveDashboardConfirm([dashboard.id, dashboard.name])}
+ title="Delete"
+ size="large">
>
diff --git a/ui/src/dashboard/DateRanges.tsx b/ui/src/dashboard/DateRanges.tsx
index ec282005..488c011a 100644
--- a/ui/src/dashboard/DateRanges.tsx
+++ b/ui/src/dashboard/DateRanges.tsx
@@ -1,24 +1,29 @@
import * as React from 'react';
-import {useMutation} from '@apollo/react-hooks';
+import {useMutation} from '@apollo/client';
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 {Dashboard, DashboardRange} from '../gql/types';
+import {IconButton, Paper, Typography} from '@mui/material';
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 ArrowForwardIcon from '@mui/icons-material/ArrowForward';
+import MoreVert from '@mui/icons-material/MoreVert';
+import PlusIcon from '@mui/icons-material/Add';
+import Menu from '@mui/material/Menu';
+import MenuItem from '@mui/material/MenuItem';
+import {
+ RemoveDashboardRangeMutation,
+ RemoveDashboardRangeMutationVariables,
+ UpdateDashboardRangeMutation,
+ UpdateDashboardRangeMutationVariables,
+ AddDashboardRangeMutation,
+ AddDashboardRangeMutationVariables,
+} from '../gql/__generated__';
import {stripTypename} from '../utils/strip';
import {Range} from '../utils/range';
-import {AddDashboardRange, AddDashboardRangeVariables} from '../gql/__generated__/AddDashboardRange';
-import Input from '@material-ui/core/Input';
+import Input from '@mui/material/Input';
interface Props {
changeMode: boolean;
- dashboard: Dashboards_dashboards;
+ dashboard: Dashboard;
setRanges: (cb: (ranges: Record) => Record) => void;
ranges: Record;
}
@@ -29,16 +34,25 @@ export const DateRanges: React.FC = ({changeMode, dashboard, ranges, setR
const [editedNames, setEditedNames] = React.useState>({});
const [openMenu, setOpenMenu] = React.useState(null);
- const [removeRange] = useMutation(gqlDashboard.RemoveDashboardRange, {
- refetchQueries: [{query: gqlDashboard.Dashboards}],
- });
- const [updateRange] = useMutation(gqlDashboard.UpdateDashboardRange, {
- refetchQueries: [{query: gqlDashboard.Dashboards}],
- });
- const [addRange] = useMutation(gqlDashboard.AddDashboardRange, {
- refetchQueries: [{query: gqlDashboard.Dashboards}],
- });
- const saveRanges = (range: Dashboards_dashboards_ranges, newRange: Range, newName: string) => {
+ const [removeRange] = useMutation(
+ gqlDashboard.RemoveDashboardRange,
+ {
+ refetchQueries: [{query: gqlDashboard.Dashboards}],
+ }
+ );
+ const [updateRange] = useMutation(
+ gqlDashboard.UpdateDashboardRange,
+ {
+ refetchQueries: [{query: gqlDashboard.Dashboards}],
+ }
+ );
+ const [addRange] = useMutation(
+ gqlDashboard.AddDashboardRange,
+ {
+ refetchQueries: [{query: gqlDashboard.Dashboards}],
+ }
+ );
+ const saveRanges = (range: DashboardRange, newRange: Range, newName: string) => {
if (saveRef.current) {
clearTimeout(saveRef.current);
}
@@ -143,9 +157,7 @@ export const DateRanges: React.FC = ({changeMode, dashboard, ranges, setR
>
- ) : (
- undefined
- )}
+ ) : undefined}
{openMenu && openMenu[1] === range.id ? (
- ) : (
- undefined
- )}
+ ) : undefined}
);
@@ -201,13 +211,12 @@ export const DateRanges: React.FC = ({changeMode, dashboard, ranges, setR
range: {name: 'new range', editable: true, range: {from: 'now/w', to: 'now/w'}},
},
});
- }}>
+ }}
+ size="large">
- ) : (
- undefined
- )}
+ ) : undefined}
>
);
};
diff --git a/ui/src/dashboard/Entry/AddPopup.tsx b/ui/src/dashboard/Entry/AddPopup.tsx
index 43bf8789..b76f83ac 100644
--- a/ui/src/dashboard/Entry/AddPopup.tsx
+++ b/ui/src/dashboard/Entry/AddPopup.tsx
@@ -1,23 +1,23 @@
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 Popper from '@mui/material/Popper';
+import ClickAwayListener from '@mui/material/ClickAwayListener';
+import {Paper} from '@mui/material';
+import Typography from '@mui/material/Typography';
+import Button from '@mui/material/Button';
+import {DashboardItem} from '../../gql/types';
+import {useMutation} from '@apollo/client';
import * as gqlDashboard from '../../gql/dashboard';
import {Fade} from '../../common/Fade';
import {DashboardEntryForm, isValidDashboardEntry} from './DashboardEntryForm';
-import {AddDashboardEntry, AddDashboardEntryVariables} from '../../gql/__generated__/AddDashboardEntry';
+import {AddDashboardEntryMutation, AddDashboardEntryMutationVariables} from '../../gql/__generated__';
import {handleError} from '../../utils/errors';
import {useSnackbar} from 'notistack';
interface EditPopupProps {
dashboardId: number;
- entry: Dashboards_dashboards_items;
+ entry: DashboardItem;
anchorEl: HTMLElement;
- onChange: (entry: Dashboards_dashboards_items | null) => void;
+ onChange: (entry: DashboardItem | null) => void;
doPreview: (preview: boolean) => void;
preview: boolean;
maxY: number;
@@ -36,9 +36,12 @@ export const AddPopup: React.FC = ({
finish,
ranges,
}) => {
- const [addEntry] = useMutation(gqlDashboard.AddDashboardEntry, {
- refetchQueries: [{query: gqlDashboard.Dashboards}],
- });
+ const [addEntry] = useMutation(
+ gqlDashboard.AddDashboardEntry,
+ {
+ refetchQueries: [{query: gqlDashboard.Dashboards}],
+ }
+ );
const valid = isValidDashboardEntry(entry);
const {enqueueSnackbar} = useSnackbar();
diff --git a/ui/src/dashboard/Entry/DashboardBarChart.tsx b/ui/src/dashboard/Entry/DashboardBarChart.tsx
index 58a45c3a..b6ba80e2 100644
--- a/ui/src/dashboard/Entry/DashboardBarChart.tsx
+++ b/ui/src/dashboard/Entry/DashboardBarChart.tsx
@@ -1,15 +1,15 @@
-import {Stats_stats} from '../../gql/__generated__/Stats';
+import {StatsRangeEntries} from '../../gql/types';
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 {StatsInterval} from '../../gql/__generated__';
import moment from 'moment';
import {TagTooltip} from './TagTooltip';
interface DashboardPieChartProps {
- entries: Stats_stats[];
+ entries: StatsRangeEntries[];
interval: StatsInterval;
type: 'stacked' | 'normal';
total: boolean;
diff --git a/ui/src/dashboard/Entry/DashboardEntry.tsx b/ui/src/dashboard/Entry/DashboardEntry.tsx
index 7653c533..337770ba 100644
--- a/ui/src/dashboard/Entry/DashboardEntry.tsx
+++ b/ui/src/dashboard/Entry/DashboardEntry.tsx
@@ -1,15 +1,13 @@
-import {Dashboards_dashboards_items} from '../../gql/__generated__/Dashboards';
+import {DashboardItem, StatEntry} from '../../gql/types';
import * as React from 'react';
import {DashboardPieChart} from './DashboardPieChart';
-import {Stats_stats_entries} from '../../gql/__generated__/Stats';
-import {useQuery} from '@apollo/react-hooks';
+import {useQuery} from '@apollo/client';
import * as gqlStats from '../../gql/statistics';
-import {Paper} from '@material-ui/core';
-import Typography from '@material-ui/core/Typography';
+import {Paper} from '@mui/material';
+import Typography from '@mui/material/Typography';
import moment from 'moment-timezone';
-import {EntryType} from '../../gql/__generated__/globalTypes';
+import {EntryType, Stats2Query, Stats2QueryVariables} from '../../gql/__generated__';
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';
@@ -18,13 +16,15 @@ import {findRange, Range} from '../../utils/range';
import {DashboardTable} from './DashboardTable';
interface DashboardEntryProps {
- entry: Dashboards_dashboards_items;
+ entry: DashboardItem;
ranges: Record;
- ref?: React.Ref;
}
-export const DashboardEntry: React.FC = React.forwardRef<{}, DashboardEntryProps>(({entry, ranges}, ref) => {
- const range = findRange(entry.statsSelection, ranges);
+export const DashboardEntry = React.forwardRef(({entry, ranges}, ref) => {
+ const range = findRange(
+ {...entry.statsSelection, range: entry.statsSelection.range ?? null, rangeId: entry.statsSelection.rangeId ?? null},
+ ranges
+ );
return (
@@ -41,13 +41,11 @@ export const DashboardEntry: React.FC = React.forwardRef<{}
});
// tslint:disable-next-line:cyclomatic-complexity mccabe-complexity
-const SpecificDashboardEntry: React.FC<{entry: Dashboards_dashboards_items; range: Range}> = ({entry, range}) => {
+const SpecificDashboardEntry: React.FC<{entry: DashboardItem; range: Range}> = ({entry, range}) => {
const interval = entry.statsSelection.interval;
- const stats = useQuery(gqlStats.Stats2, {
+ const stats = useQuery(gqlStats.Stats2, {
variables: {
- now: moment()
- .startOf('hour')
- .format(),
+ now: moment().startOf('hour').format(),
stats: {
range,
interval,
@@ -69,7 +67,7 @@ const SpecificDashboardEntry: React.FC<{entry: Dashboards_dashboards_items; rang
);
}
- const firstEntries: Stats_stats_entries[] =
+ const firstEntries: StatEntry[] =
(stats.data && stats.data.stats && stats.data.stats[0] && stats.data.stats[0].entries) || [];
if (firstEntries.length === 0) {
return (
diff --git a/ui/src/dashboard/Entry/DashboardEntryForm.tsx b/ui/src/dashboard/Entry/DashboardEntryForm.tsx
index 19d3f2e6..db3dd1ba 100644
--- a/ui/src/dashboard/Entry/DashboardEntryForm.tsx
+++ b/ui/src/dashboard/Entry/DashboardEntryForm.tsx
@@ -1,29 +1,28 @@
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 TextField from '@mui/material/TextField';
+import FormControl from '@mui/material/FormControl';
+import InputLabel from '@mui/material/InputLabel';
+import Select from '@mui/material/NativeSelect';
+import {useQuery} from '@apollo/client';
+import {TagsQuery, EntryType, StatsInterval} from '../../gql/__generated__';
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 {DashboardItem, DashboardItemRange} from '../../gql/types';
import {RelativeDateTimeSelector} from '../../common/RelativeDateTimeSelector';
import {parseRelativeTime} from '../../utils/time';
-import {Grid, Typography, Switch} from '@material-ui/core';
+import {Grid, Typography, Switch} from '@mui/material';
interface EditPopupProps {
- entry: Dashboards_dashboards_items;
- onChange: (entry: Dashboards_dashboards_items | null) => void;
+ entry: DashboardItem;
+ onChange: (entry: DashboardItem | null) => void;
disabled?: boolean;
ranges: Record;
}
-export const isValidDashboardEntry = (item: Dashboards_dashboards_items): boolean => {
+export const isValidDashboardEntry = (item: DashboardItem): boolean => {
return (
- item.statsSelection.tags !== null &&
+ item.statsSelection.tags != null &&
item.statsSelection.tags.length > 0 &&
((item.statsSelection.range &&
parseRelativeTime(item.statsSelection.range.from, 'startOf').success &&
@@ -35,7 +34,7 @@ export const isValidDashboardEntry = (item: Dashboards_dashboards_items): boolea
export const DashboardEntryForm: React.FC = ({entry, onChange: setEntry, disabled = false, ranges}) => {
const [staticRange, setStaticRange] = React.useState(!entry.statsSelection.rangeId);
- const tagsResult = useQuery(gqlTags.Tags);
+ const tagsResult = useQuery(gqlTags.Tags);
let tagKeys;
let excludeTags;
@@ -47,7 +46,7 @@ export const DashboardEntryForm: React.FC = ({entry, onChange: s
includeTags = toTagSelectorEntry(tagsResult.data.tags, entry.statsSelection.includeTags || []);
}
- const range: Dashboards_dashboards_items_statsSelection_range = entry.statsSelection.range
+ const range: DashboardItemRange = entry.statsSelection.range
? entry.statsSelection.range
: {
from: 'now/w',
@@ -165,9 +164,7 @@ export const DashboardEntryForm: React.FC = ({entry, onChange: s
entry.statsSelection.range = null;
setEntry(entry);
}}>
- {entry.statsSelection.rangeId ? (
- undefined
- ) : (
+ {entry.statsSelection.rangeId ? undefined : (
@@ -194,9 +191,7 @@ export const DashboardEntryForm: React.FC = ({entry, onChange: s
- ) : (
- undefined
- )}
+ ) : undefined}
= ({entries}) => {
@@ -19,7 +19,9 @@ export const DashboardPieChart: React.FC = ({entries}) =
isAnimationActive={false}
dataKey="timeSpendInSeconds"
nameKey={(entry) => {
- // tslint:disable-next-line:no-any
+ // recharts' own types don't allow nameKey to be a function returning a
+ // composite string; the runtime accepts it fine.
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
return (entry.key + ':' + entry.value) as any;
}}
data={entries}
diff --git a/ui/src/dashboard/Entry/DashboardTable.tsx b/ui/src/dashboard/Entry/DashboardTable.tsx
index 7241324a..611ac1b0 100644
--- a/ui/src/dashboard/Entry/DashboardTable.tsx
+++ b/ui/src/dashboard/Entry/DashboardTable.tsx
@@ -1,14 +1,14 @@
-import {Stats_stats} from '../../gql/__generated__/Stats';
import * as React from 'react';
-import {StatsInterval} from '../../gql/__generated__/globalTypes';
+import {StatsInterval} from '../../gql/__generated__';
+import {StatsRangeEntries} from '../../gql/types';
import moment from 'moment';
-import {Table, TableBody, TableCell, TableRow} from '@material-ui/core';
-import TableHead from '@material-ui/core/TableHead';
+import {Table, TableBody, TableCell, TableRow} from '@mui/material';
+import TableHead from '@mui/material/TableHead';
import {ofInterval} from './dateformat';
import prettyMs from 'pretty-ms';
interface DashboardTableProps {
- entries: Stats_stats[];
+ entries: StatsRangeEntries[];
interval: StatsInterval;
mode: 'vertical' | 'horizontal';
total: boolean;
diff --git a/ui/src/dashboard/Entry/EditGlass.tsx b/ui/src/dashboard/Entry/EditGlass.tsx
index eddb04d5..d4454ddb 100644
--- a/ui/src/dashboard/Entry/EditGlass.tsx
+++ b/ui/src/dashboard/Entry/EditGlass.tsx
@@ -1,9 +1,9 @@
import * as React from 'react';
-import EditIcon from '@material-ui/icons/Edit';
-import Typography from '@material-ui/core/Typography';
+import EditIcon from '@mui/icons-material/Edit';
+import Typography from '@mui/material/Typography';
import {Center} from '../../common/Center';
-import DeleteIcon from '@material-ui/icons/Delete';
-import {Button} from '@material-ui/core';
+import DeleteIcon from '@mui/icons-material/Delete';
+import {Button} from '@mui/material';
interface EditGlassProps {
doEdit: (elm: HTMLElement) => void;
diff --git a/ui/src/dashboard/Entry/EditPopup.tsx b/ui/src/dashboard/Entry/EditPopup.tsx
index 6519a4a0..75f02daf 100644
--- a/ui/src/dashboard/Entry/EditPopup.tsx
+++ b/ui/src/dashboard/Entry/EditPopup.tsx
@@ -1,30 +1,33 @@
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 Popper from '@mui/material/Popper';
+import ClickAwayListener from '@mui/material/ClickAwayListener';
+import {Paper} from '@mui/material';
+import Typography from '@mui/material/Typography';
+import Button from '@mui/material/Button';
+import {DashboardItem} from '../../gql/types';
+import {useMutation} from '@apollo/client';
import * as gqlDashboard from '../../gql/dashboard';
-import {UpdateDashboardEntry, UpdateDashboardEntryVariables} from '../../gql/__generated__/UpdateDashboardEntry';
+import {UpdateDashboardEntryMutation, UpdateDashboardEntryMutationVariables} from '../../gql/__generated__';
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;
+ entry: DashboardItem;
anchorEl: HTMLElement;
- onChange: (entry: Dashboards_dashboards_items | null) => void;
+ onChange: (entry: DashboardItem | null) => void;
doPreview: (preview: boolean) => void;
preview: boolean;
ranges: Record;
}
export const EditPopup: React.FC = ({entry, anchorEl, onChange: setEdit, doPreview, preview, ranges}) => {
- const [updateEntry] = useMutation(gqlDashboard.UpdateDashboardEntry, {
- refetchQueries: [{query: gqlDashboard.Dashboards}],
- });
+ const [updateEntry] = useMutation(
+ gqlDashboard.UpdateDashboardEntry,
+ {
+ refetchQueries: [{query: gqlDashboard.Dashboards}],
+ }
+ );
const valid = isValidDashboardEntry(entry);
const {enqueueSnackbar} = useSnackbar();
diff --git a/ui/src/dashboard/Entry/TagTooltip.tsx b/ui/src/dashboard/Entry/TagTooltip.tsx
index 4100ee33..2322b199 100644
--- a/ui/src/dashboard/Entry/TagTooltip.tsx
+++ b/ui/src/dashboard/Entry/TagTooltip.tsx
@@ -1,7 +1,7 @@
import {TooltipProps, TooltipPayload} from 'recharts';
import {FInterval} from './dateformat';
-import Paper from '@material-ui/core/Paper';
-import {Typography} from '@material-ui/core';
+import Paper from '@mui/material/Paper';
+import {Typography} from '@mui/material';
import moment from 'moment-timezone';
import prettyMs from 'pretty-ms';
import * as React from 'react';
@@ -25,9 +25,7 @@ export const TagTooltip = ({active, payload, dateFormat, total}: TooltipProps &
Total:
{prettyMs(sum(payload) * 1000)}
- ) : (
- undefined
- )}
+ ) : undefined}
);
}
diff --git a/ui/src/dashboard/Entry/dateformat.ts b/ui/src/dashboard/Entry/dateformat.ts
index 9cc2c42a..6d652c33 100644
--- a/ui/src/dashboard/Entry/dateformat.ts
+++ b/ui/src/dashboard/Entry/dateformat.ts
@@ -1,4 +1,4 @@
-import {StatsInterval} from '../../gql/__generated__/globalTypes';
+import {StatsInterval} from '../../gql/__generated__';
import * as moment from 'moment-timezone';
import {expectNever} from '../../utils/never';
diff --git a/ui/src/devices/AddDeviceDialog.tsx b/ui/src/devices/AddDeviceDialog.tsx
index 39d6104f..ab6138bd 100644
--- a/ui/src/devices/AddDeviceDialog.tsx
+++ b/ui/src/devices/AddDeviceDialog.tsx
@@ -1,22 +1,20 @@
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 Button from '@mui/material/Button';
+import TextField from '@mui/material/TextField';
+import Dialog from '@mui/material/Dialog';
+import DialogActions from '@mui/material/DialogActions';
+import DialogContent from '@mui/material/DialogContent';
+import DialogContentText from '@mui/material/DialogContentText';
+import DialogTitle from '@mui/material/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 {FetchResult, useMutation} from '@apollo/client';
+import {CreateDeviceMutation, CreateDeviceMutationVariables, DeviceType} from '../gql/__generated__';
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';
+import Select from '@mui/material/NativeSelect';
+import FormControl from '@mui/material/FormControl';
+import InputLabel from '@mui/material/InputLabel';
interface AddTagDialogProps {
initialName: string;
@@ -30,13 +28,13 @@ export const AddDeviceDialog: React.FC = ({close, open, initi
const [deviceType, setDeviceType] = React.useState(DeviceType.NoExpiry);
const {enqueueSnackbar} = useSnackbar();
- const [addDevice] = useMutation(gqlDevice.CreateDevice, {
+ const [addDevice] = useMutation(gqlDevice.CreateDevice, {
refetchQueries: [{query: gqlDevice.Devices}],
});
const submit = (e: React.FormEvent) => {
e.preventDefault();
addDevice({variables: {deviceType, name}})
- .then((result: MutationFetchResult) => {
+ .then((result: FetchResult) => {
if (result.data && result.data.device) {
enqueueSnackbar('Client created', {variant: 'success'});
setToken(result.data.device.token);
diff --git a/ui/src/devices/DevicesPage.tsx b/ui/src/devices/DevicesPage.tsx
index ef707eaf..9a003872 100644
--- a/ui/src/devices/DevicesPage.tsx
+++ b/ui/src/devices/DevicesPage.tsx
@@ -1,35 +1,44 @@
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 Paper from '@mui/material/Paper';
+import makeStyles from '@mui/styles/makeStyles';
+import {useMutation, useQuery} from '@apollo/client';
import * as gqlDevice from '../gql/device';
import * as gqlUser from '../gql/user';
-import {Devices} from '../gql/__generated__/Devices';
+import {
+ DevicesQuery,
+ RemoveDeviceMutation,
+ RemoveDeviceMutationVariables,
+ UpdateDeviceMutation,
+ UpdateDeviceMutationVariables,
+ DeviceType,
+} from '../gql/__generated__';
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 Table from '@mui/material/Table';
+import TableBody from '@mui/material/TableBody';
+import TableCell from '@mui/material/TableCell';
+import TableHead from '@mui/material/TableHead';
+import TableRow from '@mui/material/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 DeleteIcon from '@mui/icons-material/Delete';
+import EditIcon from '@mui/icons-material/Edit';
+import DoneIcon from '@mui/icons-material/Done';
+import CloseIcon from '@mui/icons-material/Close';
+import IconButton from '@mui/material/IconButton';
import {useSnackbar} from 'notistack';
-import {TextField} from '@material-ui/core';
-import Button from '@material-ui/core/Button';
+import {TextField} from '@mui/material';
+import Button from '@mui/material/Button';
import {AddDeviceDialog} from './AddDeviceDialog';
-import {DeviceType} from '../gql/__generated__/globalTypes';
import {deviceTypeToString} from './typeutils';
-import Select from '@material-ui/core/NativeSelect';
+import Select from '@mui/material/NativeSelect';
const useStyles = makeStyles((theme) => ({
root: {
- ...theme.mixins.gutters(),
+ paddingLeft: theme.spacing(2),
+ paddingRight: theme.spacing(2),
+ [theme.breakpoints.up('sm')]: {
+ paddingLeft: theme.spacing(3),
+ paddingRight: theme.spacing(3),
+ },
paddingTop: theme.spacing(3),
paddingBottom: theme.spacing(3),
textAlign: 'center',
@@ -40,17 +49,17 @@ const useStyles = makeStyles((theme) => ({
export const DevicesPage = () => {
const classes = useStyles();
- const {data, loading} = useQuery(gqlDevice.Devices);
+ const {data, loading} = useQuery(gqlDevice.Devices);
const refetch = {refetchQueries: [{query: gqlDevice.Devices}, {query: gqlUser.CurrentUser}]};
const {enqueueSnackbar} = useSnackbar();
- const [removeDevice] = useMutation(gqlDevice.RemoveDevice, refetch);
+ const [removeDevice] = useMutation(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(gqlDevice.UpdateDevice, refetch);
+ const [updateDevice] = useMutation(gqlDevice.UpdateDevice, refetch);
if (loading || !data || !data.currentDevice || !data.devices) {
return ;
}
@@ -70,6 +79,7 @@ export const DevicesPage = () => {
};
const isCurrent = device.id === data.currentDevice!.id;
const isEdited = editId === device.id;
+ const displayName = device.name + (isCurrent ? ' (current)' : '');
return (
{device.id}
@@ -87,7 +97,7 @@ export const DevicesPage = () => {
style={{minWidth: 128}}
/>
) : (
- device.name + (isCurrent ? ' (current)' : '')
+ displayName
)}
{moment(device.createdAt).fromNow()}
@@ -108,19 +118,22 @@ export const DevicesPage = () => {
{isEdited ? (
<>
-
+
- setEditing([-1, '', DeviceType.NoExpiry])} title="Cancel">
+ setEditing([-1, '', DeviceType.NoExpiry])} title="Cancel" size="large">
>
) : (
<>
- setEditing([device.id, device.name, device.type])} title="Edit">
+ setEditing([device.id, device.name, device.type])}
+ title="Edit"
+ size="large">
-
+
>
diff --git a/ui/src/devices/typeutils.ts b/ui/src/devices/typeutils.ts
index 8527f6fd..46396166 100644
--- a/ui/src/devices/typeutils.ts
+++ b/ui/src/devices/typeutils.ts
@@ -1,4 +1,4 @@
-import {DeviceType} from '../gql/__generated__/globalTypes';
+import {DeviceType} from '../gql/__generated__';
import {expectNever} from '../utils/never';
export const deviceTypeToString = (type: DeviceType) => {
diff --git a/ui/src/gql/dashboard.ts b/ui/src/gql/dashboard.ts
index a50f0eec..a96ff662 100644
--- a/ui/src/gql/dashboard.ts
+++ b/ui/src/gql/dashboard.ts
@@ -1,4 +1,4 @@
-import {gql} from 'apollo-boost';
+import {gql} from '@apollo/client';
export const Dashboards = gql`
query Dashboards {
diff --git a/ui/src/gql/device.ts b/ui/src/gql/device.ts
index 1a6fed90..19ff40d5 100644
--- a/ui/src/gql/device.ts
+++ b/ui/src/gql/device.ts
@@ -1,4 +1,4 @@
-import {gql} from 'apollo-boost';
+import {gql} from '@apollo/client';
export const Devices = gql`
query Devices {
diff --git a/ui/src/gql/settings.ts b/ui/src/gql/settings.ts
index 87d9d8d1..8f3ea220 100644
--- a/ui/src/gql/settings.ts
+++ b/ui/src/gql/settings.ts
@@ -1,7 +1,5 @@
-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 {gql, useQuery} from '@apollo/client';
+import {SettingsQuery as SettingsQueryResponse, DateLocale, Theme, WeekDay, DateTimeInputStyle} from './__generated__';
import {stripTypename} from '../utils/strip';
export const Settings = gql`
diff --git a/ui/src/gql/statistics.ts b/ui/src/gql/statistics.ts
index 4599b317..f4138eb3 100644
--- a/ui/src/gql/statistics.ts
+++ b/ui/src/gql/statistics.ts
@@ -1,4 +1,4 @@
-import {gql} from 'apollo-boost';
+import {gql} from '@apollo/client';
export const Stats = gql`
query Stats($ranges: [Range!], $tags: [String!], $excludeTags: [InputTimeSpanTag!], $requireTags: [InputTimeSpanTag!]) {
diff --git a/ui/src/gql/tags.ts b/ui/src/gql/tags.ts
index 67682bd2..92395c33 100644
--- a/ui/src/gql/tags.ts
+++ b/ui/src/gql/tags.ts
@@ -1,4 +1,4 @@
-import {gql} from 'apollo-boost';
+import {gql} from '@apollo/client';
export const SuggestTag = gql`
query SuggestTag($query: String!) {
diff --git a/ui/src/gql/timeSpan.ts b/ui/src/gql/timeSpan.ts
index 2307b795..07402769 100644
--- a/ui/src/gql/timeSpan.ts
+++ b/ui/src/gql/timeSpan.ts
@@ -1,4 +1,4 @@
-import {gql} from 'apollo-boost';
+import {gql} from '@apollo/client';
export const Trackers = gql`
query Trackers {
diff --git a/ui/src/gql/types.ts b/ui/src/gql/types.ts
new file mode 100644
index 00000000..71898439
--- /dev/null
+++ b/ui/src/gql/types.ts
@@ -0,0 +1,22 @@
+import {AddTimeSpanMutation, DashboardsQuery, StatsQuery, TagsQuery, TimeSpansQuery, TrackersQuery} from './__generated__';
+
+// graphql-code-generator's typescript-operations plugin inlines nested selections as anonymous
+// object types instead of apollo-codegen's old per-nesting-level named interfaces, so these are
+// hand-derived aliases for the nested shapes the app references by name in several places.
+type ArrayItem = T extends Array ? U : never;
+
+export type Dashboard = ArrayItem>;
+export type DashboardItem = ArrayItem;
+export type DashboardItemRange = NonNullable;
+export type DashboardRange = ArrayItem;
+
+export type StatsRangeEntries = ArrayItem>;
+export type StatEntry = ArrayItem>;
+
+export type Tag = ArrayItem>;
+
+export type TimeSpanItem = ArrayItem;
+
+export type Tracker = ArrayItem>;
+
+export type CreatedTimeSpan = NonNullable;
diff --git a/ui/src/gql/user.ts b/ui/src/gql/user.ts
index d4647c0e..758660be 100644
--- a/ui/src/gql/user.ts
+++ b/ui/src/gql/user.ts
@@ -1,4 +1,4 @@
-import {gql} from 'apollo-boost';
+import {gql} from '@apollo/client';
export const CurrentUser = gql`
query CurrentUser {
diff --git a/ui/src/gql/utils.ts b/ui/src/gql/utils.ts
index 9185eb4c..cf74a38e 100644
--- a/ui/src/gql/utils.ts
+++ b/ui/src/gql/utils.ts
@@ -1,21 +1,24 @@
-import {DataProxy} from 'apollo-cache';
-import {TimeSpans} from './__generated__/TimeSpans';
+import {DataProxy} from '@apollo/client';
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';
+import {CreatedTimeSpan} from './types';
+import {
+ TimeSpansQuery,
+ TimeSpansInRangeQuery,
+ TimeSpansInRangeQueryVariables,
+ TrackersQuery,
+ StopTimerMutation,
+} from './__generated__';
-export const addTimeSpanToCache = (cache: DataProxy, ts: AddTimeSpan_createTimeSpan) => {
- let oldTimeSpans: TimeSpans | null = null;
+export const addTimeSpanToCache = (cache: DataProxy, ts: CreatedTimeSpan) => {
+ let oldTimeSpans: TimeSpansQuery | null = null;
try {
- oldTimeSpans = cache.readQuery({query: gqlTimeSpan.TimeSpans});
+ oldTimeSpans = cache.readQuery({query: gqlTimeSpan.TimeSpans});
} catch {}
if (!oldTimeSpans) {
return;
}
- cache.writeQuery({
+ cache.writeQuery({
query: gqlTimeSpan.TimeSpans,
data: {
timeSpans: {
@@ -28,12 +31,12 @@ export const addTimeSpanToCache = (cache: DataProxy, ts: AddTimeSpan_createTimeS
},
});
};
-export const addTimeSpanInRangeToCache = (cache: DataProxy, ts: AddTimeSpan_createTimeSpan, vars: TimeSpansInRangeVariables) => {
- const oldTimeSpans = cache.readQuery({query: gqlTimeSpan.TimeSpansInRange, variables: vars});
+export const addTimeSpanInRangeToCache = (cache: DataProxy, ts: CreatedTimeSpan, vars: TimeSpansInRangeQueryVariables) => {
+ const oldTimeSpans = cache.readQuery({query: gqlTimeSpan.TimeSpansInRange, variables: vars});
if (!oldTimeSpans) {
return;
}
- cache.writeQuery({
+ cache.writeQuery({
query: gqlTimeSpan.TimeSpansInRange,
variables: vars,
data: {
@@ -47,12 +50,12 @@ export const addTimeSpanInRangeToCache = (cache: DataProxy, ts: AddTimeSpan_crea
},
});
};
-export const removeFromTrackersCache = (cache: DataProxy, data: StopTimer) => {
- const oldTrackers = cache.readQuery({query: gqlTimeSpan.Trackers});
+export const removeFromTrackersCache = (cache: DataProxy, data: StopTimerMutation) => {
+ const oldTrackers = cache.readQuery({query: gqlTimeSpan.Trackers});
if (!oldTrackers || !data || !data.stopTimeSpan) {
return;
}
- cache.writeQuery({
+ cache.writeQuery({
query: gqlTimeSpan.Trackers,
data: {
timers: (oldTrackers.timers || []).filter((tracker) => tracker.id !== data.stopTimeSpan!.id),
@@ -60,12 +63,12 @@ export const removeFromTrackersCache = (cache: DataProxy, data: StopTimer) => {
});
};
-export const removeFromTimeSpanInRangeCache = (cache: DataProxy, id: number, vars: TimeSpansInRangeVariables) => {
- const old = cache.readQuery({query: gqlTimeSpan.TimeSpansInRange, variables: vars});
+export const removeFromTimeSpanInRangeCache = (cache: DataProxy, id: number, vars: TimeSpansInRangeQueryVariables) => {
+ const old = cache.readQuery({query: gqlTimeSpan.TimeSpansInRange, variables: vars});
if (!old) {
return;
}
- cache.writeQuery({
+ cache.writeQuery({
query: gqlTimeSpan.TimeSpansInRange,
variables: vars,
data: {
diff --git a/ui/src/gql/version.ts b/ui/src/gql/version.ts
index 05493ddf..ff03f4c6 100644
--- a/ui/src/gql/version.ts
+++ b/ui/src/gql/version.ts
@@ -1,5 +1,5 @@
-import {gql} from 'apollo-boost';
-import {Version as VersionResponse} from './__generated__/Version';
+import {gql} from '@apollo/client';
+import {VersionQuery as VersionResponse} from './__generated__';
export const Version = gql`
query Version {
diff --git a/ui/src/index.tsx b/ui/src/index.tsx
index 95809fc8..939cac38 100644
--- a/ui/src/index.tsx
+++ b/ui/src/index.tsx
@@ -1,8 +1,4 @@
-import React from 'react';
-import ReactDOM from 'react-dom';
+import {createRoot} from 'react-dom/client';
import {Root} from './Root';
-import 'moment/locale/de';
-import 'moment/locale/en-au';
-import 'moment/locale/en-gb';
-ReactDOM.render(, document.getElementById('root'));
+createRoot(document.getElementById('root')!).render();
diff --git a/ui/src/login/LoginForm.tsx b/ui/src/login/LoginForm.tsx
index 4ef6e425..1a9c7946 100644
--- a/ui/src/login/LoginForm.tsx
+++ b/ui/src/login/LoginForm.tsx
@@ -1,15 +1,14 @@
import * as React from 'react';
-import TextField from '@material-ui/core/TextField';
-import Button from '@material-ui/core/Button';
+import TextField from '@mui/material/TextField';
+import Button from '@mui/material/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 {LoginMutation, LoginMutationVariables, DeviceType} from '../gql/__generated__';
+import {useMutation} from '@apollo/client';
+import {Checkbox} from '@mui/material';
+import FormControlLabel from '@mui/material/FormControlLabel';
+import makeStyles from '@mui/styles/makeStyles';
import {Settings as SettingsGQL} from '../gql/settings';
const useStyles = makeStyles((theme) => ({
@@ -20,7 +19,7 @@ const useStyles = makeStyles((theme) => ({
export const LoginForm = () => {
const classes = useStyles();
- const [login] = useMutation(gqlUser.Login, {
+ const [login] = useMutation(gqlUser.Login, {
update: (cache, {data}) => {
cache.writeQuery({query: gqlUser.CurrentUser, data: {user: data && data.login && data.login.user}});
},
diff --git a/ui/src/login/LoginPage.tsx b/ui/src/login/LoginPage.tsx
index 6ce4ccef..3a2d3083 100644
--- a/ui/src/login/LoginPage.tsx
+++ b/ui/src/login/LoginPage.tsx
@@ -1,13 +1,13 @@
import * as React from 'react';
-import Typography from '@material-ui/core/Typography';
-import Grid from '@material-ui/core/Grid';
+import Typography from '@mui/material/Typography';
+import Grid from '@mui/material/Grid';
import {LoginForm} from './LoginForm';
-import Link from '@material-ui/core/Link';
+import Link from '@mui/material/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';
+import {useQuery} from '@apollo/client';
+import {VersionQuery} from '../gql/__generated__';
+import makeStyles from '@mui/styles/makeStyles';
const useStyles = makeStyles(() => ({
footerLink: {
@@ -17,11 +17,11 @@ const useStyles = makeStyles(() => ({
export const LoginPage = () => {
const classes = useStyles();
- const {data: {version = gqlVersion.VersionDefault.version} = gqlVersion.VersionDefault} = useQuery(
+ const {data: {version = gqlVersion.VersionDefault.version} = gqlVersion.VersionDefault} = useQuery(
gqlVersion.Version
);
return (
-
+
diff --git a/ui/src/provider/ApolloProvider.tsx b/ui/src/provider/ApolloProvider.tsx
index 4b11bdfd..338aa143 100644
--- a/ui/src/provider/ApolloProvider.tsx
+++ b/ui/src/provider/ApolloProvider.tsx
@@ -1,16 +1,11 @@
-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';
+import {ApolloClient, ApolloProvider as Provider, HttpLink, InMemoryCache} from '@apollo/client';
const client = new ApolloClient({
- uri: './graphql',
+ link: new HttpLink({uri: './graphql'}),
+ cache: new InMemoryCache(),
});
-export const ApolloProvider: React.FC = ({children}) => {
- return (
-
- {children}
-
- );
+export const ApolloProvider: React.FC = ({children}) => {
+ return {children};
};
diff --git a/ui/src/provider/SnackbarProvider.tsx b/ui/src/provider/SnackbarProvider.tsx
index 629ab3ea..f76ddf61 100644
--- a/ui/src/provider/SnackbarProvider.tsx
+++ b/ui/src/provider/SnackbarProvider.tsx
@@ -1,29 +1,9 @@
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();
+export const SnackbarProvider: React.FC = ({children}) => {
return (
-
+
{children}
);
diff --git a/ui/src/provider/ThemeProvider.tsx b/ui/src/provider/ThemeProvider.tsx
index 678ca10f..2085c6fa 100644
--- a/ui/src/provider/ThemeProvider.tsx
+++ b/ui/src/provider/ThemeProvider.tsx
@@ -1,141 +1,154 @@
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 {createTheme, ThemeProvider as MuiThemeProvider, StyledEngineProvider, Theme, adaptV4Theme} from '@mui/material/styles';
+import CssBaseline from '@mui/material/CssBaseline';
import {useSettings} from '../gql/settings';
-import {Theme as SettingTheme} from '../gql/__generated__/globalTypes';
+import {Theme as SettingTheme} from '../gql/__generated__';
+
+declare module '@mui/styles/defaultTheme' {
+ interface DefaultTheme extends Theme {}
+}
const themes: Record = {
- [SettingTheme.GruvboxDark]: createMuiTheme({
- overrides: {
- MuiLink: {
- root: {
- color: '#458588',
+ [SettingTheme.GruvboxDark]: createTheme(
+ adaptV4Theme({
+ overrides: {
+ MuiLink: {
+ root: {
+ color: '#458588',
+ },
},
- },
- MuiIconButton: {
- root: {
- color: 'inherit',
+ MuiIconButton: {
+ root: {
+ color: 'inherit',
+ },
},
- },
- MuiListItemIcon: {
- root: {
- color: 'inherit',
+ MuiListItemIcon: {
+ root: {
+ color: 'inherit',
+ },
},
- },
- MuiToolbar: {
- root: {
- background: '#a89984',
+ 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',
+ palette: {
+ background: {
+ default: '#282828',
+ paper: '#32302f',
},
- },
- MuiIconButton: {
- root: {
- color: 'inherit',
+ text: {
+ primary: '#fbf1d4',
},
- },
- MuiListItemIcon: {
- root: {
- color: 'inherit',
+ primary: {
+ main: '#a89984',
},
- },
- MuiToolbar: {
- root: {
- background: '#7c6f64',
+ secondary: {
+ main: '#f44336',
},
- },
- },
- 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',
+ mode: 'dark',
+ },
+ })
+ ),
+ [SettingTheme.GruvboxLight]: createTheme(
+ adaptV4Theme({
+ overrides: {
+ MuiLink: {
+ root: {
+ color: '#458588',
+ },
},
- },
- MuiIconButton: {
- root: {
- color: 'inherit',
+ MuiIconButton: {
+ root: {
+ color: 'inherit',
+ },
+ },
+ MuiListItemIcon: {
+ root: {
+ color: 'inherit',
+ },
+ },
+ MuiToolbar: {
+ root: {
+ background: '#7c6f64',
+ },
},
},
- },
- palette: {
- background: {default: '#eeeeee'},
- primary: {
- main: '#455a64',
- },
- secondary: {
- main: '#f44336',
- },
- type: 'light',
- },
- }),
- [SettingTheme.MaterialDark]: createMuiTheme({
- overrides: {
- MuiLink: {
- root: {
- color: '#3498db',
+ palette: {
+ background: {
+ default: '#fbf1c7',
+ paper: '#f9f5d7',
+ },
+ text: {
+ primary: '#282828',
+ },
+ primary: {
+ main: '#7c6f64',
+ },
+ secondary: {
+ main: '#f44336',
+ },
+ mode: 'light',
+ },
+ })
+ ),
+ [SettingTheme.MaterialLight]: createTheme(
+ adaptV4Theme({
+ overrides: {
+ MuiLink: {
+ root: {
+ color: '#2980b9',
+ },
+ },
+ MuiIconButton: {
+ root: {
+ color: 'inherit',
+ },
},
},
- },
- palette: {
- primary: {
- main: '#455a64',
+ palette: {
+ background: {default: '#eeeeee'},
+ primary: {
+ main: '#455a64',
+ },
+ secondary: {
+ main: '#f44336',
+ },
+ mode: 'light',
+ },
+ })
+ ),
+ [SettingTheme.MaterialDark]: createTheme(
+ adaptV4Theme({
+ overrides: {
+ MuiLink: {
+ root: {
+ color: '#3498db',
+ },
+ },
},
- secondary: {
- main: '#f44336',
+ palette: {
+ primary: {
+ main: '#455a64',
+ },
+ secondary: {
+ main: '#f44336',
+ },
+ mode: 'dark',
},
- type: 'dark',
- },
- }),
+ })
+ ),
};
-export const ThemeProvider: React.FC = ({children}) => {
+export const ThemeProvider: React.FC = ({children}) => {
const {theme} = useSettings();
return (
-
-
- {children}
-
+
+
+
+ {children}
+
+
);
};
diff --git a/ui/src/provider/UserSettingsProvider.tsx b/ui/src/provider/UserSettingsProvider.tsx
index 9ec7a231..6623fce7 100644
--- a/ui/src/provider/UserSettingsProvider.tsx
+++ b/ui/src/provider/UserSettingsProvider.tsx
@@ -2,7 +2,8 @@ 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 '../utils/momentLocales';
+import {DateLocale, WeekDay} from '../gql/__generated__';
import {expectNever} from '../utils/never';
const setLocale = (locale: DateLocale, spec: LocaleSpecification) => {
@@ -59,20 +60,26 @@ const weekDayToMoment = (s: WeekDay): number => {
}
};
-export const BootUserSettings: React.FC = ({children}): React.ReactElement => {
+export const BootUserSettings: React.FC = ({children}): React.ReactElement => {
const {done, firstDayOfTheWeek, dateLocale} = useSettings();
+ const applied = React.useRef(null);
- React.useEffect(() => {
- if (!done) {
- return;
+ // Applied directly during render (not in a useEffect): children are rendered for the
+ // first time in this exact same pass once `done` flips to true, so a useEffect here would
+ // always run one tick too late for their first paint, leaving them stuck reading whatever
+ // locale was active before this ever ran.
+ if (done) {
+ const key = `${dateLocale}-${firstDayOfTheWeek}`;
+ if (applied.current !== key) {
+ applied.current = key;
+ setLocale(dateLocale, {
+ week: {
+ dow: weekDayToMoment(firstDayOfTheWeek),
+ doy: moment.localeData(moment.locale()).firstDayOfYear(),
+ },
+ });
}
- setLocale(dateLocale, {
- week: {
- dow: weekDayToMoment(firstDayOfTheWeek),
- doy: moment.localeData(moment.locale()).firstDayOfYear(),
- },
- });
- }, [dateLocale, firstDayOfTheWeek, done]);
+ }
if (!done) {
return ;
diff --git a/ui/src/react-app-env.d.ts b/ui/src/react-app-env.d.ts
index 6431bc5f..11f02fe2 100644
--- a/ui/src/react-app-env.d.ts
+++ b/ui/src/react-app-env.d.ts
@@ -1 +1 @@
-///
+///
diff --git a/ui/src/setting/SettingsPage.tsx b/ui/src/setting/SettingsPage.tsx
index d4b75ba4..abef4d2f 100644
--- a/ui/src/setting/SettingsPage.tsx
+++ b/ui/src/setting/SettingsPage.tsx
@@ -1,19 +1,30 @@
import * as React from 'react';
-import makeStyles from '@material-ui/core/styles/makeStyles';
-import {Paper} from '@material-ui/core';
+import makeStyles from '@mui/styles/makeStyles';
+import {Paper} from '@mui/material';
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 {useMutation} from '@apollo/client';
+import {
+ SetSettingsMutation,
+ SetSettingsMutationVariables,
+ DateLocale,
+ Theme,
+ WeekDay,
+ DateTimeInputStyle,
+} from '../gql/__generated__';
+import FormControl from '@mui/material/FormControl';
+import InputLabel from '@mui/material/InputLabel';
+import Select from '@mui/material/NativeSelect';
import {useSnackbar} from 'notistack';
import {handleError} from '../utils/errors';
const useStyles = makeStyles((theme) => ({
root: {
- ...theme.mixins.gutters(),
+ paddingLeft: theme.spacing(2),
+ paddingRight: theme.spacing(2),
+ [theme.breakpoints.up('sm')]: {
+ paddingLeft: theme.spacing(3),
+ paddingRight: theme.spacing(3),
+ },
paddingTop: theme.spacing(1),
paddingBottom: theme.spacing(3),
maxWidth: 500,
@@ -25,7 +36,7 @@ export const SettingsPage: React.FC = () => {
const classes = useStyles();
const {done, ...settings} = useSettings();
const {enqueueSnackbar} = useSnackbar();
- const [setSettings] = useMutation(SetSettingsGQL, {
+ const [setSettings] = useMutation(SetSettingsGQL, {
refetchQueries: [{query: SettingsGQL}],
});
return (
diff --git a/ui/src/tag/AddTagDialog.tsx b/ui/src/tag/AddTagDialog.tsx
index 5a48b78f..add77905 100644
--- a/ui/src/tag/AddTagDialog.tsx
+++ b/ui/src/tag/AddTagDialog.tsx
@@ -1,19 +1,18 @@
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 Button from '@mui/material/Button';
+import TextField from '@mui/material/TextField';
+import Dialog from '@mui/material/Dialog';
+import DialogActions from '@mui/material/DialogActions';
+import DialogContent from '@mui/material/DialogContent';
+import DialogContentText from '@mui/material/DialogContentText';
+import DialogTitle from '@mui/material/DialogTitle';
+import {HexColorPicker} from 'react-colorful';
+import {InputLabel} from '@mui/material';
+import FormControl from '@mui/material/FormControl';
+import {FetchResult, useMutation} from '@apollo/client';
+import {AddTagMutation, AddTagMutationVariables} from '../gql/__generated__';
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';
@@ -29,11 +28,13 @@ export const AddTagDialog: React.FC = ({close, open, initialN
const [color, setColor] = React.useState('#e6b3b3');
const {enqueueSnackbar} = useSnackbar();
- const [addTag] = useMutation(gqlTags.AddTag, {refetchQueries: [{query: gqlTags.Tags}]});
+ const [addTag] = useMutation(gqlTags.AddTag, {
+ refetchQueries: [{query: gqlTags.Tags}],
+ });
const submit = (e: React.FormEvent) => {
e.preventDefault();
addTag({variables: {name, color}})
- .then((result: MutationFetchResult | void) => {
+ .then((result: FetchResult | void) => {
close();
if (result && result.data && result.data.createTag) {
onAdded(result.data.createTag);
@@ -63,7 +64,7 @@ export const AddTagDialog: React.FC = ({close, open, initialN
Color
- setColor(c.hex)} color={color} />
+
diff --git a/ui/src/tag/FormTagSelector.tsx b/ui/src/tag/FormTagSelector.tsx
index 8113669a..0edc8d77 100644
--- a/ui/src/tag/FormTagSelector.tsx
+++ b/ui/src/tag/FormTagSelector.tsx
@@ -1,7 +1,7 @@
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 FormControl from '@mui/material/FormControl';
+import Box from '@mui/material/Box';
+import InputLabel from '@mui/material/InputLabel';
import {TagSelector, TagSelectorProps} from './TagSelector';
interface FormTagSelectorProps extends TagSelectorProps {
@@ -12,7 +12,7 @@ interface FormTagSelectorProps extends TagSelectorProps {
export const FormTagSelector = ({label, required = false, ...props}: FormTagSelectorProps) => {
return (
-
+
{label}
diff --git a/ui/src/tag/TagPage.tsx b/ui/src/tag/TagPage.tsx
index 13fe91a9..0472dcec 100644
--- a/ui/src/tag/TagPage.tsx
+++ b/ui/src/tag/TagPage.tsx
@@ -1,35 +1,44 @@
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 Paper from '@mui/material/Paper';
+import makeStyles from '@mui/styles/makeStyles';
+import {useMutation, useQuery} from '@apollo/client';
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 Table from '@mui/material/Table';
+import TableBody from '@mui/material/TableBody';
+import TableCell from '@mui/material/TableCell';
+import TableHead from '@mui/material/TableHead';
+import TableRow from '@mui/material/TableRow';
+import DeleteIcon from '@mui/icons-material/Delete';
+import EditIcon from '@mui/icons-material/Edit';
+import DoneIcon from '@mui/icons-material/Done';
+import CloseIcon from '@mui/icons-material/Close';
+import IconButton from '@mui/material/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 {TextField} from '@mui/material';
+import Button from '@mui/material/Button';
+import {
+ TagsQuery,
+ RemoveTagMutation,
+ RemoveTagMutationVariables,
+ UpdateTagMutation,
+ UpdateTagMutationVariables,
+} from '../gql/__generated__';
import {AddTagDialog} from './AddTagDialog';
-import {SliderPicker} from 'react-color';
+import {HexColorPicker} from 'react-colorful';
import {TagChip} from '../common/TagChip';
import {handleError} from '../utils/errors';
import {ConfirmDialog} from '../common/ConfirmDialog';
const useStyles = makeStyles((theme) => ({
root: {
- ...theme.mixins.gutters(),
+ paddingLeft: theme.spacing(2),
+ paddingRight: theme.spacing(2),
+ [theme.breakpoints.up('sm')]: {
+ paddingLeft: theme.spacing(3),
+ paddingRight: theme.spacing(3),
+ },
paddingTop: theme.spacing(3),
paddingBottom: theme.spacing(3),
textAlign: 'center',
@@ -40,14 +49,14 @@ const useStyles = makeStyles((theme) => ({
export const TagPage = () => {
const classes = useStyles();
- const {data, loading} = useQuery(gqlTag.Tags);
+ const {data, loading} = useQuery(gqlTag.Tags);
const [removeTagConfirm, setRemoveTagConfirm] = React.useState('');
const refetch = {refetchQueries: [{query: gqlTag.Tags}, {query: gqlDashboard.Dashboards}]};
const {enqueueSnackbar} = useSnackbar();
- const [removeTag] = useMutation(gqlTag.RemoveTag, refetch);
+ const [removeTag] = useMutation(gqlTag.RemoveTag, refetch);
const [[editKey, editKeyNew, editColor], setEditing] = React.useState<[string, string, string]>(['', '', '']);
const [addActive, setAddActive] = React.useState(false);
- const [updateTag] = useMutation(gqlTag.UpdateTag, refetch);
+ const [updateTag] = useMutation(gqlTag.UpdateTag, refetch);
if (loading || !data || !data.tags) {
return ;
}
@@ -92,7 +101,7 @@ export const TagPage = () => {
{isEdited ? (
- setEditing([editKey, editKeyNew, c.hex])} color={editColor} />
+ setEditing([editKey, editKeyNew, c])} color={editColor} />
) : (
)}
@@ -101,19 +110,19 @@ export const TagPage = () => {
{isEdited ? (
<>
-
+
- setEditing(['', '', ''])} title="Cancel">
+ setEditing(['', '', ''])} title="Cancel" size="large">
>
) : (
<>
- setEditing([tag.key, tag.key, tag.color])} title="Edit">
+ setEditing([tag.key, tag.key, tag.color])} title="Edit" size="large">
- setRemoveTagConfirm(tag.key)} title="Delete">
+ setRemoveTagConfirm(tag.key)} title="Delete" size="large">
>
diff --git a/ui/src/tag/TagSelector.tsx b/ui/src/tag/TagSelector.tsx
index dda8fc65..1aec5189 100644
--- a/ui/src/tag/TagSelector.tsx
+++ b/ui/src/tag/TagSelector.tsx
@@ -1,20 +1,22 @@
import * as React from 'react';
-import {Tags} from '../gql/__generated__/Tags';
+import {TagsQuery} from '../gql/__generated__';
import * as gqlTags from '../gql/tags';
-import {useQuery} from '@apollo/react-hooks';
+import {useQuery} from '@apollo/client';
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 Typography from '@mui/material/Typography';
+import Tooltip from '@mui/material/Tooltip';
+import MenuItem from '@mui/material/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 Paper from '@mui/material/Paper';
+import ClickAwayListener from '@mui/material/ClickAwayListener';
+import Input from '@mui/material/Input';
import {useStateAndDelegateWithDelayOnChange} from '../utils/hooks';
import {TagChip} from '../common/TagChip';
-import {makeStyles, Theme} from '@material-ui/core/styles';
+import {Theme} from '@mui/material/styles';
+
+import makeStyles from '@mui/styles/makeStyles';
const useStyles = makeStyles((theme: Theme) => ({
root: {
@@ -57,7 +59,7 @@ export const TagSelector: React.FC = ({
const input = React.useRef(null);
const container = React.useRef(null);
- const tagsResult = useQuery(gqlTags.Tags);
+ const tagsResult = useQuery(gqlTags.Tags);
const suggestions = useSuggest(tagsResult, currentValue, selectedEntries, onlySelectKeys, allowDuplicateKeys, createTags);
@@ -122,7 +124,6 @@ export const TagSelector: React.FC = ({
setSelectedEntries([...selectedEntries, entry]);
setCurrentValue('');
- return;
};
const onTagClicked = (entry: TagSelectorEntry, edit: boolean) => {
diff --git a/ui/src/tag/suggest.ts b/ui/src/tag/suggest.ts
index 040fcbce..c0469e61 100644
--- a/ui/src/tag/suggest.ts
+++ b/ui/src/tag/suggest.ts
@@ -1,12 +1,10 @@
-import {Tags} from '../gql/__generated__/Tags';
+import {TagsQuery, SuggestTagValueQuery, SuggestTagValueQueryVariables} from '../gql/__generated__';
import * as gqlTags from '../gql/tags';
-import {useQuery} from '@apollo/react-hooks';
-import {SuggestTagValue, SuggestTagValueVariables} from '../gql/__generated__/SuggestTagValue';
+import {useQuery, QueryResult} from '@apollo/client';
import {TagSelectorEntry, specialTag} from './tagSelectorEntry';
-import {QueryResult} from 'react-apollo';
export const useSuggest = (
- tagResult: QueryResult,
+ tagResult: QueryResult,
inputValue: string,
usedTags: TagSelectorEntry[],
skipValue = false,
@@ -18,9 +16,9 @@ export const useSuggest = (
const exactMatch = ((tagResult.data && tagResult.data.tags) || []).find((tag) => tag.key === tagKey);
- const valueResult = useQuery(gqlTags.SuggestTagValue, {
+ const valueResult = useQuery(gqlTags.SuggestTagValue, {
variables: {tag: tagKey, query: tagValue},
- skip: exactMatch === undefined || skipValue,
+ skip: exactMatch === undefined || skipValue || tagValue === undefined,
fetchPolicy: 'no-cache',
});
@@ -36,7 +34,7 @@ export const useSuggest = (
const suggestTag = (
exactMatch: TagSelectorEntry['tag'] | undefined,
- tagResult: QueryResult,
+ tagResult: QueryResult,
tagKey: string,
usedTags: string[],
allowDuplicateKeys: boolean,
@@ -67,7 +65,7 @@ const suggestTag = (
const suggestTagValue = (
exactMatch: TagSelectorEntry['tag'],
tagValue: string,
- valueResult: QueryResult,
+ valueResult: QueryResult,
usedValues: string[],
includeInputValueOnNoMatch: boolean
): TagSelectorEntry[] => {
diff --git a/ui/src/tag/tagSelectorEntry.ts b/ui/src/tag/tagSelectorEntry.ts
index b7ceb927..bb54e1d0 100644
--- a/ui/src/tag/tagSelectorEntry.ts
+++ b/ui/src/tag/tagSelectorEntry.ts
@@ -1,5 +1,6 @@
-import {Tags, Tags_tags} from '../gql/__generated__/Tags';
-import {QueryResult} from 'react-apollo';
+import {TagsQuery} from '../gql/__generated__';
+import {Tag} from '../gql/types';
+import {QueryResult} from '@apollo/client';
export interface TagInputError {
error: string;
@@ -15,7 +16,7 @@ export interface SpecialTag {
}
export interface TagSelectorEntry {
- tag: Omit & SpecialTag;
+ tag: Omit & SpecialTag;
value: string;
}
@@ -29,15 +30,13 @@ export const toInputTags = (entries: TagSelectorEntry[]): InputTag[] => {
};
export const toTagSelectorEntry = (tags: Array, 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,
- };
- }
- );
+ 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} => {
@@ -53,7 +52,7 @@ export const specialTag = (name: string, state: SpecialTagState): TagSelectorEnt
};
};
-const tryAdd = (tagsResult: QueryResult, entry: string, onlyKeys: boolean): TagSelectorEntry | TagInputError => {
+const tryAdd = (tagsResult: QueryResult, entry: string, onlyKeys: boolean): TagSelectorEntry | TagInputError => {
const [keySomeCase, value, ...other] = entry.split(':');
const key = keySomeCase.toLowerCase();
@@ -101,7 +100,7 @@ const groupAndCheckExistence = (onlyKeys: boolean, allowDuplicateKeys: boolean)
export const addValues = (
newValue: string,
- tagsResult: QueryResult,
+ tagsResult: QueryResult,
selectedEntries: TagSelectorEntry[],
onlyKeys: boolean,
allowDuplicateKeys: boolean
diff --git a/ui/src/timespan/ActiveTrackers.tsx b/ui/src/timespan/ActiveTrackers.tsx
index 175b65e0..6db54e4f 100644
--- a/ui/src/timespan/ActiveTrackers.tsx
+++ b/ui/src/timespan/ActiveTrackers.tsx
@@ -1,26 +1,25 @@
import * as React from 'react';
-import {useQuery} from '@apollo/react-hooks';
+import {useQuery} from '@apollo/client';
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 {TrackersQuery, TagsQuery} from '../gql/__generated__';
import {TimeSpan} from './TimeSpan';
import {toTimeSpanProps} from './timespanutils';
-import {Typography} from '@material-ui/core';
+import {Typography} from '@mui/material';
export const ActiveTrackers = () => {
- const trackersResult = useQuery(gqlTimeSpan.Trackers, {fetchPolicy: 'cache-and-network'});
- const tagsResult = useQuery(gqlTag.Tags, {fetchPolicy: 'cache-and-network'});
+ const trackersResult = useQuery(gqlTimeSpan.Trackers, {fetchPolicy: 'cache-and-network'});
+ const tagsResult = useQuery(gqlTag.Tags, {fetchPolicy: 'cache-and-network'});
const values = React.useMemo(() => {
if (
trackersResult.error ||
trackersResult.loading ||
!trackersResult.data ||
- trackersResult.data.timers === null ||
+ trackersResult.data.timers == null ||
tagsResult.error ||
tagsResult.loading ||
!tagsResult.data ||
- tagsResult.data.tags === null
+ tagsResult.data.tags == null
) {
return [];
}
diff --git a/ui/src/timespan/DoneTrackers.tsx b/ui/src/timespan/DoneTrackers.tsx
index 26c0c26d..f4c04ec7 100644
--- a/ui/src/timespan/DoneTrackers.tsx
+++ b/ui/src/timespan/DoneTrackers.tsx
@@ -1,16 +1,14 @@
import * as React from 'react';
-import {useQuery} from '@apollo/react-hooks';
+import {useQuery} from '@apollo/client';
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 {TagsQuery, TimeSpansQuery, TimeSpansQueryVariables} from '../gql/__generated__';
+import {useInterval} from '../utils/hooks';
import moment from 'moment';
-import {TimeSpans, TimeSpansVariables} from '../gql/__generated__/TimeSpans';
-import {Typography} from '@material-ui/core';
+import {Typography} from '@mui/material';
import {GroupedTimeSpanProps, toGroupedTimeSpanProps} from './timespanutils';
import {TagSelectorEntry} from '../tag/tagSelectorEntry';
-import ReactInfinite from 'react-infinite';
import {isSameDate} from '../utils/time';
interface DoneTrackersProps {
@@ -18,14 +16,13 @@ interface DoneTrackersProps {
}
export const DoneTrackers: React.FC = ({addTagsToTracker}) => {
- const trackersResult = useQuery(gqlTimeSpan.TimeSpans, {
+ const trackersResult = useQuery(gqlTimeSpan.TimeSpans, {
variables: {cursor: {pageSize: 30}},
});
const loading = React.useRef(false);
- const tagsResult = useQuery(gqlTag.Tags);
- const [infiniteLoading, setInfiniteLoading] = React.useState(false);
+ const tagsResult = useQuery(gqlTag.Tags);
const [currentDate, setCurrentDate] = React.useState(moment());
- const [heights, setHeights] = React.useState>({});
+ const loadMoreRef = React.useRef(null);
useInterval(
() => {
if (!isSameDate(currentDate, moment())) {
@@ -36,7 +33,7 @@ export const DoneTrackers: React.FC = ({addTagsToTracker}) =>
true
);
- const fetchMore = () => {
+ const fetchMore = React.useCallback(() => {
if (!trackersResult || !trackersResult.data || trackersResult.loading || loading.current) {
return;
}
@@ -51,7 +48,7 @@ export const DoneTrackers: React.FC = ({addTagsToTracker}) =>
pageSize,
},
},
- updateQuery: (prev, {fetchMoreResult}): TimeSpans => {
+ updateQuery: (prev, {fetchMoreResult}): TimeSpansQuery => {
if (!fetchMoreResult) {
return prev;
}
@@ -65,15 +62,30 @@ export const DoneTrackers: React.FC = ({addTagsToTracker}) =>
};
},
})
- .then(() => {
+ .finally(() => {
loading.current = false;
- return setInfiniteLoading(false);
- })
- .catch(() => {
- loading.current = false;
- return setInfiniteLoading(false);
});
- };
+ }, [trackersResult]);
+
+ // Loads the next page once the sentinel div at the bottom of the list scrolls into view,
+ // replacing react-infinite's windowed/virtualized approach - this list is a personal time
+ // log, not large enough to need DOM virtualization.
+ React.useEffect(() => {
+ const target = loadMoreRef.current;
+ if (!target) {
+ return;
+ }
+ const observer = new IntersectionObserver(
+ (entries) => {
+ if (entries[0].isIntersecting) {
+ fetchMore();
+ }
+ },
+ {rootMargin: '2000px 0px'}
+ );
+ observer.observe(target);
+ return () => observer.disconnect();
+ }, [fetchMore]);
const values: GroupedTimeSpanProps = React.useMemo(() => {
if (
@@ -84,7 +96,7 @@ export const DoneTrackers: React.FC = ({addTagsToTracker}) =>
tagsResult.error ||
tagsResult.loading ||
!tagsResult.data ||
- tagsResult.data.tags === null
+ tagsResult.data.tags == null
) {
return [];
}
@@ -93,51 +105,22 @@ export const DoneTrackers: React.FC = ({addTagsToTracker}) =>
return (
-
- .. loading time spans ..
-
- }
- elementHeight={values.map((m) => heights[m.key] || 500)}>
- {values.map(({key, timeSpans}) => {
- return (
-
- );
- })}
-
+ {values.map(({key, timeSpans}) => (
+
+ ))}
+
);
};
-const DatedTimeSpans: React.FC<{
- name: string;
- setHeight: (cb: (height: Record) => Record) => void;
- height: number;
- timeSpans: TimeSpanProps[];
-} & DoneTrackersProps> = ({name, timeSpans, addTagsToTracker, setHeight, height}) => {
- const ref = React.useRef();
- React.useEffect(() => {
- const currentHeight = ref.current && ref.current.getBoundingClientRect().height;
- if (currentHeight != null && currentHeight !== height) {
- setHeight((old) => ({...old, [name]: currentHeight}));
- }
- }, [ref, name, setHeight, height]);
+const DatedTimeSpans: React.FC<
+ {
+ name: string;
+ timeSpans: TimeSpanProps[];
+ } & DoneTrackersProps
+> = ({name, timeSpans, addTagsToTracker}) => {
return (
- (ref.current = r)}>
+
{name}
diff --git a/ui/src/timespan/RefreshTimespans.tsx b/ui/src/timespan/RefreshTimespans.tsx
index 1d336b1d..b3efd7b6 100644
--- a/ui/src/timespan/RefreshTimespans.tsx
+++ b/ui/src/timespan/RefreshTimespans.tsx
@@ -1,15 +1,15 @@
import * as React from 'react';
-import {useQuery} from '@apollo/react-hooks';
-import {TimeSpans, TimeSpansVariables} from '../gql/__generated__/TimeSpans';
+import {useQuery} from '@apollo/client';
+import {TimeSpansQuery, TimeSpansQueryVariables} from '../gql/__generated__';
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';
+import {Fab, Zoom} from '@mui/material';
+import RefreshIcon from '@mui/icons-material/Refresh';
export const RefreshTimeSpans: React.FC = () => {
- const {refetch, data} = useQuery
(gqlTimeSpan.TimeSpans, {
+ const {refetch, data} = useQuery(gqlTimeSpan.TimeSpans, {
variables: {cursor: {pageSize: 30}},
});
const {enqueueSnackbar, closeSnackbar} = useSnackbar();
diff --git a/ui/src/timespan/TimeSpan.tsx b/ui/src/timespan/TimeSpan.tsx
index b70ba9a2..84e15a1e 100644
--- a/ui/src/timespan/TimeSpan.tsx
+++ b/ui/src/timespan/TimeSpan.tsx
@@ -2,25 +2,32 @@ 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 Paper from '@mui/material/Paper';
import {DateTimeSelector} from '../common/DateTimeSelector';
-import {Button, TextField, Typography, makeStyles} from '@material-ui/core';
+import {Button, TextField, Typography} from '@mui/material';
+import makeStyles from '@mui/styles/makeStyles';
import {inUserTz} from './timeutils';
-import {useMutation} from '@apollo/react-hooks';
-import {StopTimer, StopTimerVariables} from '../gql/__generated__/StopTimer';
+import {useMutation} from '@apollo/client';
+import {
+ StopTimerMutation,
+ StopTimerMutationVariables,
+ UpdateTimeSpanMutation,
+ UpdateTimeSpanMutationVariables,
+ RemoveTimeSpanMutation,
+ RemoveTimeSpanMutationVariables,
+ TimeSpansQuery,
+ TrackersQuery,
+ StartTimerMutation,
+ StartTimerMutationVariables,
+} from '../gql/__generated__';
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 IconButton from '@mui/material/IconButton';
+import {MoreVert} from '@mui/icons-material';
+import Menu from '@mui/material/Menu';
+import MenuItem from '@mui/material/MenuItem';
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 {
@@ -65,6 +72,11 @@ const useStyles = makeStyles(() => ({
},
timeSelection: {
display: 'inline-flex',
+ // flex-end (not center): the start/end pickers have a floating label above their value,
+ // making them taller than the plain duration text - centering both as whole boxes leaves
+ // the duration vertically centered in the row while the picker's actual value text (below
+ // its label) sits lower, looking misaligned. Aligning bottoms lines up the value baselines.
+ alignItems: 'flex-end',
'@media (max-width: 750px)': {
justifyContent: 'space-evenly',
width: '100%',
@@ -104,7 +116,7 @@ export const TimeSpan: React.FC = React.memo(
const [openMenu, setOpenMenu] = useStateAndDelegateWithDelayOnChange(null, (o) =>
dateSelectorOpen(!!o)
);
- const [stopTimer] = useMutation(gqlTimeSpan.StopTimer, {
+ const [stopTimer] = useMutation(gqlTimeSpan.StopTimer, {
update: (cache, {data}) => {
if (!data || !data.stopTimeSpan) {
return;
@@ -113,48 +125,51 @@ export const TimeSpan: React.FC = React.memo(
addTimeSpanToCache(cache, data.stopTimeSpan);
},
});
- const [startTimer] = useMutation(gqlTimeSpan.StartTimer, {
+ const [startTimer] = useMutation(gqlTimeSpan.StartTimer, {
refetchQueries: [{query: gqlTimeSpan.Trackers}],
});
- const [updateTimeSpan] = useMutation(gqlTimeSpan.UpdateTimeSpan);
- const noteAwareUpdateTimeSpan = ({variables}: {variables: Omit}) => {
+ const [updateTimeSpan] = useMutation(gqlTimeSpan.UpdateTimeSpan);
+ const noteAwareUpdateTimeSpan = ({variables}: {variables: Omit}) => {
clearTimeout(note.current.handle);
return updateTimeSpan({variables: {...variables, note: note.current.value}});
};
- const [removeTimeSpan] = useMutation(gqlTimeSpan.RemoveTimeSpan, {
- update: (cache, {data}) => {
- let oldData: TimeSpans | null = null;
- try {
- oldData = cache.readQuery({query: gqlTimeSpan.TimeSpans});
- } catch (e) {}
+ const [removeTimeSpan] = useMutation(
+ gqlTimeSpan.RemoveTimeSpan,
+ {
+ update: (cache, {data}) => {
+ let oldData: TimeSpansQuery | null = null;
+ try {
+ oldData = cache.readQuery({query: gqlTimeSpan.TimeSpans});
+ } catch {}
- const oldTrackers = cache.readQuery({query: gqlTimeSpan.Trackers});
- if (!data || !data.removeTimeSpan) {
- return;
- }
- const removedId = data.removeTimeSpan.id;
- if (oldTrackers) {
- cache.writeQuery({
- query: gqlTimeSpan.Trackers,
- data: {
- timers: (oldTrackers.timers || []).filter((tracker) => tracker.id !== removedId),
- },
- });
- }
- if (oldData) {
- cache.writeQuery({
- query: gqlTimeSpan.TimeSpans,
- data: {
- timeSpans: {
- __typename: 'PagedTimeSpans',
- timeSpans: oldData.timeSpans.timeSpans.filter((ts) => ts.id !== removedId),
- cursor: oldData.timeSpans.cursor,
+ const oldTrackers = cache.readQuery({query: gqlTimeSpan.Trackers});
+ if (!data || !data.removeTimeSpan) {
+ return;
+ }
+ const removedId = data.removeTimeSpan.id;
+ if (oldTrackers) {
+ cache.writeQuery({
+ query: gqlTimeSpan.Trackers,
+ data: {
+ timers: (oldTrackers.timers || []).filter((tracker) => tracker.id !== removedId),
},
- },
- });
- }
- },
- });
+ });
+ }
+ if (oldData) {
+ cache.writeQuery({
+ 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);
@@ -292,7 +307,11 @@ export const TimeSpan: React.FC = React.memo(
{to ? : }
@@ -301,7 +320,8 @@ export const TimeSpan: React.FC
= React.memo(
) => setOpenMenu(e.currentTarget)}>
+ onClick={(e: React.MouseEvent) => setOpenMenu(e.currentTarget)}
+ size="large">
diff --git a/ui/src/timespan/Tracker.tsx b/ui/src/timespan/Tracker.tsx
index da53879b..be398f58 100644
--- a/ui/src/timespan/Tracker.tsx
+++ b/ui/src/timespan/Tracker.tsx
@@ -2,19 +2,23 @@ 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 {Button} from '@mui/material';
+import {MoreVert} from '@mui/icons-material';
+import IconButton from '@mui/material/IconButton';
+import Paper from '@mui/material/Paper';
+import Menu from '@mui/material/Menu';
+import MenuItem from '@mui/material/MenuItem';
+import ClickAwayListener from '@mui/material/ClickAwayListener';
import {DateTimeSelector} from '../common/DateTimeSelector';
-import {useMutation} from '@apollo/react-hooks';
+import {useMutation} from '@apollo/client';
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 {
+ StartTimerMutation,
+ StartTimerMutationVariables,
+ InputTimeSpanTag,
+ AddTimeSpanMutation,
+ AddTimeSpanMutationVariables,
+} from '../gql/__generated__';
import {useSnackbar} from 'notistack';
import {inUserTz} from './timeutils';
import {addTimeSpanToCache} from '../gql/utils';
@@ -40,10 +44,10 @@ export const Tracker: React.FC = ({selectedEntries, onSelectedEntr
const [from, setFrom] = React.useState(moment().subtract(15, 'minute'));
const [to, setTo] = React.useState(moment());
const [showDate, setShowDate] = React.useState(false);
- const [startTimer] = useMutation(gqlTimeSpan.StartTimer, {
+ const [startTimer] = useMutation(gqlTimeSpan.StartTimer, {
refetchQueries: [{query: gqlTimeSpan.Trackers}],
});
- const [addTimeSpan] = useMutation(gqlTimeSpan.AddTimeSpan, {
+ const [addTimeSpan] = useMutation(gqlTimeSpan.AddTimeSpan, {
update: (cache, {data}) => {
if (!data || !data.createTimeSpan) {
return;
@@ -61,9 +65,10 @@ export const Tracker: React.FC = ({selectedEntries, onSelectedEntr
}, [showDate, from, to]);
const submit = () => {
- const tags = selectedEntries.map(
- (entry: TagSelectorEntry): InputTimeSpanTag => ({key: entry.tag.key, value: entry.value})
- );
+ 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([]);
@@ -130,7 +135,7 @@ export const Tracker: React.FC = ({selectedEntries, onSelectedEntr
- ) => setOpenMenu(e.currentTarget)}>
+ ) => setOpenMenu(e.currentTarget)} size="large">